diff --git a/lyrics/README.md b/lyrics/README.md index faf4729..103dc82 100644 --- a/lyrics/README.md +++ b/lyrics/README.md @@ -1,7 +1,8 @@ -# Lyrics +# Noctalia Lyrics 1.4.0 -Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke -highlighting, animated line changes, and configurable online or local sources. +Synchronized lyrics for the Noctalia bar, with multiple MPRIS players, +translation and romanization layers, configurable sources, karaoke highlighting, +album artwork, and layout controls. ## Plugin @@ -12,80 +13,153 @@ highlighting, animated line changes, and configurable online or local sources. ## Requirements -Install `playerctl`, `python3`, and `cp` on `PATH`. The active media -player must expose MPRIS metadata for automatic track and playback detection. +Install these commands on `PATH`: -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`. +- `playerctl`: read and control MPRIS players. +- `python3`: run the unified lyric-source adapter and dynamic lyric parser. +- `cp`: preserve local MPRIS artwork in the plugin cache. +- `chmod`: restrict temporary credential request files to the current user. ## 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. - -When several players are available, the service prefers a playing player, then -a paused player, and keeps the current player when priorities are equal. The -optional allowlist and blocklist match player names or instances and support -`*` wildcards. - -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 `•••••`. +Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar +widget. Left-click switches between lyrics and track information when +`display_mode` is `toggle`; right-click pauses or resumes the selected player. ## Screenshots -Status-bar widget: +Double-line widget with translated or romanized lyrics: -![Lyrics bar widget](screenshots/widget.webp) +![Lyrics 1.4 widget]() -Plugin settings: +Plugin-wide settings: -![Lyrics settings](screenshots/settings.webp) +![Lyrics 1.4 settings]() + +Per-widget bar settings: + +![Lyrics 1.4 bar widget settings]() + +## Local development + +Add the parent directory of this checkout as a local Noctalia source: + +```sh +noctalia msg plugins source add lyrics-dev path /path/to/plugin-parent +noctalia msg plugins enable h465855hgg/lyrics +noctalia msg config-reload +``` + +Run validation after every change: + +```sh +python3 .github/workflows/validate-plugins.py +noctalia plugins lint lyrics +sh lyrics/scripts/setup-deps.sh --check +cd lyrics +python3 -m py_compile lyric_sources.py krc_decode.py lrclib_lyric.py +``` + +## Lyrics model + +Every source is normalized to lines with independent original, translation, +romanization, and character-timing fields: + +```json +{ + "time": 1200, + "duration": 1800, + "text": "Original lyric", + "translation": "Translated lyric", + "romanization": "romanized lyric", + "chars": [1200, 1500, 1800] +} +``` + +## Sources + +`auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported +IDs are: + +- `lrclib`: public LRCLIB search. +- `netease`: public NetEase search, synchronized lyrics, translations, and + romanization when returned. +- `qqmusic`: public QQ Music search and lyric endpoint. +- `kugou`: public Kugou search and lyric download endpoint. +- `qishui`: user-configured HTTP endpoint supporting `{title}`, `{artist}`, and + `{album}` placeholders. +- `apple_music`: Apple Music catalog and lyrics request using manually supplied + developer and optional user tokens. +- `spotify`: Spotify search and color-lyrics request using a manually supplied + access token or `sp_dc`. +- `musixmatch`: Musixmatch subtitle request using a manually supplied usertoken. +- `mpris`: embedded `xesam:asText` lyrics from the selected player. +- `custom`: existing generic HTTP endpoint. +- `external`: lyrics pushed through Noctalia IPC. + +Source APIs can change or reject requests by region or account. Failure of one +source in automatic mode moves to the next source without logging credentials. + +## Credential warning + +Noctalia currently exposes these as normal string settings, not secret fields. +Spotify, Apple Music, Musixmatch, and Qishui credentials may therefore be stored +in plaintext in Noctalia's settings. The plugin never scans browser cookies, +never logs credential values, and deletes its temporary credential request file +as soon as the source adapter reads it. ## Settings -| Setting | Type | Default | Description | -| --- | --- | --- | --- | -| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. | -| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. | -| `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. | -| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. | -| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. | -| `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` | `15` | 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` | `on_surface` | Colors the current and already-sung lyric characters. | -| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. | +The plugin settings control source selection and fallback order, translation +language, lyric timing offset in milliseconds, MPRIS polling interval in +milliseconds, double-line translation and romanization, karaoke highlighting, +marquee behavior, transitions, fonts, spacing, and side padding. -## IPC +Per-widget settings control the fallback glyph, artist visibility, paused-state +visibility, album-cover shape and size, and primary, inactive, and secondary +lyric colors. Credential and source-specific fields are shown only when their +matching source is selected; source order, credentials, polling, marquee +metrics, player filters, and fine layout controls are under Advanced settings. -External players can set `lyrics_source` to `external` and address the singleton -service with: +## Feature test checklist + +The normal settings view contains everyday source, lyric-layer, display, font, +and animation controls. Open Advanced settings for credentials, source order, +polling, marquee metrics, player filters, and fine padding. + +1. Language: change Noctalia's global language and reload the config. Plugin + settings and runtime text follow the host language. Noctalia currently + supports English and Simplified Chinese from the originally requested locale + set; Japanese, Korean, and Traditional Chinese are not host language options. + Close and reopen Settings, then disable and enable the plugin if the runtime + tooltip still uses the previous language. +2. Chinese translation: use NetEase/QQ/Musixmatch with `show_translation=true` + and `translation_language=zh-Hans`. +3. Romanization: enable `show_romanization` and select a NetEase/QQ track that + returns romanized lyrics. +4. Sources: select each `lyrics_source` directly, then test custom fallback order + through `lyrics_sources`. +5. Delay: set `lyrics_offset_ms` to `1000` and `-1000`; positive values should + show the next lyric one second earlier. +6. Polling: test `poll_interval_ms` at `100`, `500`, and `2000`. +7. Cover: test circle, rounded, square, and custom radius; change `cover_size`. +8. Double line: enable `double_line` and switch `secondary_line_mode`. + On horizontal bars keep `double_line_auto_fit` enabled and lower + `double_line_height_budget` if the bar clips the second line. +9. Karaoke: toggle `karaoke_enabled`; line transitions must continue when off. +10. Hide layers: independently disable `show_translation` and + `show_romanization`. +11. Track-only: select `display_mode=track`; no lyric source request should run. +12. Font and double-line sizing: test `font_family`, `primary_font_size`, + `secondary_font_size`, `line_gap`, `font_weight`, and `font_style`. +13. Animations: test karaoke, cascade, wave, fade, typewriter, pulse, blink, and + none. +14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and + vertical bars. + +## External protocol + +Address the singleton service with: ```sh noctalia msg plugin h465855hgg/lyrics:service all '' @@ -93,26 +167,10 @@ 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. +- `push-lrc`: synchronized or plain LRC text. +- `push-json`: JSON with `lines` or `lyrics`. +- `push-state`: also updates track, position, playing, and cover. +- `clear`: clears current 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 select, 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. +MPRIS track duration and playback position are microseconds. Lyric line, +duration, and character timestamps are milliseconds. diff --git a/lyrics/lyric_sources.py b/lyrics/lyric_sources.py new file mode 100644 index 0000000..896ca25 --- /dev/null +++ b/lyrics/lyric_sources.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +"""Standalone, standard-library lyric source adapter for the Noctalia plugin.""" + +import base64 +import html +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + + +USER_AGENT = "Noctalia-Lyrics/1.0" +TIME_TAG = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]") +KRC_LINE = re.compile(r"^\[(\d+),(\d+)\](.*)$") +PREFIX_WORD = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)") +SUFFIX_WORD = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>") +ENHANCED_WORD = re.compile(r"<(?:(\d+):)?(\d{1,2}(?:[.:]\d{1,3})?)>([^<]*)") +META_TAG = re.compile(r"^\[(ar|al|ti|by|re|ve|length|offset):", re.I) +CREDIT_LINE = re.compile(r"^(词|曲|作词|作曲|编曲|制作人|lyricist|composer|arranger)\s*[::]", re.I) + + +def empty(source, *diag): + return {"type": "none", "source": source, "lines": [], "diag": list(diag)} + + +def clean_text(value): + if value is None: + return "" + return html.unescape(str(value)).replace("\ufeff", "").strip() + + +def number(value, default=0): + try: + return int(float(value)) + except (TypeError, ValueError, OverflowError): + return default + + +def normalize(value): + return "".join(c.lower() for c in clean_text(value) if c.isalnum()) + + +def timestamp_ms(minutes, seconds): + seconds = str(seconds).replace(":", ".") + return int(round(number(minutes) * 60000 + float(seconds) * 1000)) + + +def duration_ms(value): + value = number(value, 0) + if value <= 0: + return 0 + # Track duration is commonly milliseconds, but MPRIS callers may send microseconds. + return value // 1000 if value > 10_000_000 else value + + +def line(time=-1, duration=0, text="", translation="", romanization="", chars=None): + return { + "time": number(time, -1), + "duration": max(0, number(duration)), + "text": clean_text(text), + "translation": clean_text(translation), + "romanization": clean_text(romanization), + "chars": [number(item) for item in (chars or [])], + } + + +def finalize(lines, total_duration=0): + cleaned = [] + for item in lines or []: + if not isinstance(item, dict): + continue + normalized = line( + item.get("time", item.get("start", item.get("startTimeMs", -1))), + item.get("duration", item.get("durationMs", 0)), + item.get("text", item.get("words", item.get("lyric", ""))), + item.get("translation", item.get("translated", "")), + item.get("romanization", item.get("romanized", item.get("romaji", ""))), + item.get("chars", item.get("charTimes", [])), + ) + if normalized["text"] or normalized["translation"] or normalized["romanization"]: + cleaned.append(normalized) + cleaned.sort(key=lambda item: (item["time"] < 0, item["time"] if item["time"] >= 0 else 0)) + for index, item in enumerate(cleaned): + if item["duration"] > 0 or item["time"] < 0: + continue + next_time = next( + (other["time"] for other in cleaned[index + 1:] if other["time"] > item["time"]), + total_duration if total_duration > item["time"] else 0, + ) + if next_time: + item["duration"] = max(0, next_time - item["time"]) + return cleaned + + +def merge_timed(primary, secondary, field, tolerance=500): + if not primary or not secondary: + return primary + untimed = [item for item in secondary if item.get("time", -1) < 0] + timed = [item for item in secondary if item.get("time", -1) >= 0] + for index, target in enumerate(primary): + value = "" + if target.get("time", -1) >= 0 and timed: + candidate = min(timed, key=lambda item: abs(item["time"] - target["time"])) + if abs(candidate["time"] - target["time"]) <= tolerance: + value = candidate.get("text", "") + elif index < len(untimed): + value = untimed[index].get("text", "") + if value and not target.get(field): + target[field] = value + return primary + + +def parse_plain(text): + return [line(-1, text=value) for value in str(text or "").splitlines() if clean_text(value)] + + +def parse_lrc(text): + text = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + offset = 0 + match = re.search(r"\[offset:([+-]?\d+)\]", text, re.I) + if match: + offset = number(match.group(1)) + result = [] + for raw in text.splitlines(): + raw = raw.strip() + if not raw or META_TAG.match(raw): + continue + krc = KRC_LINE.match(raw) + if krc: + start, duration, body = number(krc.group(1)), number(krc.group(2)), krc.group(3) + words = PREFIX_WORD.findall(body) if re.match(r"^[<(]\d+,", body) else [] + if words: + pieces = [(word, number(word_offset), number(word_duration)) + for word_offset, word_duration, word in words] + else: + pieces = [(word, number(word_offset), number(word_duration)) + for word, word_offset, word_duration in SUFFIX_WORD.findall(body)] + if pieces: + content, chars = "", [] + for word, word_offset, word_duration in pieces: + for index, character in enumerate(word): + content += character + chars.append(start + word_offset + (index * word_duration // max(1, len(word)))) + if clean_text(content): + result.append(line(start + offset, duration, content, chars=chars)) + continue + if clean_text(body): + result.append(line(start + offset, duration, body)) + continue + tags = list(TIME_TAG.finditer(raw)) + if not tags: + continue + body = TIME_TAG.sub("", raw).strip() + enhanced = list(ENHANCED_WORD.finditer(body)) + visible = clean_text(ENHANCED_WORD.sub(lambda item: item.group(3), body)) if enhanced else clean_text(body) + if not visible: + continue + for tag in tags: + start = timestamp_ms(tag.group(1), tag.group(2)) + offset + if CREDIT_LINE.match(visible) or (start <= 1000 and " - " in visible): + continue + chars = [] + if enhanced: + for word in enhanced: + word_time = timestamp_ms(word.group(1) or tag.group(1), word.group(2)) + offset + chars.extend([word_time] * len(word.group(3))) + result.append(line(start, text=visible, chars=chars)) + return finalize(result) + + +def parse_time_expression(value): + value = clean_text(value) + if not value: + return -1 + if value.endswith("ms"): + return number(value[:-2], -1) + if value.endswith("s"): + try: + return int(float(value[:-1]) * 1000) + except ValueError: + return -1 + parts = value.split(":") + try: + if len(parts) == 3: + return int((float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])) * 1000) + if len(parts) == 2: + return int((float(parts[0]) * 60 + float(parts[1])) * 1000) + return int(float(value) * 1000) + except ValueError: + return -1 + + +def parse_ttml(text): + try: + root = ET.fromstring(text) + except (ET.ParseError, TypeError): + return [] + result = [] + for node in root.iter(): + if node.tag.rsplit("}", 1)[-1] != "p": + continue + start = parse_time_expression(node.attrib.get("begin", "")) + end = parse_time_expression(node.attrib.get("end", "")) + content = clean_text("".join(node.itertext())) + if not content: + continue + chars = [] + for child in node.iter(): + if child is node or child.tag.rsplit("}", 1)[-1] != "span": + continue + child_text = "".join(child.itertext()) + child_start = parse_time_expression(child.attrib.get("begin", "")) + if child_text and child_start >= 0: + chars.extend([child_start] * len(child_text)) + role = " ".join(str(value) for key, value in node.attrib.items() if "role" in key.lower()).lower() + item = line(start, max(0, end - start) if end >= start >= 0 else 0, content, chars=chars) + if "translation" in role: + item["_kind"] = "translation" + elif "roman" in role: + item["_kind"] = "romanization" + result.append(item) + primary = [item for item in result if not item.get("_kind")] + translations = [item for item in result if item.get("_kind") == "translation"] + romanizations = [item for item in result if item.get("_kind") == "romanization"] + if not primary: + primary = translations or romanizations + merge_timed(primary, translations, "translation") + merge_timed(primary, romanizations, "romanization") + for item in primary: + item.pop("_kind", None) + return finalize(primary) + + +def first_value(data, names): + if isinstance(data, dict): + for name in names: + if name in data and data[name] not in (None, "", [], {}): + return data[name] + for value in data.values(): + found = first_value(value, names) + if found not in (None, "", [], {}): + return found + elif isinstance(data, list): + for value in data: + found = first_value(value, names) + if found not in (None, "", [], {}): + return found + return None + + +def parse_json_lines(value): + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith("<") and (" best_score: + best, best_score = item, score + return best if best_score >= 3 else None + + +def success(source, lines, diag, total=0): + lines = finalize(lines, total) + return {"type": "lyrics", "source": source, "lines": lines, "diag": diag} if lines else empty(source, *diag) + + +def adapter_lrclib(track, credentials, options): + source = "lrclib" + params = {"track_name": track.get("title", ""), "artist_name": track.get("artist", "")} + if track.get("album"): + params["album_name"] = track["album"] + data = request_json(query_url("https://lrclib.net/api/search", params)) + best = best_match(data, track, lambda x: x.get("trackName", ""), lambda x: x.get("artistName", ""), + lambda x: x.get("albumName", "")) + if not best: + return empty(source, "lrclib: no match") + lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or "" + return success(source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"], duration_ms(track.get("duration"))) + + +def adapter_netease(track, credentials, options): + source = "netease" + search = request_json(query_url("https://music.163.com/api/search/get", { + "type": 1, "s": " ".join(filter(None, (track.get("title"), track.get("artist")))), "limit": 10 + }), {"Referer": "https://music.163.com/"}) + songs = search.get("result", {}).get("songs", []) + best = best_match(songs, track, lambda x: x.get("name", ""), + lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])), + lambda x: x.get("album", {}).get("name", "")) + if not best: + return empty(source, "netease: no match") + data = request_json(query_url("https://music.163.com/api/song/lyric", { + "id": best.get("id"), "lv": 1, "kv": 1, "tv": 1, "rv": 1, "yv": 1 + }), {"Referer": "https://music.163.com/"}) + lines = [] + for name in ("yrc", "klyric", "lrc"): + main = data.get(name, {}) + main = main.get("lyric", "") if isinstance(main, dict) else main + lines = parse_lrc(main) if main else [] + if lines: + break + translation = data.get("tlyric", {}) + romanization = data.get("romalrc", {}) + merge_timed(lines, parse_lrc(translation.get("lyric", "") if isinstance(translation, dict) else translation), "translation") + merge_timed(lines, parse_lrc(romanization.get("lyric", "") if isinstance(romanization, dict) else romanization), "romanization") + return success(source, lines, ["netease: match"], duration_ms(track.get("duration"))) + + +def adapter_qqmusic(track, credentials, options): + source = "qqmusic" + search = request_json(query_url("https://c.y.qq.com/soso/fcgi-bin/client_search_cp", { + "format": "json", "p": 1, "n": 10, "w": " ".join(filter(None, (track.get("title"), track.get("artist")))) + }), {"Referer": "https://y.qq.com/"}) + songs = search.get("data", {}).get("song", {}).get("list", []) + best = best_match(songs, track, lambda x: x.get("songname", x.get("title", "")), + lambda x: " ".join(a.get("name", "") for a in x.get("singer", [])), + lambda x: x.get("albumname", "")) + if not best: + return empty(source, "qqmusic: no match") + data = request_json(query_url("https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg", { + "songmid": best.get("songmid", best.get("mid", "")), "format": "json", "nobase64": 1, + "g_tk": 5381 + }), {"Referer": "https://y.qq.com/portal/player.html"}) + def decoded(name): + value = data.get(name, "") + if not value: + return "" + try: + return base64.b64decode(value).decode("utf-8", "replace") if not TIME_TAG.search(value) else value + except (ValueError, TypeError): + return value + lines = parse_lrc(decoded("lyric")) + merge_timed(lines, parse_lrc(decoded("trans")), "translation") + merge_timed(lines, parse_lrc(decoded("roma")), "romanization") + return success(source, lines, ["qqmusic: match"], duration_ms(track.get("duration"))) + + +def adapter_kugou(track, credentials, options): + source = "kugou" + keyword = " ".join(filter(None, (track.get("title"), track.get("artist")))) + search = request_json(query_url("https://mobilecdn.kugou.com/api/v3/search/song", { + "format": "json", "keyword": keyword, "page": 1, "pagesize": 10, "showtype": 1 + })) + songs = search.get("data", {}).get("info", []) + best = best_match(songs, track, lambda x: x.get("songname", x.get("filename", "")), + lambda x: x.get("singername", ""), lambda x: x.get("album_name", "")) + if not best: + return empty(source, "kugou: no match") + candidates = request_json(query_url("https://lyrics.kugou.com/search", { + "ver": 1, "man": "yes", "client": "pc", "keyword": keyword, + "duration": best.get("duration", duration_ms(track.get("duration"))), "hash": best.get("hash", "") + })).get("candidates", []) + if not candidates: + return empty(source, "kugou: lyrics unavailable") + candidate = candidates[0] + data = request_json(query_url("https://lyrics.kugou.com/download", { + "ver": 1, "client": "pc", "id": candidate.get("id"), "accesskey": candidate.get("accesskey"), + "fmt": "lrc", "charset": "utf8" + })) + content = data.get("content", "") + try: + content = base64.b64decode(content).decode("utf-8", "replace") + except (ValueError, TypeError): + pass + return success(source, parse_lrc(content), ["kugou: match"], duration_ms(track.get("duration"))) + + +def adapter_qishui(track, credentials, options): + source = "qishui" + template = clean_text(credentials.get("qishui_api_url")) + if not template: + return empty(source, "qishui: endpoint required") + replacements = {key: urllib.parse.quote(str(track.get(key, "")), safe="") for key in ("title", "artist", "album")} + try: + url = template.format(**replacements) + except (KeyError, ValueError): + return empty(source, "qishui: invalid endpoint template") + parsed = urllib.parse.urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return empty(source, "qishui: invalid endpoint") + headers = {} + if credentials.get("qishui_token"): + headers["Authorization"] = "Bearer " + str(credentials["qishui_token"]) + body, charset = request_data(url, headers) + lines = parse_payload(body.decode(charset, "replace")) + return success(source, lines, ["qishui: response parsed"], duration_ms(track.get("duration"))) + + +def spotify_token(credentials): + token = clean_text(credentials.get("spotify_access_token")) + if token: + return token + cookie = clean_text(credentials.get("spotify_sp_dc")) + if not cookie: + return "" + data = request_json("https://open.spotify.com/get_access_token?reason=transport&productType=web_player", + {"Cookie": "sp_dc=" + cookie, "Referer": "https://open.spotify.com/"}) + return clean_text(data.get("accessToken")) + + +def adapter_spotify(track, credentials, options): + source = "spotify" + token = spotify_token(credentials) + if not token: + return empty(source, "spotify: credentials required") + headers = {"Authorization": "Bearer " + token} + search = request_json(query_url("https://api.spotify.com/v1/search", { + "q": " ".join(filter(None, (track.get("title"), track.get("artist")))), "type": "track", "limit": 10 + }), headers) + items = search.get("tracks", {}).get("items", []) + best = best_match(items, track, lambda x: x.get("name", ""), + lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])), + lambda x: x.get("album", {}).get("name", "")) + if not best: + return empty(source, "spotify: no match") + data = request_json(query_url("https://spclient.wg.spotify.com/color-lyrics/v2/track/" + urllib.parse.quote(best["id"]), { + "format": "json", "market": "from_token" + }), headers) + lines = parse_json_lines(data.get("lyrics", {}).get("lines", [])) + alternatives = data.get("lyrics", {}).get("alternatives", []) + if alternatives and isinstance(alternatives[0], dict): + merge_timed(lines, parse_json_lines(alternatives[0].get("lines", [])), "translation") + return success(source, lines, ["spotify: match"], duration_ms(track.get("duration"))) + + +def adapter_apple_music(track, credentials, options): + source = "apple_music" + developer = clean_text(credentials.get("apple_developer_token")) + if not developer: + return empty(source, "apple_music: developer token required") + storefront = clean_text(credentials.get("apple_storefront")) or "us" + if not re.fullmatch(r"[A-Za-z0-9-]+", storefront): + return empty(source, "apple_music: invalid storefront") + headers = {"Authorization": "Bearer " + developer, "Origin": "https://music.apple.com"} + if credentials.get("apple_user_token"): + headers["Music-User-Token"] = str(credentials["apple_user_token"]) + search = request_json(query_url("https://api.music.apple.com/v1/catalog/" + storefront + "/search", { + "term": " ".join(filter(None, (track.get("title"), track.get("artist")))), "types": "songs", "limit": 10 + }), headers) + songs = search.get("results", {}).get("songs", {}).get("data", []) + best = best_match(songs, track, lambda x: x.get("attributes", {}).get("name", ""), + lambda x: x.get("attributes", {}).get("artistName", ""), + lambda x: x.get("attributes", {}).get("albumName", "")) + if not best: + return empty(source, "apple_music: no match") + body, charset = request_data( + "https://amp-api.music.apple.com/v1/catalog/" + storefront + "/songs/" + urllib.parse.quote(str(best["id"])) + "/lyrics", + headers, + ) + text = body.decode(charset, "replace") + try: + payload = json.loads(text) + lyric_data = first_value(payload, ("ttml", "syllableLyrics", "lyrics", "content")) + lines = parse_payload(lyric_data) if lyric_data is not None else parse_json_lines(payload) + except ValueError: + lines = parse_ttml(text) + return success(source, lines, ["apple_music: match"], duration_ms(track.get("duration"))) + + +def adapter_musixmatch(track, credentials, options): + source = "musixmatch" + token = clean_text(credentials.get("musixmatch_token")) + if not token: + return empty(source, "musixmatch: usertoken required") + params = { + "app_id": "web-desktop-app-v1.0", "usertoken": token, + "q_track": track.get("title", ""), "q_artist": track.get("artist", ""), + "q_album": track.get("album", ""), "subtitle_format": "lrc", "page_size": 5, + } + language = clean_text(options.get("translation_language")) + if language: + params["selected_language"] = language + data = request_json(query_url("https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get", params), + {"Origin": "https://www.musixmatch.com", "Referer": "https://www.musixmatch.com/"}) + subtitle = first_value(data, ("subtitle_body",)) + if not subtitle: + return empty(source, "musixmatch: lyrics unavailable") + lines = parse_lrc(subtitle) + translated = first_value(data, ("translation_list", "translations")) + if isinstance(translated, list): + translated_lines = [] + for item in translated: + value = item.get("translation", item) if isinstance(item, dict) else item + if isinstance(value, dict): + text = value.get("description", value.get("translation", "")) + time = value.get("time", value.get("matched_line", -1)) + translated_lines.append(line(time, text=text)) + merge_timed(lines, translated_lines, "translation") + return success(source, lines, ["musixmatch: match"], duration_ms(track.get("duration"))) + + +ADAPTERS = { + "lrclib": adapter_lrclib, + "netease": adapter_netease, + "netease_public": adapter_netease, + "qq": adapter_qqmusic, + "qqmusic": adapter_qqmusic, + "kugou": adapter_kugou, + "qishui": adapter_qishui, + "apple": adapter_apple_music, + "apple_music": adapter_apple_music, + "spotify": adapter_spotify, + "musixmatch": adapter_musixmatch, +} + + +def main(): + source = "" + response = None + if len(sys.argv) != 2: + response = empty(source, "request: expected one file path") + else: + try: + with open(sys.argv[1], "r", encoding="utf-8") as request_file: + request = json.load(request_file) + try: + os.remove(sys.argv[1]) + except OSError: + pass + except FileNotFoundError: + response = empty(source, "request: file not found") + except (OSError, UnicodeError, json.JSONDecodeError): + response = empty(source, "request: unreadable or invalid JSON") + else: + try: + if not isinstance(request, dict): + response = empty(source, "request: invalid JSON object") + else: + source = clean_text(request.get("source")).lower() + track = request.get("track") if isinstance(request.get("track"), dict) else {} + credentials = request.get("credentials") if isinstance(request.get("credentials"), dict) else {} + options = request.get("options") if isinstance(request.get("options"), dict) else {} + adapter = ADAPTERS.get(source) + if not adapter: + response = empty(source, "request: unknown source") + elif not clean_text(track.get("title")): + response = empty(source, "request: track title required") + else: + response = adapter(track, credentials, options) + except urllib.error.HTTPError as error: + response = empty(source, "source: HTTP " + str(error.code)) + except (urllib.error.URLError, TimeoutError): + response = empty(source, "source: network failure") + except (ValueError, ET.ParseError): + response = empty(source, "source: invalid response") + except Exception: + response = empty(source, "source: unexpected failure") + print(json.dumps(response or empty(source, "source: empty response"), ensure_ascii=False, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau index 6458607..a9148e6 100644 --- a/lyrics/lyrics.luau +++ b/lyrics/lyrics.luau @@ -8,8 +8,12 @@ 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 coverShape = noctalia.getConfig("cover_shape") or "circle" +local coverRadius = tonumber(noctalia.getConfig("cover_radius")) or 5 +local coverSize = tonumber(noctalia.getConfig("cover_size")) or 18 local activeColor = noctalia.getConfig("active_color") or "on_surface" local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant" +local secondaryColor = noctalia.getConfig("secondary_color") or "on_surface_variant" -- These are plugin settings, not per-widget settings. local scrollMode = noctalia.getConfig("scroll_mode") or "auto" @@ -25,14 +29,35 @@ if cueText == "" then cueText = "•••••" end local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow" local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif" if cueFontFamily == "" then cueFontFamily = "sans-serif" end +local displayMode = noctalia.getConfig("display_mode") or "toggle" +local doubleLine = noctalia.getConfig("double_line") +if doubleLine == nil then doubleLine = true end +local doubleLineAutoFit = noctalia.getConfig("double_line_auto_fit") +if doubleLineAutoFit == nil then doubleLineAutoFit = true end +local doubleLineHeightBudget = tonumber(noctalia.getConfig("double_line_height_budget")) or 26 +local showTranslation = noctalia.getConfig("show_translation") +if showTranslation == nil then showTranslation = true end +local showRomanization = noctalia.getConfig("show_romanization") == true +local secondaryLineMode = noctalia.getConfig("secondary_line_mode") or "translation_first" +local karaokeEnabled = noctalia.getConfig("karaoke_enabled") +if karaokeEnabled == nil then karaokeEnabled = true end +local fontFamily = noctalia.getConfig("font_family") or "" +local primaryFontSize = tonumber(noctalia.getConfig("primary_font_size")) or 0 +local secondaryFontSize = tonumber(noctalia.getConfig("secondary_font_size")) or 10 +local fontWeight = noctalia.getConfig("font_weight") or "normal" +local fontStyle = noctalia.getConfig("font_style") or "normal" +local lineGap = tonumber(noctalia.getConfig("line_gap")) or 2 +local paddingLeft = tonumber(noctalia.getConfig("padding_left")) or 0 +local paddingRight = tonumber(noctalia.getConfig("padding_right")) or 0 local track = nil local lyrics = nil local cover = nil local playing = false -local showMode = "auto" +local showMode = displayMode == "track" and "track" or "auto" local rendered = false local playerInstance = "" +local sourceUsed = "" local baselinePosition = 0 local baselineClock = os.clock() @@ -80,6 +105,23 @@ local function toChars(text) return chars end +local function charUnits(char) + if char == " " or char == "\t" then return 0.35 end + local byte = char:byte(1) or 0 + if byte < 0x80 then + if char:match("[%.,:;!'|ilI%-%(%)]") then return 0.38 end + if char:match("[MW@#%%&]") then return 0.85 end + return 0.58 + end + return 1 +end + +local function textUnits(text) + local units = 0 + for _, char in ipairs(toChars(text or "")) do units = units + charUnits(char) end + return units +end + local function getProgressMs() local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0 return baselinePosition / 1000 + elapsed @@ -176,11 +218,20 @@ local function getFallbackLabel() end local function shouldMarquee(text) - return scrollMode ~= "static" and #toChars(text) > maxChars + return scrollMode ~= "static" and textUnits(text) > maxChars end -local function getMarqueeOffset(length) - local distance = math.max(0, length - maxChars) +local function getMarqueeOffset(chars) + local trailingChars = 0 + local units = 0 + for index = #chars, 1, -1 do + local char = chars[index] + local nextUnits = units + charUnits(char) + if nextUnits > maxChars then break end + units = nextUnits + trailingChars = trailingChars + 1 + end + local distance = math.max(0, #chars - math.max(1, trailingChars)) if distance == 0 then return 0 end -- The setting is pixels per second; convert it to character cells per second. @@ -225,6 +276,37 @@ local function karaokeColor(charTime, charProgress, position, useGradient) return active .. "/" .. string.format("%.2f", math.max(0.72, alpha)) end +local function labelBaseline() + if fontStyle == "fixed" then return "textFixedHeight" end + if fontStyle == "ink_centered" then return "inkCentered" end + return "text" +end + +local function secondaryText(line) + if not doubleLine or type(line) ~= "table" then return "" end + local translation = showTranslation and (line.translation or "") or "" + local romanization = showRomanization and (line.romanization or "") or "" + if secondaryLineMode == "translation" then return translation end + if secondaryLineMode == "romanization" then return romanization end + if secondaryLineMode == "romanization_first" then + return romanization ~= "" and romanization or translation + end + return translation ~= "" and translation or romanization +end + +local function secondaryRowEnabled() + return doubleLine and (showTranslation or showRomanization) +end + +local function compactMetrics(hasSecondary, vertical) + if not hasSecondary or vertical or not doubleLineAutoFit then return 1, lineGap end + local primaryBase = primaryFontSize > 0 and primaryFontSize or 13 + local secondaryBase = secondaryFontSize > 0 and secondaryFontSize or 10 + local naturalHeight = primaryBase * 1.25 + secondaryBase * 1.25 + lineGap + local scale = math.min(1, doubleLineHeightBudget / math.max(1, naturalHeight)) + return scale, math.floor(lineGap * scale + 0.5) +end + local function buildLineRow(line, opts) opts = opts or {} local text = type(line) == "table" and line.text or line @@ -233,16 +315,25 @@ local function buildLineRow(line, opts) local chars = toChars(text or "") if #chars == 0 then chars = { " " } end - local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0 + 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 last = #chars + if opts.marquee then + local units = 0 + last = first - 1 + for sourceIndex = first, #chars do + units = units + charUnits(chars[sourceIndex]) + last = sourceIndex + if units > maxChars then break end + end + end local labels = {} for sourceIndex = first, last do local active = playing and activeColor or inactiveColor - local color = opts.dim and inactiveColor or active + local color = opts.secondary and secondaryColor or (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) @@ -263,17 +354,24 @@ local function buildLineRow(line, opts) local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78) alpha = alpha * (wave * (1 - waveProgress) + waveProgress) end + if opts.typewriter then + local reveal = clamp((transitionElapsed - FADE_OUT_MS) / 520, 0, 1) + local threshold = (sourceIndex - first) / math.max(1, last - first + 1) + alpha = alpha * (reveal >= threshold and 1 or 0.08) + end + if opts.blink and transitionElapsed < FADE_OUT_MS + 540 then + local blink = math.floor(math.max(0, transitionElapsed - FADE_OUT_MS) / 90) % 2 + alpha = alpha * (blink == 0 and 0.35 or 1) + 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 + elseif sourceIndex == last and sourceIndex > first then alpha = alpha * fraction end - elseif sourceIndex == first + maxChars then - alpha = 0 end labels[#labels + 1] = ui.label({ @@ -282,12 +380,17 @@ local function buildLineRow(line, opts) color = color, opacity = alpha, maxLines = 1, - fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil, + fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or (fontFamily ~= "" and fontFamily or nil), + fontSize = opts.secondary and math.max(6, secondaryFontSize * (opts.fontScale or 1)) + or ((primaryFontSize > 0 or (opts.fontScale or 1) < 1) + and math.max(6, (primaryFontSize > 0 and primaryFontSize or 13) * (opts.fontScale or 1)) or nil), + fontWeight = opts.secondary and "normal" or fontWeight, + baseline = labelBaseline(), }) end if opts.marquee then - while #labels < maxChars + 1 do + while #labels < math.ceil(maxChars) + 1 do labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 }) end end @@ -305,6 +408,9 @@ 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) + if animation == "pulse" and transitionElapsed < FADE_OUT_MS + 600 then + opacity = opacity * (0.76 + 0.24 * math.sin(math.max(0, transitionElapsed - FADE_OUT_MS) / 75)) + end return info and info.line or nil, opacity end return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS) @@ -323,7 +429,11 @@ local function render() 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" }) + local radius = coverShape == "circle" and coverSize / 2 + or coverShape == "rounded" and math.min(5, coverSize / 2) + or coverShape == "square" and 0 + or math.min(coverRadius, coverSize / 2) + prefix[#prefix + 1] = ui.image({ path = coverPath, width = coverSize, height = coverSize, radius = radius, fit = "cover" }) else prefix[#prefix + 1] = ui.glyph({ name = glyph, @@ -333,7 +443,9 @@ local function render() end local lineNodes = {} - if showMode == "track" then + local renderLineGap = lineGap + local renderTextHeight = 0 + if displayMode == "track" or showMode == "track" then local label = getTrackLabel() lineNodes[1] = buildLineRow(label, { key = "track", @@ -345,17 +457,38 @@ local function render() if info then local shownLine, opacity = currentTransitionLine(info) if shownLine then + local showSecondaryRow = secondaryRowEnabled() and not shownLine.cue and not info.cue + local secondary = showSecondaryRow and secondaryText(shownLine) or "" + local fontScale, effectiveLineGap = compactMetrics(showSecondaryRow, vertical) 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"), + useGradient = karaokeEnabled and shownLine == info.line and info.synced and useGradient, + solid = not karaokeEnabled or 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", + typewriter = shownLine == info.line and animation == "typewriter", + blink = shownLine == info.line and animation == "blink", opacity = opacity, cue = shownLine == info.line and info.cue == true, + fontScale = fontScale, + compact = showSecondaryRow and not vertical and doubleLineAutoFit, }) + if showSecondaryRow then + lineNodes[#lineNodes + 1] = buildLineRow({ text = secondary }, { + key = "secondary-" .. tostring(info.index), + marquee = secondary ~= "" and shouldMarquee(secondary), + solid = true, + dim = true, + secondary = true, + opacity = secondary ~= "" and math.min(0.82, opacity) or 0, + fontScale = fontScale, + compact = not vertical and doubleLineAutoFit, + }) + renderLineGap = effectiveLineGap + if not vertical and doubleLineAutoFit then renderTextHeight = doubleLineHeightBudget end + end end if vertical and maxLines > 1 and shownLine == info.line then @@ -382,20 +515,41 @@ local function render() 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), + ui.column({ + gap = renderLineGap, + align = "start", + justify = renderTextHeight > 0 and "center" or nil, + height = renderTextHeight > 0 and renderTextHeight or nil, + }, lineNodes), })) else local children = {} + if paddingLeft > 0 then children[#children + 1] = ui.spacer({ width = paddingLeft }) end for _, node in ipairs(prefix) do children[#children + 1] = node end - children[#children + 1] = lineNodes[1] + children[#children + 1] = ui.column({ + gap = renderLineGap, + align = "start", + justify = renderTextHeight > 0 and "center" or nil, + height = renderTextHeight > 0 and renderTextHeight or nil, + }, lineNodes) + if paddingRight > 0 then children[#children + 1] = ui.spacer({ width = paddingRight }) end barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children)) end - barWidget.setTooltip(getTrackLabel()) + local sourceNames = { + lrclib = "LRCLIB", netease = "NetEase", qqmusic = "QQ Music", kugou = "Kugou", + qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify", + musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache", + } + local sourceLabel = noctalia.tr("source_label") + local tooltip = getTrackLabel() + if sourceUsed ~= "" then tooltip = tooltip .. "\n" .. sourceLabel .. ": " .. (sourceNames[sourceUsed] or sourceUsed) end + barWidget.setTooltip(tooltip) rendered = true end function onClick() + if displayMode ~= "toggle" then return end showMode = showMode == "auto" and "track" or "auto" marqueeElapsed = 0 if rendered then render() end @@ -425,6 +579,7 @@ function update() cover = noctalia.state.get("cover") playing = noctalia.state.get("playing") == true playerInstance = noctalia.state.get("player_instance") or "" + sourceUsed = noctalia.state.get("lyrics_source_used") or "" local info = getLineInfo() local nextKey = info and info.key or "fallback" @@ -443,7 +598,10 @@ function update() 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 + local secondary = showMode ~= "track" and info and secondaryText(info.line) or "" + if shouldMarquee(text) or (secondary ~= "" and shouldMarquee(secondary)) then + marqueeElapsed = marqueeElapsed + delta + end end render() diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau index 4953c47..e1690f1 100644 --- a/lyrics/lyrics_service.luau +++ b/lyrics/lyrics_service.luau @@ -3,23 +3,38 @@ -- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music -- via /api endpoints, publishes state. No polling delay. -noctalia.setUpdateInterval(250) +local updateIntervalMs = 100 +noctalia.setUpdateInterval(updateIntervalMs) local cache = {} local coverCache = {} local lastTrackKey = "" local inFlight = nil +local fetchGeneration = 0 +local pollInFlight = false local coverInFlight = nil local pluginDir = noctalia.pluginDir() or "/tmp" local cacheDir = pluginDir .. "/.cache" noctalia.mkdirAll(cacheDir) local krcTmp = cacheDir .. "/krc.tmp" local lyricsSource = noctalia.getConfig("lyrics_source") or "auto" +local lyricsSources = noctalia.getConfig("lyrics_sources") or { + "lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch" +} local customUrl = noctalia.getConfig("custom_url") or "" local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" +local pollIntervalMs = tonumber(noctalia.getConfig("poll_interval_ms")) or 500 +local lyricsOffsetMs = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0 +local displayMode = noctalia.getConfig("display_mode") or "toggle" +local credentialSignature = "" local currentTrack = nil local currentEmbeddedLyrics = "" local currentPlayerInstance = "" +local currentArtUrl = "" + +for _, name in ipairs(noctalia.listDir(cacheDir) or {}) do + if name:match("^source_request_.*%.json$") then noctalia.removeFile(cacheDir .. "/" .. name) end +end local function normalizePatterns(value) if type(value) ~= "table" then return {} end @@ -72,9 +87,11 @@ local function selectPlayer(players) end local function clearPlayerState() + fetchGeneration = fetchGeneration + 1 currentPlayerInstance = "" currentTrack = nil currentEmbeddedLyrics = "" + currentArtUrl = "" lastTrackKey = "" inFlight = nil coverInFlight = nil @@ -86,9 +103,14 @@ local function clearPlayerState() noctalia.state.set("playing", false) end -local function coverPathFor(track) - local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_") - return cacheDir .. "/cover_" .. safe .. ".jpg" +local function stableHash(value) + local hash = 5381 + for index = 1, #value do hash = (hash * 33 + value:byte(index)) % 4294967296 end + return string.format("%08x", hash) +end + +local function coverPathFor(track, artUrl) + return cacheDir .. "/cover_" .. stableHash(trackKey(track) .. "|" .. (artUrl or "")) .. ".jpg" end local function shellQuote(value) @@ -134,6 +156,46 @@ local function parsePlain(text) return lines end +local function credentialsFor(source) + if source == "spotify" then + return { + spotify_sp_dc = noctalia.getConfig("spotify_sp_dc") or "", + spotify_access_token = noctalia.getConfig("spotify_access_token") or "", + } + elseif source == "apple_music" then + return { + apple_developer_token = noctalia.getConfig("apple_developer_token") or "", + apple_user_token = noctalia.getConfig("apple_user_token") or "", + apple_storefront = noctalia.getConfig("apple_storefront") or "us", + } + elseif source == "musixmatch" then + return { musixmatch_token = noctalia.getConfig("musixmatch_token") or "" } + elseif source == "qishui" then + return { + qishui_token = noctalia.getConfig("qishui_token") or "", + qishui_api_url = noctalia.getConfig("qishui_api_url") or "", + } + end + return {} +end + +local function normalizedSources() + if lyricsSource ~= "auto" then return { lyricsSource } end + local result = {} + local seen = {} + if type(lyricsSources) == "table" then + for _, source in ipairs(lyricsSources) do + source = tostring(source):lower():gsub("^%s+", ""):gsub("%s+$", "") + if source ~= "" and not seen[source] then + result[#result + 1] = source + seen[source] = true + end + end + end + if #result == 0 then result = { "lrclib", "netease" } end + return result +end + local function evictCache() local keys = {} for k, _ in pairs(cache) do keys[#keys + 1] = k end @@ -145,14 +207,15 @@ end local function fetchLyricsNetEase(track, embeddedLyrics) local tk = trackKey(track) - if inFlight == tk then return end - inFlight = tk + fetchGeneration = fetchGeneration + 1 + local flight = tk .. "|" .. tostring(fetchGeneration) + inFlight = flight 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 inFlight ~= tk then return end + if inFlight ~= flight 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 @@ -196,7 +259,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) 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 inFlight ~= tk then return end + if inFlight ~= flight then return end if tk ~= lastTrackKey then inFlight = nil; return end local lyrics = nil @@ -218,7 +281,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) if klyricStr ~= "" then local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"' noctalia.runAsync(py, function(r3) - if inFlight ~= tk then return end + if inFlight ~= flight 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 @@ -296,7 +359,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) local function runPy(script, cb) local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"' noctalia.runAsync(py, function(r) - if inFlight ~= tk then return end + if inFlight ~= flight 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) @@ -314,10 +377,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics) return true end - local function fetchCustom() + local function fetchCustom(fallback) if customUrl == "" then - inFlight = nil - noctalia.state.set("lyrics", nil) + if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end return end @@ -332,6 +394,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) end) noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response) + if inFlight ~= flight then return end if tk ~= lastTrackKey then inFlight = nil; return end local text = response.ok and response.body or "" if text ~= "" and customJsonField ~= "" then @@ -339,17 +402,25 @@ local function fetchLyricsNetEase(track, embeddedLyrics) for part in customJsonField:gmatch("[^.]+") do decoded = type(decoded) == "table" and decoded[part] or nil end - if type(decoded) == "table" then + if type(decoded) == "table" then cache[tk] = decoded evictCache() noctalia.state.set("lyrics", decoded) + noctalia.state.set("lyrics_source_used", "custom") inFlight = nil return end text = type(decoded) == "string" and decoded or "" end - applyText(text) - inFlight = nil + if applyText(text) then + noctalia.state.set("lyrics_source_used", "custom") + inFlight = nil + elseif fallback then + fallback() + else + inFlight = nil + noctalia.state.set("lyrics", nil) + end end) end @@ -357,48 +428,98 @@ local function fetchLyricsNetEase(track, embeddedLyrics) inFlight = nil return elseif lyricsSource == "mpris" then - applyText(embeddedLyrics) + if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end inFlight = nil return elseif lyricsSource == "custom" then - fetchCustom() - return - elseif lyricsSource == "netease" then - tryFetch(track.title .. " " .. track.artist, function() - tryFetch(track.title, nil) - end) + fetchCustom(nil) 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 + local requestPath = cacheDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json" + local sources = normalizedSources() + local request = { + track = track, + options = { + translation_language = noctalia.getConfig("translation_language") or "zh-Hans", + }, + } + + local function trySource(index) + if inFlight ~= flight then return end + local source = sources[index] + if not source then inFlight = nil noctalia.state.set("lyrics", nil) + noctalia.state.set("lyrics_source_used", nil) return end - tryFetch(track.title .. " " .. track.artist, function() - tryFetch(track.title, nil) - end) - end) + if source == "mpris" then + if applyText(embeddedLyrics) then + inFlight = nil + noctalia.state.set("lyrics_source_used", "mpris") + else + trySource(index + 1) + end + return + end + if source == "custom" then + fetchCustom(function() trySource(index + 1) end) + return + end + request.source = source + request.credentials = credentialsFor(source) + local encoded = noctalia.json.encode(request) + if not encoded or not noctalia.writeFile(requestPath, encoded) then + trySource(index + 1) + return + end + local command = "chmod 600 " .. shellQuote(requestPath) .. " && python3 " + .. shellQuote(pluginDir .. "/lyric_sources.py") .. " " .. shellQuote(requestPath) + local started = noctalia.runAsync(command, function(result) + noctalia.removeFile(requestPath) + if inFlight ~= flight or tk ~= lastTrackKey then return end + local parsed = noctalia.json.decode(result.stdout or "") + if type(parsed) == "table" and parsed.type == "lyrics" and type(parsed.lines) == "table" and #parsed.lines > 0 then + cache[tk] = parsed.lines + evictCache() + inFlight = nil + noctalia.state.set("lyrics", parsed.lines) + noctalia.state.set("lyrics_source_used", parsed.source or source) + else + trySource(index + 1) + end + end, 30000) + if not started then + noctalia.removeFile(requestPath) + trySource(index + 1) + end + end + + trySource(1) end local function fetchCover(track, artUrl) local tk = trackKey(track) - if coverInFlight == tk then return end - coverInFlight = tk + local ck = tk .. "|" .. (artUrl or "") + if coverInFlight == ck then return end + coverInFlight = ck - local dest = coverPathFor(track) + local dest = coverPathFor(track, artUrl) local apply = function(path) - if coverInFlight ~= tk then return end + if coverInFlight ~= ck then return end coverInFlight = nil if tk ~= lastTrackKey then return end - coverCache[tk] = path + coverCache[ck] = path noctalia.state.set("cover", path) end + if noctalia.fileExists(dest) then + apply(dest) + return + end + if not artUrl or artUrl == "" then noctalia.state.set("cover", nil) coverInFlight = nil @@ -417,15 +538,38 @@ local function fetchCover(track, artUrl) return end - noctalia.download(artUrl, dest, function(ok) - if ok then apply(dest) else apply(nil) end + local downloadUrl = artUrl + local fallbackUrl = nil + if artUrl:find("music%.126%.net", 1) then + downloadUrl = artUrl:gsub("https?://p%d+%.music%.126%.net", "https://p1.music.126.net") + if downloadUrl:find("[?&]param=%d+y%d+") then + downloadUrl = downloadUrl:gsub("param=%d+y%d+", "param=400y400") + else + downloadUrl = downloadUrl .. (downloadUrl:find("?", 1, true) and "&" or "?") .. "param=400y400" + end + fallbackUrl = downloadUrl:gsub("https://p1%.music%.126%.net", "https://p3.music.126.net") + end + + noctalia.download(downloadUrl, dest, function(ok) + if ok then + apply(dest) + elseif fallbackUrl then + noctalia.download(fallbackUrl, dest, function(fallbackOk) + if fallbackOk then apply(dest) else apply(nil) end + end) + else + apply(nil) + end end) end local function poll() + if pollInFlight then return end + pollInFlight = true local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]] - noctalia.runAsync(cmd, function(r) + local started = noctalia.runAsync(cmd, function(r) + pollInFlight = false if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then clearPlayerState() return @@ -486,8 +630,10 @@ local function poll() local tk = trackKey(t) currentTrack = t currentEmbeddedLyrics = selected.embeddedLyrics + local previousArtUrl = currentArtUrl + currentArtUrl = selected.artUrl - noctalia.state.set("position", t.position) + noctalia.state.set("position", t.position + lyricsOffsetMs * 1000) if tk ~= lastTrackKey then lastTrackKey = tk @@ -495,14 +641,19 @@ local function poll() noctalia.state.set("playing", playing) local cached = cache[tk] - if cached then + if displayMode == "track" then + noctalia.state.set("lyrics", nil) + noctalia.state.set("lyrics_source_used", nil) + elseif cached then noctalia.state.set("lyrics", cached) + noctalia.state.set("lyrics_source_used", "cache") else noctalia.state.set("lyrics", nil) fetchLyricsNetEase(t, selected.embeddedLyrics) end - local cachedCover = coverCache[tk] + local coverKey = tk .. "|" .. selected.artUrl + local cachedCover = coverCache[coverKey] if cachedCover then noctalia.state.set("cover", cachedCover) else @@ -512,16 +663,22 @@ local function poll() else noctalia.state.set("track", t) noctalia.state.set("playing", playing) + if selected.artUrl ~= previousArtUrl then + noctalia.state.set("cover", nil) + fetchCover(t, selected.artUrl) + end end end) + if not started then pollInFlight = false end end -local pollTick = 0 +local pollElapsedMs = pollIntervalMs function update() if lyricsSource == "external" then return end - pollTick = pollTick + 1 - if pollTick % 2 == 0 then + pollElapsedMs = pollElapsedMs + updateIntervalMs + if pollElapsedMs >= pollIntervalMs then + pollElapsedMs = 0 poll() end @@ -531,26 +688,51 @@ 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 nextSources = noctalia.getConfig("lyrics_sources") or lyricsSources + local nextPollInterval = tonumber(noctalia.getConfig("poll_interval_ms")) or 500 + local nextOffset = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0 + local nextDisplayMode = noctalia.getConfig("display_mode") or "toggle" + local nextCredentialSignature = table.concat({ + noctalia.getConfig("spotify_sp_dc") or "", + noctalia.getConfig("spotify_access_token") or "", + noctalia.getConfig("apple_developer_token") or "", + noctalia.getConfig("apple_user_token") or "", + noctalia.getConfig("apple_storefront") or "us", + noctalia.getConfig("musixmatch_token") or "", + noctalia.getConfig("qishui_token") or "", + noctalia.getConfig("qishui_api_url") or "", + noctalia.getConfig("translation_language") or "zh-Hans", + }, "\0") local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")) local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist")) local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField + or table.concat(nextSources, "\n") ~= table.concat(lyricsSources, "\n") + or nextCredentialSignature ~= credentialSignature + or nextDisplayMode ~= displayMode local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n") or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n") lyricsSource = nextSource customUrl = nextUrl customJsonField = nextField + lyricsSources = nextSources + pollIntervalMs = math.max(100, nextPollInterval) + lyricsOffsetMs = nextOffset + displayMode = nextDisplayMode + credentialSignature = nextCredentialSignature playerAllowlist = nextAllowlist playerBlocklist = nextBlocklist if playersChanged then currentPlayerInstance = "" + fetchGeneration = fetchGeneration + 1 inFlight = nil poll() end if sourceChanged and currentTrack and not playersChanged then + fetchGeneration = fetchGeneration + 1 inFlight = nil cache = {} noctalia.state.set("lyrics", nil) - fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) + if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end end end @@ -572,7 +754,7 @@ local function applyPushedLyrics(payload) 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.position ~= nil then noctalia.state.set("position", decoded.position + lyricsOffsetMs * 1000) 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 diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml index ec8f2bc..53e9029 100644 --- a/lyrics/plugin.toml +++ b/lyrics/plugin.toml @@ -1,10 +1,10 @@ id = "h465855hgg/lyrics" name = "Lyrics" -version = "1.3.0" +version = "1.4.0" plugin_api = 3 author = "h465855hgg" license = "MIT" -dependencies = ["playerctl", "python3", "cp"] +dependencies = ["playerctl", "python3", "cp", "chmod"] tags = ["bar", "service", "music", "media", "animation"] icon = "music" description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources." @@ -35,11 +35,112 @@ 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 = "qqmusic", label_key = "settings.lyrics_source.options.qqmusic" }, + { value = "kugou", label_key = "settings.lyrics_source.options.kugou" }, + { value = "qishui", label_key = "settings.lyrics_source.options.qishui" }, + { value = "apple_music", label_key = "settings.lyrics_source.options.apple_music" }, + { value = "spotify", label_key = "settings.lyrics_source.options.spotify" }, + { value = "musixmatch", label_key = "settings.lyrics_source.options.musixmatch" }, { 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 = "lyrics_sources" +type = "string_list" +label_key = "settings.lyrics_sources.label" +description_key = "settings.lyrics_sources.description" +default = ["lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"] +visible_when = { key = "lyrics_source", values = ["auto"] } +advanced = true + +[[setting]] +key = "translation_language" +type = "select" +label_key = "settings.translation_language.label" +description_key = "settings.translation_language.description" +default = "zh-Hans" +options = [ + { value = "zh-Hans", label_key = "settings.translation_language.options.zh-Hans" }, + { value = "zh-Hant", label_key = "settings.translation_language.options.zh-Hant" }, + { value = "en", label_key = "settings.translation_language.options.en" }, + { value = "ja", label_key = "settings.translation_language.options.ja" }, + { value = "ko", label_key = "settings.translation_language.options.ko" }, +] + +[[setting]] +key = "spotify_sp_dc" +type = "string" +label_key = "settings.spotify_sp_dc.label" +description_key = "settings.spotify_sp_dc.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["spotify"] } + +[[setting]] +key = "spotify_access_token" +type = "string" +label_key = "settings.spotify_access_token.label" +description_key = "settings.spotify_access_token.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["spotify"] } + +[[setting]] +key = "apple_developer_token" +type = "string" +label_key = "settings.apple_developer_token.label" +description_key = "settings.apple_developer_token.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["apple_music"] } + +[[setting]] +key = "apple_user_token" +type = "string" +label_key = "settings.apple_user_token.label" +description_key = "settings.apple_user_token.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["apple_music"] } + +[[setting]] +key = "apple_storefront" +type = "string" +label_key = "settings.apple_storefront.label" +description_key = "settings.apple_storefront.description" +default = "us" +advanced = true +visible_when = { key = "lyrics_source", values = ["apple_music"] } + +[[setting]] +key = "musixmatch_token" +type = "string" +label_key = "settings.musixmatch_token.label" +description_key = "settings.musixmatch_token.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["musixmatch"] } + +[[setting]] +key = "qishui_api_url" +type = "string" +label_key = "settings.qishui_api_url.label" +description_key = "settings.qishui_api_url.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["qishui"] } + +[[setting]] +key = "qishui_token" +type = "string" +label_key = "settings.qishui_token.label" +description_key = "settings.qishui_token.description" +default = "" +advanced = true +visible_when = { key = "lyrics_source", values = ["qishui"] } + [[setting]] key = "custom_url" type = "string" @@ -55,6 +156,7 @@ label_key = "settings.custom_json_field.label" description_key = "settings.custom_json_field.description" default = "syncedLyrics" visible_when = { key = "lyrics_source", values = ["custom"] } +advanced = true [[setting]] key = "cue_text" @@ -69,6 +171,7 @@ type = "select" label_key = "settings.cue_font_mode.label" description_key = "settings.cue_font_mode.description" default = "follow" +advanced = true options = [ { value = "follow", label_key = "settings.cue_font_mode.options.follow" }, { value = "custom", label_key = "settings.cue_font_mode.options.custom" }, @@ -81,6 +184,101 @@ label_key = "settings.cue_font_family.label" description_key = "settings.cue_font_family.description" default = "sans-serif" visible_when = { key = "cue_font_mode", values = ["custom"] } +advanced = true + +[[setting]] +key = "lyrics_offset_ms" +type = "int" +label_key = "settings.lyrics_offset_ms.label" +description_key = "settings.lyrics_offset_ms.description" +default = 0 +min = -10000 +max = 10000 + +[[setting]] +key = "poll_interval_ms" +type = "int" +label_key = "settings.poll_interval_ms.label" +description_key = "settings.poll_interval_ms.description" +default = 500 +min = 100 +max = 5000 +advanced = true + +[[setting]] +key = "display_mode" +type = "select" +label_key = "settings.display_mode.label" +description_key = "settings.display_mode.description" +default = "toggle" +options = [ + { value = "toggle", label_key = "settings.display_mode.options.toggle" }, + { value = "lyrics", label_key = "settings.display_mode.options.lyrics" }, + { value = "track", label_key = "settings.display_mode.options.track" }, +] + +[[setting]] +key = "double_line" +type = "bool" +label_key = "settings.double_line.label" +description_key = "settings.double_line.description" +default = true + +[[setting]] +key = "double_line_auto_fit" +type = "bool" +label_key = "settings.double_line_auto_fit.label" +description_key = "settings.double_line_auto_fit.description" +default = true +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "double_line_height_budget" +type = "int" +label_key = "settings.double_line_height_budget.label" +description_key = "settings.double_line_height_budget.description" +default = 26 +min = 18 +max = 40 +visible_when = { key = "double_line", values = ["true"] } +advanced = true + +[[setting]] +key = "show_translation" +type = "bool" +label_key = "settings.show_translation.label" +description_key = "settings.show_translation.description" +default = true +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "show_romanization" +type = "bool" +label_key = "settings.show_romanization.label" +description_key = "settings.show_romanization.description" +default = false +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "secondary_line_mode" +type = "select" +label_key = "settings.secondary_line_mode.label" +description_key = "settings.secondary_line_mode.description" +default = "translation_first" +options = [ + { value = "translation_first", label_key = "settings.secondary_line_mode.options.translation_first" }, + { value = "romanization_first", label_key = "settings.secondary_line_mode.options.romanization_first" }, + { value = "translation", label_key = "settings.secondary_line_mode.options.translation" }, + { value = "romanization", label_key = "settings.secondary_line_mode.options.romanization" }, +] +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "karaoke_enabled" +type = "bool" +label_key = "settings.karaoke_enabled.label" +description_key = "settings.karaoke_enabled.description" +default = true [[setting]] key = "scroll_mode" @@ -102,6 +300,7 @@ default = 30 min = 10 max = 120 description_key = "settings.marquee_speed.description" +advanced = true [[setting]] key = "max_lines" @@ -111,6 +310,7 @@ default = 1 min = 1 max = 3 description_key = "settings.max_lines.description" +advanced = true [[setting]] key = "gradient" @@ -130,6 +330,9 @@ options = [ { 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 = "typewriter", label_key = "settings.animation.options.typewriter" }, + { value = "pulse", label_key = "settings.animation.options.pulse" }, + { value = "blink", label_key = "settings.animation.options.blink" }, { value = "none", label_key = "settings.animation.options.none" }, ] @@ -141,6 +344,7 @@ default = 15 min = 8 max = 80 description_key = "settings.max_chars.description" +advanced = true [[setting]] key = "char_width" @@ -150,6 +354,92 @@ default = 9 min = 4 max = 24 description_key = "settings.char_width.description" +advanced = true + +[[setting]] +key = "font_family" +type = "string" +label_key = "settings.font_family.label" +description_key = "settings.font_family.description" +default = "" + +[[setting]] +key = "primary_font_size" +type = "int" +label_key = "settings.primary_font_size.label" +description_key = "settings.primary_font_size.description" +default = 0 +min = 0 +max = 48 + +[[setting]] +key = "font_weight" +type = "select" +label_key = "settings.font_weight.label" +description_key = "settings.font_weight.description" +default = "normal" +options = [ + { value = "thin", label_key = "settings.font_weight.options.thin" }, + { value = "light", label_key = "settings.font_weight.options.light" }, + { value = "normal", label_key = "settings.font_weight.options.normal" }, + { value = "medium", label_key = "settings.font_weight.options.medium" }, + { value = "semibold", label_key = "settings.font_weight.options.semibold" }, + { value = "bold", label_key = "settings.font_weight.options.bold" }, + { value = "heavy", label_key = "settings.font_weight.options.heavy" }, +] + +[[setting]] +key = "secondary_font_size" +type = "int" +label_key = "settings.secondary_font_size.label" +description_key = "settings.secondary_font_size.description" +default = 10 +min = 6 +max = 36 +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "font_style" +type = "select" +label_key = "settings.font_style.label" +description_key = "settings.font_style.description" +default = "normal" +advanced = true +options = [ + { value = "normal", label_key = "settings.font_style.options.normal" }, + { value = "fixed", label_key = "settings.font_style.options.fixed" }, + { value = "ink_centered", label_key = "settings.font_style.options.ink_centered" }, +] + +[[setting]] +key = "line_gap" +type = "int" +label_key = "settings.line_gap.label" +description_key = "settings.line_gap.description" +default = 2 +min = 0 +max = 24 +visible_when = { key = "double_line", values = ["true"] } + +[[setting]] +key = "padding_left" +type = "int" +label_key = "settings.padding_left.label" +description_key = "settings.padding_left.description" +default = 0 +min = 0 +max = 64 +advanced = true + +[[setting]] +key = "padding_right" +type = "int" +label_key = "settings.padding_right.label" +description_key = "settings.padding_right.description" +default = 0 +min = 0 +max = 64 +advanced = true # Headless background service: polls MPRIS metadata, fetches lyrics, publishes state. [[service]] @@ -186,6 +476,38 @@ entry = "lyrics.luau" description_key = "settings.show_cover.description" default = true + [[widget.setting]] + key = "cover_shape" + type = "select" + label_key = "settings.cover_shape.label" + description_key = "settings.cover_shape.description" + default = "circle" + options = [ + { value = "circle", label_key = "settings.cover_shape.options.circle" }, + { value = "rounded", label_key = "settings.cover_shape.options.rounded" }, + { value = "square", label_key = "settings.cover_shape.options.square" }, + { value = "custom", label_key = "settings.cover_shape.options.custom" }, + ] + + [[widget.setting]] + key = "cover_radius" + type = "int" + label_key = "settings.cover_radius.label" + description_key = "settings.cover_radius.description" + default = 5 + min = 0 + max = 32 + visible_when = { key = "cover_shape", values = ["custom"] } + + [[widget.setting]] + key = "cover_size" + type = "int" + label_key = "settings.cover_size.label" + description_key = "settings.cover_size.description" + default = 18 + min = 12 + max = 32 + [[widget.setting]] key = "active_color" type = "color" @@ -199,3 +521,10 @@ entry = "lyrics.luau" label_key = "settings.inactive_color.label" description_key = "settings.inactive_color.description" default = "on_surface_variant" + + [[widget.setting]] + key = "secondary_color" + type = "color" + label_key = "settings.secondary_color.label" + description_key = "settings.secondary_color.description" + default = "on_surface_variant" diff --git a/lyrics/screenshots/bar widget settings - 1.4.0.png b/lyrics/screenshots/bar widget settings - 1.4.0.png new file mode 100644 index 0000000..d7414de Binary files /dev/null and b/lyrics/screenshots/bar widget settings - 1.4.0.png differ diff --git a/lyrics/screenshots/settings - 1.4.0.png b/lyrics/screenshots/settings - 1.4.0.png new file mode 100644 index 0000000..2a861fe Binary files /dev/null and b/lyrics/screenshots/settings - 1.4.0.png differ diff --git a/lyrics/screenshots/widget - 1.4.0.png b/lyrics/screenshots/widget - 1.4.0.png new file mode 100644 index 0000000..6eb7464 Binary files /dev/null and b/lyrics/screenshots/widget - 1.4.0.png differ diff --git a/lyrics/scripts/setup-deps.sh b/lyrics/scripts/setup-deps.sh index 08b5bd0..78ea064 100755 --- a/lyrics/scripts/setup-deps.sh +++ b/lyrics/scripts/setup-deps.sh @@ -46,9 +46,10 @@ MISSING_COMMANDS="" need_command playerctl need_command python3 need_command cp +need_command chmod if [ -z "$MISSING_COMMANDS" ]; then - echo "All runtime commands are installed: playerctl python3 cp" + echo "All runtime commands are installed: playerctl python3 cp chmod" exit 0 fi @@ -138,6 +139,7 @@ MISSING_COMMANDS="" need_command playerctl need_command python3 need_command cp +need_command chmod if [ -n "$MISSING_COMMANDS" ]; then echo "Still missing after installation:$MISSING_COMMANDS" >&2 diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json index e62e25b..74c10b4 100644 --- a/lyrics/translations/en.json +++ b/lyrics/translations/en.json @@ -1,113 +1,173 @@ { - "instrumental": "Instrumental", - "intro": "Intro", + "title": "Lyrics", "no_lyrics": "♪ No lyrics available", + "intro": "Intro", + "instrumental": "Instrumental", + "source_label": "Lyrics source", "settings": { - "active_color": { - "description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.", - "label": "Active lyric color" + "player_allowlist": { + "label": "Player allowlist", + "description": "Only use matching MPRIS players. Leave empty to allow all. Supports * wildcards and matches player names or instances." }, - "animation": { - "description": "How lyrics animate on line change.", - "label": "Animation", - "options": { - "cascade": "Character cascade", - "fade": "Fade only", - "karaoke": "Karaoke gradient + fade", - "none": "No animation", - "wave": "Character wave reveal" - } - }, - "char_width": { - "description": "Approximate pixel width per character, used to fix the widget width and clip scrolling.", - "label": "Character width (px)" - }, - "cue_font_family": { - "description": "Installed font family used only for intro and interlude characters in custom-font mode.", - "label": "Intro/interlude font" - }, - "cue_font_mode": { - "description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.", - "label": "Intro/interlude font mode", - "options": { - "custom": "Custom font", - "follow": "Follow interface font" - } - }, - "cue_text": { - "description": "Text or characters highlighted during intros and interludes.", - "label": "Intro/interlude characters" - }, - "custom_json_field": { - "description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC.", - "label": "JSON lyrics field" - }, - "custom_url": { - "description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly.", - "label": "Custom endpoint URL" + "player_blocklist": { + "label": "Player blocklist", + "description": "Ignore matching MPRIS players after applying the allowlist. Supports * wildcards, for example chromium* or firefox*." }, "glyph": { "label": "Glyph" }, - "gradient": { - "description": "Light up each character as the song progresses (karaoke style).", - "label": "Per-character gradient" + "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. Defaults to the interface text color." + }, "inactive_color": { - "description": "Color used for upcoming lyrics, paused playback, and secondary lines.", - "label": "Inactive lyric color" + "label": "Inactive lyric color", + "description": "Color used for upcoming lyrics, paused playback, and secondary lines." }, "lyrics_source": { - "description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.", "label": "Lyrics source", + "description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.", "options": { "auto": "Automatic fallback", - "custom": "Custom HTTP endpoint", - "external": "External IPC push", "lrclib": "LRCLIB", + "netease": "NetEase Music", + "qqmusic": "QQ Music", + "kugou": "Kugou Music", + "qishui": "Qishui Music", + "apple_music": "Apple Music", + "spotify": "Spotify", + "musixmatch": "Musixmatch", "mpris": "Local player (MPRIS)", - "netease": "NetEase Music (public API)" + "custom": "Custom HTTP endpoint", + "external": "External IPC push" } }, - "marquee_speed": { - "description": "Pixels per second for long-line scrolling.", - "label": "Marquee speed" + "lyrics_sources": { "label": "Automatic source order", "description": "Source IDs tried from top to bottom: lrclib, netease, qqmusic, kugou, qishui, apple_music, spotify, musixmatch, mpris, or custom." }, + "translation_language": { + "label": "Translation language", "description": "Preferred translated-lyric language when supported by the source.", + "options": { "zh-Hans": "Simplified Chinese", "zh-Hant": "Traditional Chinese", "en": "English", "ja": "Japanese", "ko": "Korean" } }, - "max_chars": { - "description": "Visible width before a long lyric line scrolls (marquee).", - "label": "Max characters" + "spotify_sp_dc": { "label": "Spotify sp_dc", "description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs." }, + "spotify_access_token": { "label": "Spotify access token", "description": "Optional manually supplied Spotify access token. Takes priority over sp_dc." }, + "apple_developer_token": { "label": "Apple Music developer token", "description": "Developer token used for Apple Music catalog and lyrics requests." }, + "apple_user_token": { "label": "Apple Music user token", "description": "Optional Music-User-Token for lyrics requiring a user subscription." }, + "apple_storefront": { "label": "Apple Music storefront", "description": "Apple Music storefront code, for example us, cn, jp, or hk." }, + "musixmatch_token": { "label": "Musixmatch user token", "description": "User token for the Musixmatch desktop subtitle endpoint." }, + "qishui_api_url": { "label": "Qishui API URL", "description": "Endpoint template supporting {title}, {artist}, and {album}. Required because public Qishui endpoints vary." }, + "qishui_token": { "label": "Qishui token", "description": "Optional Bearer token sent only to the configured Qishui endpoint." }, + "custom_url": { + "label": "Custom endpoint URL", + "description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly." }, - "max_lines": { - "description": "Maximum lyric lines to show at once.", - "label": "Max lines" + "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." }, - "player_allowlist": { - "description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players.", - "label": "Allowed players" + "cue_text": { + "label": "Intro/interlude characters", + "description": "Text or characters highlighted during intros and interludes." }, - "player_blocklist": { - "description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist.", - "label": "Blocked players" + "cue_font_mode": { + "label": "Intro/interlude font mode", + "description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.", + "options": { + "follow": "Follow interface font", + "custom": "Custom font" + } }, + "cue_font_family": { + "label": "Intro/interlude font", + "description": "Installed font family used only for intro and interlude characters in custom-font mode." + }, + "lyrics_offset_ms": { "label": "Lyrics offset (ms)", "description": "Positive values show lyrics earlier; negative values show them later." }, + "poll_interval_ms": { "label": "Player polling interval (ms)", "description": "How often the service refreshes MPRIS metadata and playback position." }, + "display_mode": { + "label": "Display mode", "description": "Always show lyrics, always show track information, or allow left-click switching.", + "options": { "toggle": "Click to switch", "lyrics": "Lyrics only", "track": "Track title and artist only" } + }, + "double_line": { "label": "Double-line lyrics", "description": "Show translation or romanization below the current original lyric." }, + "double_line_auto_fit": { "label": "Compact double lines", "description": "Reduce both font sizes and their gap on horizontal bars so capsule backgrounds do not clip the second line. Vertical bars are unchanged." }, + "double_line_height_budget": { "label": "Double-line height budget", "description": "Approximate total text height available on a horizontal bar. Lower values make both lines more compact." }, + "show_translation": { "label": "Show translation", "description": "Allow translated lyrics to appear on the secondary line." }, + "show_romanization": { "label": "Show romanization", "description": "Allow romanized or transliterated lyrics to appear on the secondary line." }, + "secondary_line_mode": { + "label": "Secondary line content", "description": "Choose which additional lyric layer is preferred.", + "options": { "translation_first": "Translation, then romanization", "romanization_first": "Romanization, then translation", "translation": "Translation only", "romanization": "Romanization only" } + }, + "karaoke_enabled": { "label": "Per-character karaoke", "description": "Use source character timings or simulated line progress for per-character highlighting." }, "scroll_mode": { - "description": "How lyrics scroll in the bar.", "label": "Scroll mode", + "description": "How lyrics scroll in the bar.", "options": { "auto": "Auto (synced)", "marquee": "Marquee", "static": "Static" } }, - "show_artist": { - "label": "Show artist" + "marquee_speed": { + "label": "Marquee speed", + "description": "Pixels per second for long-line scrolling." }, - "show_cover": { - "description": "Display the song's album art next to the lyrics.", - "label": "Show album cover" - } - }, - "title": "Lyrics" + "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", + "typewriter": "Typewriter reveal", + "pulse": "Pulse fade", + "blink": "Blink reveal", + "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." + }, + "font_family": { "label": "Lyrics font family", "description": "Installed font family for all lyrics. Leave empty to follow Noctalia." }, + "primary_font_size": { "label": "Primary lyric font size", "description": "Font size for the original lyric line. Zero follows Noctalia." }, + "font_weight": { + "label": "Lyrics font weight", "description": "Weight used for primary lyrics.", + "options": { "thin": "Thin", "light": "Light", "normal": "Normal", "medium": "Medium", "semibold": "Semibold", "bold": "Bold", "heavy": "Heavy" } + }, + "secondary_font_size": { "label": "Secondary font size", "description": "Font size for translations and romanization." }, + "font_style": { + "label": "Font layout style", "description": "Text baseline style supported by the Noctalia renderer.", + "options": { "normal": "Normal", "fixed": "Fixed text height", "ink_centered": "Ink centered" } + }, + "line_gap": { "label": "Line gap", "description": "Spacing between original, translation, romanization, and context lines." }, + "padding_left": { "label": "Left padding", "description": "Empty space before the widget content." }, + "padding_right": { "label": "Right padding", "description": "Empty space after the widget content." }, + "cover_shape": { + "label": "Cover shape", "description": "Shape of the album artwork.", + "options": { "circle": "Circle", "rounded": "Rounded square", "square": "Square", "custom": "Custom radius" } + }, + "cover_radius": { "label": "Cover corner radius", "description": "Corner radius used by the custom cover shape." }, + "cover_size": { "label": "Cover size", "description": "Album artwork width and height in logical pixels." }, + "secondary_color": { "label": "Secondary lyric color", "description": "Color used for translation and romanization lines." } + } }