Merge pull request #32 from h465855hgg/feat/lyrics-plugin

feat: add lyrics plugin
This commit is contained in:
Lysec
2026-07-17 16:34:17 +02:00
committed by GitHub
11 changed files with 1635 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# Lyrics
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke
highlighting, animated line changes, and configurable online or local sources.
## Plugin
| Field | Value |
| --- | --- |
| ID | `h465855hgg/lyrics` |
| Entries | Bar widget: `lyrics`; service: `service` |
## Requirements
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media
player must expose MPRIS metadata for automatic track and playback detection.
Noctalia installs the plugin files; it does not install system packages for you.
To check or install the runtime packages automatically, run:
```sh
sh scripts/setup-deps.sh --check
sh scripts/setup-deps.sh
```
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
`pacman`, `zypper`, `apk`, and `xbps-install`.
## Usage
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
settings. The background `service` detects the active MPRIS player, resolves
lyrics, downloads or caches album artwork, and publishes playback state to the
widget.
Left-click the widget to switch between lyrics and track information. Right-click
to pause or resume the active player. Paused content is dimmed and all lyric,
transition, and marquee animation stops until playback resumes.
When synchronized lyrics are unavailable, the widget displays
`track title + artist`. Long lines pause at each end while scrolling. Intro and
instrumental gaps can show a configurable cue such as `•••••`.
## Screenshots
Status-bar widget:
![Lyrics bar widget](screenshots/widget.webp)
Plugin settings:
![Lyrics settings](screenshots/settings.webp)
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
| `max_chars` | `int` | `24` | Number of visible Unicode characters before marquee scrolling starts. |
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
| `active_color` | `color` | `primary` | Colors the current and already-sung lyric characters. |
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
## IPC
External players can set `lyrics_source` to `external` and address the singleton
service with:
```sh
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
```
Supported events:
- `push-lrc`: accepts synchronized or plain LRC text.
- `push-json`: accepts JSON with a `lines` timed array or a `lyrics` LRC string.
- `push-state`: also accepts `track`, `position`, `playing`, and `cover` fields.
- `clear`: clears the currently published lyrics.
Line timestamps and character timestamps are milliseconds. MPRIS track duration
and playback position are microseconds:
```json
{"lines":[{"time":1200,"duration":1800,"text":"Hello","chars":[1200,1500,1800,2100,2400]}]}
```
## Notes
Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
HTTP mode contacts only the configured endpoint. The plugin never reads browser
cookies or player credentials.
The service runs `playerctl` to read and control MPRIS playback, `python3` for the
LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover
files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and
downloaded cover images are written inside the plugin runtime directory. Remote
code is never downloaded or executed.
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Decode NetEase KRC ("klyric") dynamic lyrics into per-character timings.
# Reads the klyric field (base64 of "krc1" + zlib stream) from argv[1],
# writes JSON: {"type":"krc","lines":[{"time":ms,"text":"...","chars":[ms,...]}]}
import sys, json, base64, zlib, re
def find_zlib(buf):
for i in range(len(buf) - 1):
if buf[i] == 0x78 and buf[i + 1] in (0x01, 0x9c, 0xda):
try:
return zlib.decompress(buf[i:])
except Exception:
continue
return None
def decode_krc(raw):
if isinstance(raw, str) and re.search(r"^\[\d+,\d+\]", raw, re.MULTILINE):
return raw
data = None
if isinstance(raw, str):
try:
data = base64.b64decode(raw)
except Exception:
data = raw.encode("latin-1")
else:
data = raw
if data[:4] == b"krc1":
data = data[4:]
dec = find_zlib(data)
if dec is None:
return None
try:
return dec.decode("utf-8")
except Exception:
return dec.decode("utf-8", "ignore")
LINE_RE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
PREFIX_SYL_RE = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
SUFFIX_SYL_RE = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>")
def parse_krc(text):
out = []
for line in text.splitlines():
m = LINE_RE.match(line)
if not m:
continue
start = int(m.group(1))
dur = int(m.group(2))
body = m.group(3)
# NetEase has shipped both `(offset,duration,0)word` and
# `word<offset,duration>` variants of its word-synced format.
prefixed = PREFIX_SYL_RE.findall(body) if re.match(r"^[<(]\d+,", body) else []
if prefixed:
syl = [(word, int(offset), int(duration))
for offset, duration, word in prefixed]
else:
syl = [(word, int(offset), int(duration))
for word, offset, duration in SUFFIX_SYL_RE.findall(body)]
if not syl and body.strip() != "":
syl = [(body, 0, dur)]
chars = []
full = ""
for (word, so, sd) in syl:
n = len(word)
if n == 0:
continue
for j in range(n):
chars.append(start + so + (j * sd) // n)
full += word[j]
if full.strip() != "":
out.append({"time": start, "duration": dur, "text": full, "chars": chars})
return out
def main():
if len(sys.argv) < 2:
print(json.dumps({"type": "none"}))
return
try:
with open(sys.argv[1], "r", encoding="utf-8") as f:
raw = f.read()
except Exception:
print(json.dumps({"type": "none"}))
return
text = decode_krc(raw)
if text is None:
print(json.dumps({"type": "none"}))
return
print(json.dumps({"type": "krc", "lines": parse_krc(text)}, ensure_ascii=False))
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import sys, os, json, urllib.request, urllib.parse
LRCLIB = "https://lrclib.net/api/search"
def norm(s):
return "".join(ch for ch in (s or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff").lower()
def http_get(url):
req = urllib.request.Request(url, headers={
"User-Agent": "lyrics-plugin/1.0",
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=15) as r:
return r.read().decode("utf-8", "ignore")
def main():
raw = sys.argv[1] if len(sys.argv) > 1 else ""
title, artist, album = "test", "", ""
if raw and os.path.isfile(raw):
try:
with open(raw, encoding="utf-8") as f:
lines = [l.strip() for l in f.read().splitlines()]
title = lines[0] if lines else "test"
artist = lines[1] if len(lines) > 1 else ""
album = lines[2] if len(lines) > 2 else ""
except Exception as e:
out = {"type": "none", "lines": [], "lrc": "", "diag": [f"query_file_read_err={e!r}"]}
print(json.dumps(out, ensure_ascii=False))
return
else:
title = raw or "test"
out = {"type": "none", "lines": [], "lrc": "", "diag": []}
try:
q = urllib.parse.urlencode({"track_name": title, "artist_name": artist or title})
s = json.loads(http_get(LRCLIB + "?" + q))
if not s:
out["diag"].append("lrclib: no results")
print(json.dumps(out, ensure_ascii=False))
return
nart = norm(artist)
nalb = norm(album)
ntitle = norm(title)
best = None
for c in s:
cn = norm(c.get("trackName", ""))
ca = norm(c.get("artistName", ""))
if ntitle and cn != ntitle and ntitle not in cn and cn not in ntitle:
continue
if nart and ca and (nart in ca or ca in nart):
best = c
break
if nalb and norm(c.get("albumName", "")) == nalb:
best = c
break
if best is None:
best = c
if best is None:
best = s[0]
lrc = best.get("syncedLyrics") or best.get("plainLyrics") or ""
if not lrc:
out["diag"].append("lrclib: empty lyrics")
print(json.dumps(out, ensure_ascii=False))
return
out["lrc"] = lrc
out["type"] = "lrc"
out["diag"].append(f"lrclib: {best.get('trackName')} / {best.get('artistName')} synced={bool(best.get('syncedLyrics'))}")
print(json.dumps(out, ensure_ascii=False))
except Exception as e:
out["diag"].append(f"lrclib ERR: {e!r}")
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
main()
+459
View File
@@ -0,0 +1,459 @@
--!nonstrict
-- Synchronized lyrics bar widget with karaoke highlighting and long-line scrolling.
noctalia.setUpdateInterval(33)
local glyph = noctalia.getConfig("glyph")
local showArtist = noctalia.getConfig("show_artist")
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
local showCover = noctalia.getConfig("show_cover")
if showCover == nil then showCover = true end
local activeColor = noctalia.getConfig("active_color") or "primary"
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
-- These are plugin settings, not per-widget settings.
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30
local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1
local maxChars = tonumber(noctalia.getConfig("max_chars")) or 24
local charWidth = tonumber(noctalia.getConfig("char_width")) or 9
local gradientOn = noctalia.getConfig("gradient")
if gradientOn == nil then gradientOn = true end
local animation = noctalia.getConfig("animation") or "karaoke"
local cueText = noctalia.getConfig("cue_text") or "•••••"
if cueText == "" then cueText = "•••••" end
local track = nil
local lyrics = nil
local cover = nil
local playing = false
local showMode = "auto"
local rendered = false
local baselinePosition = 0
local baselineClock = os.clock()
local lastClock = 0
local displayKey = ""
local displayedLine = nil
local outgoingLine = nil
local transitionElapsed = 0
local marqueeElapsed = 0
local smoothProgress = 0
local FADE_OUT_MS = 130
local FADE_IN_MS = 260
local MARQUEE_HOLD = 1.1
local BLEND_MS = 350
local INTERLUDE_GAP_MS = 7000
local INTRO_MIN_MS = 6000
local INTERLUDE_MIN_MS = 8000
local function clamp(value, low, high)
return math.min(high, math.max(low, value))
end
local function easeOutCubic(t)
local u = 1 - clamp(t, 0, 1)
return 1 - u * u * u
end
local function toChars(text)
local chars = {}
local i = 1
while i <= #text do
local byte = text:byte(i)
local length = 1
if byte >= 0xF0 then length = 4
elseif byte >= 0xE0 then length = 3
elseif byte >= 0xC0 then length = 2 end
chars[#chars + 1] = text:sub(i, i + length - 1)
i = i + length
end
return chars
end
local function getProgressMs()
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
return baselinePosition / 1000 + elapsed
end
local function getLineInfo()
if not lyrics or #lyrics == 0 or not track then return nil end
local position = getProgressMs()
local synced = lyrics[1].time >= 0
if not synced then
return { key = "plain", index = 1, line = lyrics[1], progress = 0, synced = false }
end
local firstTime = lyrics[1].time
if firstTime >= INTRO_MIN_MS and position < firstTime then
return {
key = "intro",
index = 0,
line = { text = cueText },
progress = clamp(position / firstTime, 0, 1),
synced = true,
position = position,
cue = true,
}
end
local index = 1
for i = #lyrics, 1, -1 do
if lyrics[i].time <= position then
index = i
break
end
end
local line = lyrics[index]
local startTime = line.time
local durationMs = track.duration and track.duration > 0 and track.duration / 1000 or nil
local nextLine = lyrics[index + 1]
local nextTime = nextLine and nextLine.time
or durationMs
or (startTime + 4000)
local lineEnd = nextTime
if line.duration and line.duration > 0 then
lineEnd = math.min(nextTime, startTime + line.duration)
elseif nextTime - startTime >= INTERLUDE_GAP_MS then
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap.
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
lineEnd = math.min(nextTime, startTime + estimatedDuration)
end
-- Only show an interlude between two real lyric lines. The final lyric stays
-- visible through the outro instead of turning the whole song ending into dots.
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then
return {
key = "interlude-" .. tostring(index),
index = index,
line = { text = cueText },
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
synced = true,
position = position,
cue = true,
}
end
local progress = lineEnd > startTime and clamp((position - startTime) / (lineEnd - startTime), 0, 1) or 1
return {
key = "line-" .. tostring(index),
index = index,
line = line,
progress = progress,
synced = true,
position = position,
}
end
local function getTrackLabel()
if not track then return "--" end
if not showArtist then return track.title end
if track.artist and track.artist ~= "" then
return track.artist .. " - " .. track.title
end
return track.title
end
local function getFallbackLabel()
if not track then return "--" end
if track.artist and track.artist ~= "" then
return track.title .. " + " .. track.artist
end
return track.title
end
local function shouldMarquee(text)
return scrollMode ~= "static" and #toChars(text) > maxChars
end
local function getMarqueeOffset(length)
local distance = math.max(0, length - maxChars)
if distance == 0 then return 0 end
-- The setting is pixels per second; convert it to character cells per second.
local charsPerSecond = math.max(0.5, marqueeSpeed / math.max(1, charWidth))
local travelTime = distance / charsPerSecond
local cycle = MARQUEE_HOLD * 2 + travelTime * 2
local phase = marqueeElapsed % cycle
if phase < MARQUEE_HOLD then return 0 end
phase = phase - MARQUEE_HOLD
if phase < travelTime then return distance * (phase / travelTime) end
phase = phase - travelTime
if phase < MARQUEE_HOLD then return distance end
phase = phase - MARQUEE_HOLD
return distance * (1 - phase / travelTime)
end
local function karaokeColor(charTime, charProgress, position, useGradient)
if not playing then return inactiveColor end
local active = activeColor
local inactive = inactiveColor
if charTime and position then
if not useGradient then
return position >= charTime and active or inactive
end
if position >= charTime then return active end
local distance = charTime - position
if distance >= BLEND_MS then return inactive end
local alpha = 1 - distance / BLEND_MS
if alpha <= 0.5 then return inactive end
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end
if not useGradient then
return smoothProgress >= charProgress and active or inactive
end
local distance = charProgress - smoothProgress
if distance <= 0 then return active end
if distance >= 0.16 then return inactive end
local alpha = 1 - distance / 0.16
if alpha <= 0.5 then return inactive end
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end
local function buildLineRow(line, opts)
opts = opts or {}
local text = type(line) == "table" and line.text or line
local times = type(line) == "table" and line.chars or nil
local chars = toChars(text or "")
if #chars == 0 then chars = { " " } end
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
local offset = math.floor(exactOffset)
local fraction = exactOffset - offset
local first = offset + 1
local last = math.min(#chars, first + maxChars)
local labels = {}
for sourceIndex = first, last do
local active = playing and activeColor or inactiveColor
local color = opts.dim and inactiveColor or active
if not opts.solid and not opts.dim then
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
end
-- Soft viewport edges make cell-by-cell marquee movement less abrupt.
local alpha = 1
local edgeAlpha = noctalia.isDarkMode() and 0.5 or 0.72
if opts.marquee and offset > 0 and sourceIndex == first then alpha = edgeAlpha end
if opts.marquee and last < #chars and sourceIndex == last then alpha = edgeAlpha end
if opts.cascade then
local charProgress = (sourceIndex - first) / math.max(1, last - first)
local cascadeProgress = clamp((transitionElapsed - FADE_OUT_MS) / 420, 0, 1)
alpha = alpha * clamp((cascadeProgress - charProgress * 0.45) * 2.4, 0.08, 1)
end
if opts.wave then
local waveProgress = clamp((transitionElapsed - FADE_OUT_MS) / 650, 0, 1)
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
end
-- Bar labels elide glyphs when constrained below their natural width, so
-- animate the viewport edges with opacity without clipping CJK characters.
if opts.marquee and fraction > 0 then
if sourceIndex == first then
alpha = alpha * (1 - fraction)
elseif sourceIndex == first + maxChars then
alpha = alpha * fraction
end
elseif sourceIndex == first + maxChars then
alpha = 0
end
labels[#labels + 1] = ui.label({
key = "char-" .. tostring(sourceIndex),
text = chars[sourceIndex],
color = color,
opacity = alpha,
maxLines = 1,
})
end
if opts.marquee then
while #labels < maxChars + 1 do
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
end
end
return ui.row({
key = opts.key or "line",
gap = 0,
align = "center",
minWidth = maxChars * charWidth,
opacity = opts.opacity or 1,
}, labels)
end
local function currentTransitionLine(info)
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
local opacity = animation == "none" and 1
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
return info and info.line or nil, opacity
end
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
end
local function render()
if hideWhenPaused and not playing then
barWidget.setVisible(false)
return
end
barWidget.setVisible(true)
local vertical = barWidget.isVertical()
local useGradient = (animation == "karaoke" or animation == "cascade" or animation == "wave") and gradientOn
local position = getProgressMs()
local prefix = {}
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
if coverPath then
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
else
prefix[#prefix + 1] = ui.glyph({
name = glyph,
size = 14,
color = playing and activeColor or inactiveColor,
})
end
local lineNodes = {}
if showMode == "track" then
local label = getTrackLabel()
lineNodes[1] = buildLineRow(label, {
key = "track",
marquee = shouldMarquee(label),
solid = true,
})
else
local info = getLineInfo()
if info then
local shownLine, opacity = currentTransitionLine(info)
if shownLine then
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
key = "current-" .. tostring(info.index),
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
position = position,
useGradient = shownLine == info.line and info.synced and useGradient,
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
cascade = shownLine == info.line and animation == "cascade",
wave = shownLine == info.line and animation == "wave",
opacity = opacity,
})
end
if vertical and maxLines > 1 and shownLine == info.line then
for i = 1, maxLines - 1 do
local nextLine = lyrics[info.index + i]
if not nextLine then break end
lineNodes[#lineNodes + 1] = buildLineRow(nextLine, {
key = "next-" .. tostring(info.index + i),
dim = true,
opacity = math.max(0.25, 0.58 - (i - 1) * 0.16),
})
end
end
else
local label = getFallbackLabel()
lineNodes[1] = buildLineRow(label, {
key = "fallback",
marquee = shouldMarquee(label),
solid = true,
})
end
end
if vertical then
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
ui.row({ gap = 6, align = "center" }, prefix),
ui.column({ gap = 2, align = "start" }, lineNodes),
}))
else
local children = {}
for _, node in ipairs(prefix) do children[#children + 1] = node end
children[#children + 1] = lineNodes[1]
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
end
barWidget.setTooltip(getTrackLabel())
rendered = true
end
function onClick()
showMode = showMode == "auto" and "track" or "auto"
marqueeElapsed = 0
if rendered then render() end
end
function onRightClick()
noctalia.runAsync("playerctl play-pause", function(result)
if result.exitCode == 0 then
local wasPlaying = playing
if wasPlaying then baselinePosition = getProgressMs() * 1000 end
playing = not wasPlaying
noctalia.state.set("playing", playing)
baselineClock = os.clock()
render()
end
end)
end
function update()
local now = os.clock()
local delta = lastClock > 0 and now - lastClock or 0
lastClock = now
track = noctalia.state.get("track")
lyrics = noctalia.state.get("lyrics")
cover = noctalia.state.get("cover")
playing = noctalia.state.get("playing") == true
local info = getLineInfo()
local nextKey = info and info.key or "fallback"
if nextKey ~= displayKey then
outgoingLine = displayedLine
displayKey = nextKey
transitionElapsed = animation == "none" and FADE_OUT_MS + FADE_IN_MS or 0
marqueeElapsed = 0
smoothProgress = info and info.progress or 0
else
local target = info and info.progress or 0
smoothProgress = smoothProgress + (target - smoothProgress) * math.min(1, delta * 8)
end
displayedLine = info and info.line or nil
if playing then
transitionElapsed = transitionElapsed + delta * 1000
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
end
render()
end
noctalia.state.watch("position", function(value)
baselinePosition = tonumber(value) or 0
baselineClock = os.clock()
end)
noctalia.state.watch("lyrics", function(value)
lyrics = value
displayKey = ""
displayedLine = nil
outgoingLine = nil
marqueeElapsed = 0
end)
noctalia.state.watch("playing", function(value)
-- Preserve the interpolated position when pausing between service polls.
if playing and value ~= true then
baselinePosition = getProgressMs() * 1000
end
playing = value == true
baselineClock = os.clock()
end)
+489
View File
@@ -0,0 +1,489 @@
--!nonstrict
-- Lyrics — headless service.
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay.
noctalia.setUpdateInterval(250)
local cache = {}
local coverCache = {}
local lastTrackKey = ""
local inFlight = nil
local coverInFlight = nil
local krcTmp = (noctalia.pluginDir() or "/tmp") .. "/.krc_cache.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local currentTrack = nil
local currentEmbeddedLyrics = ""
local function coverPathFor(track)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
return noctalia.pluginDir() .. "/cover_" .. safe .. ".jpg"
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function parseLRC(lrcText)
local lines = {}
for line in lrcText:gmatch("[^\n]+") do
local mins, secs = line:match("%[(%d+):(%d+%.?%d*)%]")
if mins and secs then
local ms = math.floor(tonumber(mins) * 60000 + tonumber(secs) * 1000)
local text = line:gsub("%[%d+:%d+%.?%d*%]", ""):gsub("^%s+", ""):gsub("%s+$", "")
if text ~= "" then
local isMeta = ms < 5000 and (text:find(":") or text:find(":"))
if not isMeta then
lines[#lines + 1] = { time = ms, text = text }
end
end
end
end
if #lines == 0 then return nil end
return lines
end
local function parsePlain(text)
local lines = {}
local seenLyric = false
for line in text:gmatch("[^\n]+") do
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
if trimmed ~= "" then
if seenLyric then
lines[#lines + 1] = { time = -1, text = trimmed }
elseif trimmed:find(":") or trimmed:find(":") then
-- skip metadata header line
else
seenLyric = true
lines[#lines + 1] = { time = -1, text = trimmed }
end
end
end
if #lines == 0 then return nil end
return lines
end
local function evictCache()
local keys = {}
for k, _ in pairs(cache) do keys[#keys + 1] = k end
if #keys > 30 then
table.sort(keys)
for i = 1, #keys - 30 do cache[keys[i]] = nil end
end
end
local function fetchLyricsNetEase(track, embeddedLyrics)
if inFlight then return end
inFlight = track
local tk = track.title .. "|" .. track.artist .. "|" .. track.album
local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
if not inFlight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local data = noctalia.json.decode(r1.body)
if not data or not data.result or not data.result.songs or #data.result.songs == 0 then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local function songArtist(s)
if s.artists and #s.artists > 0 then
return (s.artists[1].name or ""):lower()
end
return ""
end
local bestMatch = nil
local trackArtist = track.artist:lower()
for _, s in ipairs(data.result.songs) do
if songArtist(s):find(trackArtist, 1, true) then
bestMatch = s
break
end
end
if not bestMatch then
bestMatch = data.result.songs[1]
end
local songId = tostring(bestMatch.id or "")
if not songId:match("^%d+$") then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
if not inFlight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil
if r2.ok and r2.status >= 200 and r2.status < 300 and r2.body and #r2.body > 0 then
local ldata = noctalia.json.decode(r2.body)
local klyricStr = ""
if ldata and ldata.klyric then
local k = ldata.klyric
if type(k) == "string" then
klyricStr = k
elseif type(k) == "table" then
klyricStr = (k.lyric and type(k.lyric) == "string") and k.lyric or ""
end
end
if klyricStr ~= "" then
local ok = pcall(noctalia.writeFile, krcTmp, klyricStr)
if not ok then klyricStr = "" end
end
if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
noctalia.runAsync(py, function(r3)
if not inFlight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then
cache[tk] = parsed.lines
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
return
end
local lrc = (ldata.lrc and ldata.lrc.lyric) or ""
local lyr = parseLRC(lrc)
if not lyr then lyr = parsePlain(lrc) end
if lyr then
cache[tk] = lyr
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyr)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
return
end
if ldata and ldata.lrc and ldata.lrc.lyric and ldata.lrc.lyric ~= "" then
lyrics = parseLRC(ldata.lrc.lyric)
if not lyrics then
lyrics = parsePlain(ldata.lrc.lyric)
end
end
end
if lyrics then
cache[tk] = lyrics
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyrics)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
end)
end
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
local qTmp = (noctalia.pluginDir() or "/tmp") .. "/.query_cache.tmp"
noctalia.writeFile(qTmp, query)
local dir = noctalia.pluginDir() or "/tmp"
local function applyParsed(parsed)
if parsed and parsed.type == "krc" and parsed.lines then
cache[tk] = parsed.lines
evictCache()
noctalia.state.set("lyrics", parsed.lines)
return true
end
if parsed and parsed.type == "lrc" and parsed.lrc and parsed.lrc ~= "" then
local lyr = parseLRC(parsed.lrc)
if not lyr then lyr = parsePlain(parsed.lrc) end
if lyr then
cache[tk] = lyr
evictCache()
noctalia.state.set("lyrics", lyr)
return true
end
end
return false
end
local function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
noctalia.runAsync(py, function(r)
if not inFlight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil)
end)
end
local function applyText(text)
if not text or text == "" then return false end
local parsed = parseLRC(text)
if not parsed then parsed = parsePlain(text) end
if not parsed then return false end
cache[tk] = parsed
evictCache()
noctalia.state.set("lyrics", parsed)
return true
end
local function fetchCustom()
if customUrl == "" then
inFlight = nil
noctalia.state.set("lyrics", nil)
return
end
local replacements = {
title = track.title,
artist = track.artist,
album = track.album,
duration = tostring(math.floor((track.duration or 0) / 1000000)),
}
local url = customUrl:gsub("{([%w_]+)}", function(key)
return noctalia.string.urlEncode(replacements[key] or "")
end)
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
if tk ~= lastTrackKey then inFlight = nil; return end
local text = response.ok and response.body or ""
if text ~= "" and customJsonField ~= "" then
local decoded = noctalia.json.decode(text)
for part in customJsonField:gmatch("[^.]+") do
decoded = type(decoded) == "table" and decoded[part] or nil
end
if type(decoded) == "table" then
cache[tk] = decoded
evictCache()
noctalia.state.set("lyrics", decoded)
inFlight = nil
return
end
text = type(decoded) == "string" and decoded or ""
end
applyText(text)
inFlight = nil
end)
end
if lyricsSource == "external" then
inFlight = nil
return
elseif lyricsSource == "mpris" then
applyText(embeddedLyrics)
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom()
return
elseif lyricsSource == "netease" then
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
return
end
-- Auto: LRCLIB, then the public NetEase API.
runPy("lrclib_lyric.py", function(parsed)
if applyParsed(parsed) then inFlight = nil; return end
if lyricsSource == "lrclib" then
inFlight = nil
noctalia.state.set("lyrics", nil)
return
end
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
end)
end
local function fetchCover(track, artUrl)
if coverInFlight then return end
coverInFlight = track.title .. "|" .. track.artist .. "|" .. track.album
local dest = coverPathFor(track)
local apply = function(path)
local tk = track.title .. "|" .. track.artist .. "|" .. track.album
coverInFlight = nil
if tk ~= lastTrackKey then return end
coverCache[tk] = path
noctalia.state.set("cover", path)
end
if not artUrl or artUrl == "" then
noctalia.state.set("cover", nil)
coverInFlight = nil
return
end
if artUrl:sub(1, 7) == "file://" then
local source = noctalia.string.urlDecode(artUrl:sub(8))
if not noctalia.fileExists(source) then
apply(nil)
return
end
noctalia.runAsync("cp -- " .. shellQuote(source) .. " " .. shellQuote(dest), function(result)
if result.exitCode == 0 then apply(dest) else apply(source) end
end)
return
end
noctalia.download(artUrl, dest, function(ok)
if ok then apply(dest) else apply(nil) end
end)
end
local function poll()
local cmd = [[playerctl metadata --format $'{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}' 2>/dev/null]]
noctalia.runAsync(cmd, function(r)
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
return
end
local parts = {}
local separator = string.char(31)
for field in (r.stdout .. separator):gmatch("(.-)" .. separator) do
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
end
local status = parts[1] or "stopped"
local title = parts[2] or ""
local artist = parts[3] or ""
local album = parts[4] or ""
local artUrl = parts[7] or ""
local embeddedLyrics = parts[8] or ""
if title == "" and artist == "" then
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("playing", status == "playing")
return
end
local playing = status == "playing"
local tk = title .. "|" .. artist .. "|" .. album
local t = {
title = title,
artist = artist,
album = album,
status = status,
position = tonumber(parts[5]) or 0,
duration = tonumber(parts[6]) or 0,
}
currentTrack = t
currentEmbeddedLyrics = embeddedLyrics
noctalia.state.set("position", t.position)
if tk ~= lastTrackKey then
lastTrackKey = tk
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
local cached = cache[tk]
if cached then
noctalia.state.set("lyrics", cached)
else
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, embeddedLyrics)
end
local cachedCover = coverCache[tk]
if cachedCover then
noctalia.state.set("cover", cachedCover)
else
noctalia.state.set("cover", nil)
fetchCover(t, artUrl)
end
else
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
end
end)
end
local pollTick = 0
function update()
if lyricsSource == "external" then return end
pollTick = pollTick + 1
if pollTick % 2 == 0 then
poll()
end
end
function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
lyricsSource = nextSource
customUrl = nextUrl
customJsonField = nextField
if sourceChanged and currentTrack then
inFlight = nil
cache = {}
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
end
end
local function applyPushedLyrics(payload)
local decoded = noctalia.json.decode(payload or "")
if type(decoded) == "table" then
if decoded.track then
currentTrack = decoded.track
lastTrackKey = (decoded.track.title or "") .. "|" .. (decoded.track.artist or "") .. "|" .. (decoded.track.album or "")
noctalia.state.set("track", decoded.track)
end
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
if decoded.lyrics then
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
noctalia.state.set("lyrics", parsed)
end
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
return true
end
return false
end
function onIpc(event, payload)
if event == "push-lrc" then
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
elseif event == "push-json" or event == "push-state" then
applyPushedLyrics(payload)
elseif event == "clear" then
noctalia.state.set("lyrics", nil)
end
end
if lyricsSource ~= "external" then poll() end
+166
View File
@@ -0,0 +1,166 @@
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.1.0"
plugin_api = 3
author = "h465855hgg"
license = "MIT"
dependencies = ["playerctl", "python3", "cp"]
tags = ["bar", "service", "music", "media", "animation"]
icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
[[setting]]
key = "lyrics_source"
type = "select"
label_key = "settings.lyrics_source.label"
description_key = "settings.lyrics_source.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.lyrics_source.options.auto" },
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
{ value = "netease", label_key = "settings.lyrics_source.options.netease" },
{ value = "mpris", label_key = "settings.lyrics_source.options.mpris" },
{ value = "custom", label_key = "settings.lyrics_source.options.custom" },
{ value = "external", label_key = "settings.lyrics_source.options.external" },
]
[[setting]]
key = "custom_url"
type = "string"
label_key = "settings.custom_url.label"
description_key = "settings.custom_url.description"
default = ""
visible_when = { key = "lyrics_source", values = ["custom"] }
[[setting]]
key = "custom_json_field"
type = "string"
label_key = "settings.custom_json_field.label"
description_key = "settings.custom_json_field.description"
default = "syncedLyrics"
visible_when = { key = "lyrics_source", values = ["custom"] }
[[setting]]
key = "cue_text"
type = "string"
label_key = "settings.cue_text.label"
description_key = "settings.cue_text.description"
default = "•••••"
[[setting]]
key = "scroll_mode"
type = "select"
label_key = "settings.scroll_mode.label"
default = "auto"
description_key = "settings.scroll_mode.description"
options = [
{ value = "auto", label_key = "settings.scroll_mode.options.auto" },
{ value = "marquee", label_key = "settings.scroll_mode.options.marquee" },
{ value = "static", label_key = "settings.scroll_mode.options.static" },
]
[[setting]]
key = "marquee_speed"
type = "int"
label_key = "settings.marquee_speed.label"
default = 30
min = 10
max = 120
description_key = "settings.marquee_speed.description"
[[setting]]
key = "max_lines"
type = "int"
label_key = "settings.max_lines.label"
default = 1
min = 1
max = 3
description_key = "settings.max_lines.description"
[[setting]]
key = "gradient"
type = "bool"
label_key = "settings.gradient.label"
default = true
description_key = "settings.gradient.description"
[[setting]]
key = "animation"
type = "select"
label_key = "settings.animation.label"
default = "karaoke"
description_key = "settings.animation.description"
options = [
{ value = "karaoke", label_key = "settings.animation.options.karaoke" },
{ value = "cascade", label_key = "settings.animation.options.cascade" },
{ value = "wave", label_key = "settings.animation.options.wave" },
{ value = "fade", label_key = "settings.animation.options.fade" },
{ value = "none", label_key = "settings.animation.options.none" },
]
[[setting]]
key = "max_chars"
type = "int"
label_key = "settings.max_chars.label"
default = 24
min = 8
max = 80
description_key = "settings.max_chars.description"
[[setting]]
key = "char_width"
type = "int"
label_key = "settings.char_width.label"
default = 9
min = 4
max = 24
description_key = "settings.char_width.description"
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]]
id = "service"
entry = "lyrics_service.luau"
# Bar widget: thin presentation that mirrors the published lyrics state.
[[widget]]
id = "lyrics"
entry = "lyrics.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
default = "music"
[[widget.setting]]
key = "show_artist"
type = "bool"
label_key = "settings.show_artist.label"
default = true
[[widget.setting]]
key = "hide_when_paused"
type = "bool"
label_key = "settings.hide_when_paused.label"
default = false
[[widget.setting]]
key = "show_cover"
type = "bool"
label_key = "settings.show_cover.label"
description_key = "settings.show_cover.description"
default = true
[[widget.setting]]
key = "active_color"
type = "color"
label_key = "settings.active_color.label"
description_key = "settings.active_color.description"
default = "primary"
[[widget.setting]]
key = "inactive_color"
type = "color"
label_key = "settings.inactive_color.label"
description_key = "settings.inactive_color.description"
default = "on_surface_variant"
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage: scripts/setup-deps.sh [--check] [--yes]
Install or check runtime dependencies for the Noctalia Lyrics plugin.
Options:
--check Only report missing commands; do not install anything.
--yes Skip the confirmation prompt before installing packages.
--help Show this help text.
EOF
}
CHECK_ONLY=0
ASSUME_YES=0
while [ "$#" -gt 0 ]; do
case "$1" in
--check)
CHECK_ONLY=1
;;
-y|--yes)
ASSUME_YES=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
need_command() {
command -v "$1" >/dev/null 2>&1 || MISSING_COMMANDS="$MISSING_COMMANDS $1"
}
MISSING_COMMANDS=""
need_command playerctl
need_command python3
need_command cp
if [ -z "$MISSING_COMMANDS" ]; then
echo "All runtime commands are installed: playerctl python3 cp"
exit 0
fi
echo "Missing runtime command(s):$MISSING_COMMANDS"
if [ "$CHECK_ONLY" -eq 1 ]; then
exit 1
fi
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "sudo is required to install packages as a non-root user." >&2
exit 1
fi
detect_pm() {
if command -v apt-get >/dev/null 2>&1; then
echo apt
elif command -v dnf >/dev/null 2>&1; then
echo dnf
elif command -v pacman >/dev/null 2>&1; then
echo pacman
elif command -v zypper >/dev/null 2>&1; then
echo zypper
elif command -v apk >/dev/null 2>&1; then
echo apk
elif command -v xbps-install >/dev/null 2>&1; then
echo xbps
else
echo unknown
fi
}
PM="$(detect_pm)"
case "$PM" in
apt)
INSTALL_CMD="$SUDO apt-get update && $SUDO apt-get install -y playerctl python3 coreutils"
;;
dnf)
INSTALL_CMD="$SUDO dnf install -y playerctl python3 coreutils"
;;
pacman)
INSTALL_CMD="$SUDO pacman -S --needed playerctl python coreutils"
;;
zypper)
INSTALL_CMD="$SUDO zypper install -y playerctl python3 coreutils"
;;
apk)
INSTALL_CMD="$SUDO apk add playerctl python3 coreutils"
;;
xbps)
INSTALL_CMD="$SUDO xbps-install -Sy playerctl python3 coreutils"
;;
*)
cat >&2 <<'EOF'
Could not detect a supported package manager.
Install these packages manually with your distribution package manager:
playerctl python3 coreutils
EOF
exit 1
;;
esac
echo "Detected package manager: $PM"
echo "Install command: $INSTALL_CMD"
if [ "$ASSUME_YES" -ne 1 ]; then
printf "Proceed with installation? [y/N] "
read -r answer
case "$answer" in
y|Y|yes|YES)
;;
*)
echo "Cancelled."
exit 1
;;
esac
fi
sh -c "$INSTALL_CMD"
MISSING_COMMANDS=""
need_command playerctl
need_command python3
need_command cp
if [ -n "$MISSING_COMMANDS" ]; then
echo "Still missing after installation:$MISSING_COMMANDS" >&2
exit 1
fi
echo "Dependencies installed successfully."
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+93
View File
@@ -0,0 +1,93 @@
{
"title": "Lyrics",
"no_lyrics": "♪ No lyrics available",
"intro": "Intro",
"instrumental": "Instrumental",
"settings": {
"glyph": {
"label": "Glyph"
},
"show_artist": {
"label": "Show artist"
},
"hide_when_paused": {
"label": "Hide when paused"
},
"show_cover": {
"label": "Show album cover",
"description": "Display the song's album art next to the lyrics."
},
"active_color": {
"label": "Active lyric color",
"description": "Color used for the current and already-sung lyric characters."
},
"inactive_color": {
"label": "Inactive lyric color",
"description": "Color used for upcoming lyrics, paused playback, and secondary lines."
},
"lyrics_source": {
"label": "Lyrics source",
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"options": {
"auto": "Automatic fallback",
"lrclib": "LRCLIB",
"netease": "NetEase Music (public API)",
"mpris": "Local player (MPRIS)",
"custom": "Custom HTTP endpoint",
"external": "External IPC push"
}
},
"custom_url": {
"label": "Custom endpoint URL",
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly."
},
"custom_json_field": {
"label": "JSON lyrics field",
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC."
},
"cue_text": {
"label": "Intro/interlude characters",
"description": "Text or characters highlighted during intros and interludes."
},
"scroll_mode": {
"label": "Scroll mode",
"description": "How lyrics scroll in the bar.",
"options": {
"auto": "Auto (synced)",
"marquee": "Marquee",
"static": "Static"
}
},
"marquee_speed": {
"label": "Marquee speed",
"description": "Pixels per second for long-line scrolling."
},
"max_lines": {
"label": "Max lines",
"description": "Maximum lyric lines to show at once."
},
"gradient": {
"label": "Per-character gradient",
"description": "Light up each character as the song progresses (karaoke style)."
},
"animation": {
"label": "Animation",
"description": "How lyrics animate on line change.",
"options": {
"karaoke": "Karaoke gradient + fade",
"cascade": "Character cascade",
"wave": "Character wave reveal",
"fade": "Fade only",
"none": "No animation"
}
},
"max_chars": {
"label": "Max characters",
"description": "Visible width before a long lyric line scrolls (marquee)."
},
"char_width": {
"label": "Character width (px)",
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling."
}
}
}