From 7140e2fe7dfb8fd6aeb39929508de52dd776a24f Mon Sep 17 00:00:00 2001 From: h465855hgg <3382198490@qq.com> Date: Fri, 17 Jul 2026 05:40:37 +0800 Subject: [PATCH] feat: add lyrics plugin --- lyrics/README.md | 86 +++++++ lyrics/krc_decode.py | 91 +++++++ lyrics/lrclib_lyric.py | 81 ++++++ lyrics/lyrics.luau | 457 +++++++++++++++++++++++++++++++++ lyrics/lyrics_service.luau | 486 ++++++++++++++++++++++++++++++++++++ lyrics/plugin.toml | 152 +++++++++++ lyrics/thumbnail.webp | Bin 0 -> 13748 bytes lyrics/translations/en.json | 85 +++++++ 8 files changed, 1438 insertions(+) create mode 100644 lyrics/README.md create mode 100644 lyrics/krc_decode.py create mode 100644 lyrics/lrclib_lyric.py create mode 100644 lyrics/lyrics.luau create mode 100644 lyrics/lyrics_service.luau create mode 100644 lyrics/plugin.toml create mode 100644 lyrics/thumbnail.webp create mode 100644 lyrics/translations/en.json diff --git a/lyrics/README.md b/lyrics/README.md new file mode 100644 index 0000000..58dd762 --- /dev/null +++ b/lyrics/README.md @@ -0,0 +1,86 @@ +# 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`, `curl`, and `cp` on `PATH`. The active media +player must expose MPRIS metadata for automatic track and playback detection. + +## 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 `•••••`. + +## 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. | + +## 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, `curl` for the public NetEase API, and +`cp` to preserve temporary local cover files. 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..bb9a4cd --- /dev/null +++ b/lyrics/lyrics.luau @@ -0,0 +1,457 @@ +--!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 + +-- 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 "on_surface_variant" end + local active = noctalia.isDarkMode() and "primary" or "on_surface" + local inactive = "on_surface_variant" + 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 (noctalia.isDarkMode() and "primary" or "on_surface") or "on_surface_variant" + local color = opts.dim and "on_surface_variant" 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 (noctalia.isDarkMode() and "primary" or "on_surface") or "on_surface_variant", + }) + 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..3368540 --- /dev/null +++ b/lyrics/lyrics_service.luau @@ -0,0 +1,486 @@ +--!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" + local cmd = 'curl -s --max-time 10 --connect-timeout 5 "' .. searchUrl .. '" -H "Referer: https://music.163.com"' + + noctalia.runAsync(cmd, function(r1) + if not inFlight then return end + if tk ~= lastTrackKey then inFlight = nil; return end + + if r1.exitCode ~= 0 or not r1.stdout or #r1.stdout == 0 then + if fallback then fallback() + else inFlight = nil; noctalia.state.set("lyrics", nil) end + return + end + + local data = noctalia.json.decode(r1.stdout) + 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 = bestMatch.id + local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. songId .. "&lv=1&kv=1&tv=-1" + local lCmd = 'curl -s --max-time 10 --connect-timeout 5 "' .. lyricUrl .. '" -H "Referer: https://music.163.com"' + + noctalia.runAsync(lCmd, function(r2) + if not inFlight then return end + if tk ~= lastTrackKey then inFlight = nil; return end + + local lyrics = nil + if r2.exitCode == 0 and r2.stdout and #r2.stdout > 0 then + local ldata = noctalia.json.decode(r2.stdout) + 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..70689ac --- /dev/null +++ b/lyrics/plugin.toml @@ -0,0 +1,152 @@ +id = "h465855hgg/lyrics" +name = "Lyrics" +version = "1.0.0" +plugin_api = 3 +author = "h465855hgg" +license = "MIT" +dependencies = ["playerctl", "python3", "curl", "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 diff --git a/lyrics/thumbnail.webp b/lyrics/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..dd51510f4d93d68ae5e6836458144830e607dad5 GIT binary patch literal 13748 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 a866NPk^q-sEQ}ru3|xy=*&hINkqH2C1d{Fm literal 0 HcmV?d00001 diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json new file mode 100644 index 0000000..6c0a6d5 --- /dev/null +++ b/lyrics/translations/en.json @@ -0,0 +1,85 @@ +{ + "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." + }, + "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." + } + } +}