diff --git a/lyrics/README.md b/lyrics/README.md new file mode 100644 index 0000000..86ce6e7 --- /dev/null +++ b/lyrics/README.md @@ -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 '' +``` + +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. diff --git a/lyrics/krc_decode.py b/lyrics/krc_decode.py new file mode 100644 index 0000000..fc2ab34 --- /dev/null +++ b/lyrics/krc_decode.py @@ -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` 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() diff --git a/lyrics/lrclib_lyric.py b/lyrics/lrclib_lyric.py new file mode 100644 index 0000000..9c953fb --- /dev/null +++ b/lyrics/lrclib_lyric.py @@ -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() diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau new file mode 100644 index 0000000..c352031 --- /dev/null +++ b/lyrics/lyrics.luau @@ -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) diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau new file mode 100644 index 0000000..119add0 --- /dev/null +++ b/lyrics/lyrics_service.luau @@ -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 diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml new file mode 100644 index 0000000..6f7837b --- /dev/null +++ b/lyrics/plugin.toml @@ -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" diff --git a/lyrics/screenshots/settings.webp b/lyrics/screenshots/settings.webp new file mode 100644 index 0000000..fd049ad Binary files /dev/null and b/lyrics/screenshots/settings.webp differ diff --git a/lyrics/screenshots/widget.webp b/lyrics/screenshots/widget.webp new file mode 100644 index 0000000..f85b784 Binary files /dev/null and b/lyrics/screenshots/widget.webp differ diff --git a/lyrics/scripts/setup-deps.sh b/lyrics/scripts/setup-deps.sh new file mode 100755 index 0000000..08b5bd0 --- /dev/null +++ b/lyrics/scripts/setup-deps.sh @@ -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." diff --git a/lyrics/thumbnail.webp b/lyrics/thumbnail.webp new file mode 100644 index 0000000..cf876db Binary files /dev/null and b/lyrics/thumbnail.webp differ diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json new file mode 100644 index 0000000..ac28055 --- /dev/null +++ b/lyrics/translations/en.json @@ -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." + } + } +}