feat(lyrics): expand sources and display controls

This commit is contained in:
h465855hgg
2026-07-18 18:29:33 +08:00
parent 1e23c02e0b
commit fc3d3447d0
10 changed files with 1682 additions and 230 deletions
+142 -84
View File
@@ -1,7 +1,8 @@
# Lyrics # Noctalia Lyrics 1.4.0
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
highlighting, animated line changes, and configurable online or local sources. translation and romanization layers, configurable sources, karaoke highlighting,
album artwork, and layout controls.
## Plugin ## Plugin
@@ -12,80 +13,153 @@ highlighting, animated line changes, and configurable online or local sources.
## Requirements ## Requirements
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media Install these commands on `PATH`:
player must expose MPRIS metadata for automatic track and playback detection.
Noctalia installs the plugin files; it does not install system packages for you. - `playerctl`: read and control MPRIS players.
To check or install the runtime packages automatically, run: - `python3`: run the unified lyric-source adapter and dynamic lyric parser.
- `cp`: preserve local MPRIS artwork in the plugin cache.
```sh - `chmod`: restrict temporary credential request files to the current user.
sh scripts/setup-deps.sh --check
sh scripts/setup-deps.sh
```
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
`pacman`, `zypper`, `apk`, and `xbps-install`.
## Usage ## Usage
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar
settings. The background `service` detects the active MPRIS player, resolves widget. Left-click switches between lyrics and track information when
lyrics, downloads or caches album artwork, and publishes playback state to the `display_mode` is `toggle`; right-click pauses or resumes the selected player.
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 `•••••`.
## Screenshots ## Screenshots
Status-bar widget: Double-line widget with translated or romanized lyrics:
![Lyrics bar widget](screenshots/widget.webp) ![Lyrics 1.4 widget](<screenshots/widget - 1.4.0.png>)
Plugin settings: Plugin-wide settings:
![Lyrics settings](screenshots/settings.webp) ![Lyrics 1.4 settings](<screenshots/settings - 1.4.0.png>)
Per-widget bar settings:
![Lyrics 1.4 bar widget settings](<screenshots/bar widget settings - 1.4.0.png>)
## 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 ## Settings
| Setting | Type | Default | Description | The plugin settings control source selection and fallback order, translation
| --- | --- | --- | --- | language, lyric timing offset in milliseconds, MPRIS polling interval in
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. | milliseconds, double-line translation and romanization, karaoke highlighting,
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. | marquee behavior, transitions, fonts, spacing, and side padding.
| `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. |
## 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 ## Feature test checklist
service with:
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 ```sh
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>' noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
@@ -93,26 +167,10 @@ noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
Supported events: Supported events:
- `push-lrc`: accepts synchronized or plain LRC text. - `push-lrc`: synchronized or plain LRC text.
- `push-json`: accepts JSON with a `lines` timed array or a `lyrics` LRC string. - `push-json`: JSON with `lines` or `lyrics`.
- `push-state`: also accepts `track`, `position`, `playing`, and `cover` fields. - `push-state`: also updates track, position, playing, and cover.
- `clear`: clears the currently published lyrics. - `clear`: clears current lyrics.
Line timestamps and character timestamps are milliseconds. MPRIS track duration MPRIS track duration and playback position are microseconds. Lyric line,
and playback position are microseconds: duration, and character timestamps are milliseconds.
```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.
+663
View File
@@ -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 ("<tt" in stripped[:300] or "<p" in stripped[:300]):
return parse_ttml(stripped)
if "[" in stripped and (TIME_TAG.search(stripped) or KRC_LINE.search(stripped)):
return parse_lrc(stripped)
return parse_plain(stripped)
if isinstance(value, dict):
direct = first_value(value, ("lines", "lyricLines", "lyricsLines", "sentences"))
if direct is not None and direct is not value:
parsed = parse_json_lines(direct)
if parsed:
return parsed
lyric = first_value(value, ("syncedLyrics", "synced_lyrics", "subtitle_body", "ttml", "lyric", "lyrics", "lrc", "content"))
if lyric is not None and lyric is not value:
return parse_json_lines(lyric)
return []
if not isinstance(value, list):
return []
result = []
for item in value:
if isinstance(item, str):
result.append(line(-1, text=item))
continue
if not isinstance(item, dict):
continue
start = first_value(item, ("time", "start", "startTime", "startTimeMs", "start_time", "begin", "timestamp"))
duration = first_value(item, ("duration", "durationMs", "duration_ms"))
end = first_value(item, ("end", "endTime", "endTimeMs", "end_time"))
text = first_value(item, ("text", "words", "lyric", "content", "line"))
translation = first_value(item, ("translation", "translated", "translatedLyric"))
romanization = first_value(item, ("romanization", "romanized", "romaji", "transliteration"))
if isinstance(start, str) and (":" in start or start.endswith(("s", "ms"))):
start = parse_time_expression(start)
start = number(start, -1)
duration = number(duration, 0)
if not duration and end is not None:
if isinstance(end, str) and (":" in end or end.endswith(("s", "ms"))):
end = parse_time_expression(end)
duration = max(0, number(end) - start)
chars = first_value(item, ("chars", "charTimes", "syllables", "wordsTiming")) or []
char_times = []
if isinstance(chars, list):
for char in chars:
if isinstance(char, dict):
char_times.append(number(first_value(char, ("time", "start", "startTimeMs"))))
elif isinstance(char, (int, float, str)):
char_times.append(number(char))
result.append(line(start, duration, text, translation, romanization, char_times))
return finalize(result)
def parse_payload(payload):
if isinstance(payload, bytes):
payload = payload.decode("utf-8", "replace")
if isinstance(payload, str):
stripped = payload.strip().lstrip("\ufeff")
if stripped.startswith("<"):
parsed = parse_ttml(stripped)
if parsed:
return parsed
try:
payload = json.loads(stripped)
except (ValueError, TypeError):
return parse_lrc(stripped) or parse_plain(stripped)
return parse_json_lines(payload)
def request_data(url, headers=None, data=None, method=None, timeout=15):
safe_headers = {"User-Agent": USER_AGENT, "Accept": "application/json, text/plain, application/xml, text/xml"}
safe_headers.update(headers or {})
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode("utf-8")
request = urllib.request.Request(url, data=body, headers=safe_headers, method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read(), response.headers.get_content_charset() or "utf-8"
def request_json(url, headers=None, data=None, method=None):
body, charset = request_data(url, headers, data, method)
text = body.decode(charset, "replace").strip()
if text.startswith("callback(") and text.endswith(")"):
text = text[9:-1]
return json.loads(text)
def query_url(base, params):
return base + ("&" if "?" in base else "?") + urllib.parse.urlencode(params)
def best_match(items, track, title_key, artist_key, album_key=None):
if not items:
return None
wanted_title, wanted_artist, wanted_album = map(normalize, (
track.get("title"), track.get("artist"), track.get("album")
))
best, best_score = None, -1
for item in items:
title = normalize(title_key(item))
artist = normalize(artist_key(item))
album = normalize(album_key(item)) if album_key else ""
score = 0
if wanted_title and title:
title_score = 6 if title == wanted_title else 3 if wanted_title in title or title in wanted_title else 0
if title_score == 0:
continue
score += title_score
if wanted_artist and artist:
score += 4 if artist == wanted_artist else 2 if wanted_artist in artist or artist in wanted_artist else 0
if wanted_album and album:
score += 2 if album == wanted_album else 1 if wanted_album in album or album in wanted_album else 0
if score > 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()
+178 -20
View File
@@ -8,8 +8,12 @@ local showArtist = noctalia.getConfig("show_artist")
local hideWhenPaused = noctalia.getConfig("hide_when_paused") local hideWhenPaused = noctalia.getConfig("hide_when_paused")
local showCover = noctalia.getConfig("show_cover") local showCover = noctalia.getConfig("show_cover")
if showCover == nil then showCover = true end 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 activeColor = noctalia.getConfig("active_color") or "on_surface"
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant" 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. -- These are plugin settings, not per-widget settings.
local scrollMode = noctalia.getConfig("scroll_mode") or "auto" 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 cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif" local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
if cueFontFamily == "" then cueFontFamily = "sans-serif" end 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 track = nil
local lyrics = nil local lyrics = nil
local cover = nil local cover = nil
local playing = false local playing = false
local showMode = "auto" local showMode = displayMode == "track" and "track" or "auto"
local rendered = false local rendered = false
local playerInstance = "" local playerInstance = ""
local sourceUsed = ""
local baselinePosition = 0 local baselinePosition = 0
local baselineClock = os.clock() local baselineClock = os.clock()
@@ -80,6 +105,23 @@ local function toChars(text)
return chars return chars
end 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 function getProgressMs()
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0 local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
return baselinePosition / 1000 + elapsed return baselinePosition / 1000 + elapsed
@@ -176,11 +218,20 @@ local function getFallbackLabel()
end end
local function shouldMarquee(text) local function shouldMarquee(text)
return scrollMode ~= "static" and #toChars(text) > maxChars return scrollMode ~= "static" and textUnits(text) > maxChars
end end
local function getMarqueeOffset(length) local function getMarqueeOffset(chars)
local distance = math.max(0, length - maxChars) 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 if distance == 0 then return 0 end
-- The setting is pixels per second; convert it to character cells per second. -- 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)) return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end 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) local function buildLineRow(line, opts)
opts = opts or {} opts = opts or {}
local text = type(line) == "table" and line.text or line local text = type(line) == "table" and line.text or line
@@ -233,16 +315,25 @@ local function buildLineRow(line, opts)
local chars = toChars(text or "") local chars = toChars(text or "")
if #chars == 0 then chars = { " " } end 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 offset = math.floor(exactOffset)
local fraction = exactOffset - offset local fraction = exactOffset - offset
local first = offset + 1 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 = {} local labels = {}
for sourceIndex = first, last do for sourceIndex = first, last do
local active = playing and activeColor or inactiveColor 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 if not opts.solid and not opts.dim then
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0 local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient) 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) local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
alpha = alpha * (wave * (1 - waveProgress) + waveProgress) alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
end 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 -- Bar labels elide glyphs when constrained below their natural width, so
-- animate the viewport edges with opacity without clipping CJK characters. -- animate the viewport edges with opacity without clipping CJK characters.
if opts.marquee and fraction > 0 then if opts.marquee and fraction > 0 then
if sourceIndex == first then if sourceIndex == first then
alpha = alpha * (1 - fraction) alpha = alpha * (1 - fraction)
elseif sourceIndex == first + maxChars then elseif sourceIndex == last and sourceIndex > first then
alpha = alpha * fraction alpha = alpha * fraction
end end
elseif sourceIndex == first + maxChars then
alpha = 0
end end
labels[#labels + 1] = ui.label({ labels[#labels + 1] = ui.label({
@@ -282,12 +380,17 @@ local function buildLineRow(line, opts)
color = color, color = color,
opacity = alpha, opacity = alpha,
maxLines = 1, 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 end
if opts.marquee then 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 }) labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
end end
end end
@@ -305,6 +408,9 @@ local function currentTransitionLine(info)
if animation == "none" or transitionElapsed >= FADE_OUT_MS then if animation == "none" or transitionElapsed >= FADE_OUT_MS then
local opacity = animation == "none" and 1 local opacity = animation == "none" and 1
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS) 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 return info and info.line or nil, opacity
end end
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS) return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
@@ -323,7 +429,11 @@ local function render()
local prefix = {} local prefix = {}
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
if coverPath then 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 else
prefix[#prefix + 1] = ui.glyph({ prefix[#prefix + 1] = ui.glyph({
name = glyph, name = glyph,
@@ -333,7 +443,9 @@ local function render()
end end
local lineNodes = {} local lineNodes = {}
if showMode == "track" then local renderLineGap = lineGap
local renderTextHeight = 0
if displayMode == "track" or showMode == "track" then
local label = getTrackLabel() local label = getTrackLabel()
lineNodes[1] = buildLineRow(label, { lineNodes[1] = buildLineRow(label, {
key = "track", key = "track",
@@ -345,17 +457,38 @@ local function render()
if info then if info then
local shownLine, opacity = currentTransitionLine(info) local shownLine, opacity = currentTransitionLine(info)
if shownLine then 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, { lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
key = "current-" .. tostring(info.index), key = "current-" .. tostring(info.index),
marquee = shownLine == info.line and shouldMarquee(shownLine.text), marquee = shownLine == info.line and shouldMarquee(shownLine.text),
position = position, position = position,
useGradient = shownLine == info.line and info.synced and useGradient, useGradient = karaokeEnabled and shownLine == info.line and info.synced and useGradient,
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"), solid = not karaokeEnabled or not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
cascade = shownLine == info.line and animation == "cascade", cascade = shownLine == info.line and animation == "cascade",
wave = shownLine == info.line and animation == "wave", wave = shownLine == info.line and animation == "wave",
typewriter = shownLine == info.line and animation == "typewriter",
blink = shownLine == info.line and animation == "blink",
opacity = opacity, opacity = opacity,
cue = shownLine == info.line and info.cue == true, 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 end
if vertical and maxLines > 1 and shownLine == info.line then if vertical and maxLines > 1 and shownLine == info.line then
@@ -382,20 +515,41 @@ local function render()
if vertical then if vertical then
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, { barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
ui.row({ gap = 6, align = "center" }, prefix), 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 else
local children = {} 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 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)) barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
end 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 rendered = true
end end
function onClick() function onClick()
if displayMode ~= "toggle" then return end
showMode = showMode == "auto" and "track" or "auto" showMode = showMode == "auto" and "track" or "auto"
marqueeElapsed = 0 marqueeElapsed = 0
if rendered then render() end if rendered then render() end
@@ -425,6 +579,7 @@ function update()
cover = noctalia.state.get("cover") cover = noctalia.state.get("cover")
playing = noctalia.state.get("playing") == true playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or "" playerInstance = noctalia.state.get("player_instance") or ""
sourceUsed = noctalia.state.get("lyrics_source_used") or ""
local info = getLineInfo() local info = getLineInfo()
local nextKey = info and info.key or "fallback" local nextKey = info and info.key or "fallback"
@@ -443,7 +598,10 @@ function update()
if playing then if playing then
transitionElapsed = transitionElapsed + delta * 1000 transitionElapsed = transitionElapsed + delta * 1000
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "") 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 end
render() render()
+227 -45
View File
@@ -3,23 +3,38 @@
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music -- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay. -- via /api endpoints, publishes state. No polling delay.
noctalia.setUpdateInterval(250) local updateIntervalMs = 100
noctalia.setUpdateInterval(updateIntervalMs)
local cache = {} local cache = {}
local coverCache = {} local coverCache = {}
local lastTrackKey = "" local lastTrackKey = ""
local inFlight = nil local inFlight = nil
local fetchGeneration = 0
local pollInFlight = false
local coverInFlight = nil local coverInFlight = nil
local pluginDir = noctalia.pluginDir() or "/tmp" local pluginDir = noctalia.pluginDir() or "/tmp"
local cacheDir = pluginDir .. "/.cache" local cacheDir = pluginDir .. "/.cache"
noctalia.mkdirAll(cacheDir) noctalia.mkdirAll(cacheDir)
local krcTmp = cacheDir .. "/krc.tmp" local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto" 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 customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" 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 currentTrack = nil
local currentEmbeddedLyrics = "" local currentEmbeddedLyrics = ""
local currentPlayerInstance = "" 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) local function normalizePatterns(value)
if type(value) ~= "table" then return {} end if type(value) ~= "table" then return {} end
@@ -72,9 +87,11 @@ local function selectPlayer(players)
end end
local function clearPlayerState() local function clearPlayerState()
fetchGeneration = fetchGeneration + 1
currentPlayerInstance = "" currentPlayerInstance = ""
currentTrack = nil currentTrack = nil
currentEmbeddedLyrics = "" currentEmbeddedLyrics = ""
currentArtUrl = ""
lastTrackKey = "" lastTrackKey = ""
inFlight = nil inFlight = nil
coverInFlight = nil coverInFlight = nil
@@ -86,9 +103,14 @@ local function clearPlayerState()
noctalia.state.set("playing", false) noctalia.state.set("playing", false)
end end
local function coverPathFor(track) local function stableHash(value)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_") local hash = 5381
return cacheDir .. "/cover_" .. safe .. ".jpg" 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 end
local function shellQuote(value) local function shellQuote(value)
@@ -134,6 +156,46 @@ local function parsePlain(text)
return lines return lines
end 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 function evictCache()
local keys = {} local keys = {}
for k, _ in pairs(cache) do keys[#keys + 1] = k end for k, _ in pairs(cache) do keys[#keys + 1] = k end
@@ -145,14 +207,15 @@ end
local function fetchLyricsNetEase(track, embeddedLyrics) local function fetchLyricsNetEase(track, embeddedLyrics)
local tk = trackKey(track) local tk = trackKey(track)
if inFlight == tk then return end fetchGeneration = fetchGeneration + 1
inFlight = tk local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight
local function tryFetch(query, fallback) local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5" 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) 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 tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then if 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" 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) 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 if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil local lyrics = nil
@@ -218,7 +281,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
if klyricStr ~= "" then if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"' local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
noctalia.runAsync(py, function(r3) 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 if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "") local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then 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 function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"' local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
noctalia.runAsync(py, function(r) 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 if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "") local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil) cb(ok and parsed or nil)
@@ -314,10 +377,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
return true return true
end end
local function fetchCustom() local function fetchCustom(fallback)
if customUrl == "" then if customUrl == "" then
inFlight = nil if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end
noctalia.state.set("lyrics", nil)
return return
end end
@@ -332,6 +394,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end) end)
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response) 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 if tk ~= lastTrackKey then inFlight = nil; return end
local text = response.ok and response.body or "" local text = response.ok and response.body or ""
if text ~= "" and customJsonField ~= "" then if text ~= "" and customJsonField ~= "" then
@@ -343,13 +406,21 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
cache[tk] = decoded cache[tk] = decoded
evictCache() evictCache()
noctalia.state.set("lyrics", decoded) noctalia.state.set("lyrics", decoded)
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil inFlight = nil
return return
end end
text = type(decoded) == "string" and decoded or "" text = type(decoded) == "string" and decoded or ""
end end
applyText(text) if applyText(text) then
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil inFlight = nil
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end) end)
end end
@@ -357,48 +428,98 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
inFlight = nil inFlight = nil
return return
elseif lyricsSource == "mpris" then elseif lyricsSource == "mpris" then
applyText(embeddedLyrics) if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil inFlight = nil
return return
elseif lyricsSource == "custom" then elseif lyricsSource == "custom" then
fetchCustom() fetchCustom(nil)
return
elseif lyricsSource == "netease" then
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
return return
end end
-- Auto: LRCLIB, then the public NetEase API. local requestPath = cacheDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
runPy("lrclib_lyric.py", function(parsed) local sources = normalizedSources()
if applyParsed(parsed) then inFlight = nil; return end local request = {
if lyricsSource == "lrclib" then 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 inFlight = nil
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
return return
end end
tryFetch(track.title .. " " .. track.artist, function() if source == "mpris" then
tryFetch(track.title, nil) if applyText(embeddedLyrics) then
end) inFlight = nil
end) 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 end
local function fetchCover(track, artUrl) local function fetchCover(track, artUrl)
local tk = trackKey(track) local tk = trackKey(track)
if coverInFlight == tk then return end local ck = tk .. "|" .. (artUrl or "")
coverInFlight = tk if coverInFlight == ck then return end
coverInFlight = ck
local dest = coverPathFor(track) local dest = coverPathFor(track, artUrl)
local apply = function(path) local apply = function(path)
if coverInFlight ~= tk then return end if coverInFlight ~= ck then return end
coverInFlight = nil coverInFlight = nil
if tk ~= lastTrackKey then return end if tk ~= lastTrackKey then return end
coverCache[tk] = path coverCache[ck] = path
noctalia.state.set("cover", path) noctalia.state.set("cover", path)
end end
if noctalia.fileExists(dest) then
apply(dest)
return
end
if not artUrl or artUrl == "" then if not artUrl or artUrl == "" then
noctalia.state.set("cover", nil) noctalia.state.set("cover", nil)
coverInFlight = nil coverInFlight = nil
@@ -417,15 +538,38 @@ local function fetchCover(track, artUrl)
return return
end end
noctalia.download(artUrl, dest, function(ok) local downloadUrl = artUrl
if ok then apply(dest) else apply(nil) end 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)
end end
local function poll() 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]] 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 if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
clearPlayerState() clearPlayerState()
return return
@@ -486,8 +630,10 @@ local function poll()
local tk = trackKey(t) local tk = trackKey(t)
currentTrack = t currentTrack = t
currentEmbeddedLyrics = selected.embeddedLyrics 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 if tk ~= lastTrackKey then
lastTrackKey = tk lastTrackKey = tk
@@ -495,14 +641,19 @@ local function poll()
noctalia.state.set("playing", playing) noctalia.state.set("playing", playing)
local cached = cache[tk] 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", cached)
noctalia.state.set("lyrics_source_used", "cache")
else else
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, selected.embeddedLyrics) fetchLyricsNetEase(t, selected.embeddedLyrics)
end end
local cachedCover = coverCache[tk] local coverKey = tk .. "|" .. selected.artUrl
local cachedCover = coverCache[coverKey]
if cachedCover then if cachedCover then
noctalia.state.set("cover", cachedCover) noctalia.state.set("cover", cachedCover)
else else
@@ -512,16 +663,22 @@ local function poll()
else else
noctalia.state.set("track", t) noctalia.state.set("track", t)
noctalia.state.set("playing", playing) noctalia.state.set("playing", playing)
if selected.artUrl ~= previousArtUrl then
noctalia.state.set("cover", nil)
fetchCover(t, selected.artUrl)
end
end end
end) end)
if not started then pollInFlight = false end
end end
local pollTick = 0 local pollElapsedMs = pollIntervalMs
function update() function update()
if lyricsSource == "external" then return end if lyricsSource == "external" then return end
pollTick = pollTick + 1 pollElapsedMs = pollElapsedMs + updateIntervalMs
if pollTick % 2 == 0 then if pollElapsedMs >= pollIntervalMs then
pollElapsedMs = 0
poll() poll()
end end
@@ -531,26 +688,51 @@ function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto" local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or "" local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics" 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 nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist")) local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField 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") local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n") or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
lyricsSource = nextSource lyricsSource = nextSource
customUrl = nextUrl customUrl = nextUrl
customJsonField = nextField customJsonField = nextField
lyricsSources = nextSources
pollIntervalMs = math.max(100, nextPollInterval)
lyricsOffsetMs = nextOffset
displayMode = nextDisplayMode
credentialSignature = nextCredentialSignature
playerAllowlist = nextAllowlist playerAllowlist = nextAllowlist
playerBlocklist = nextBlocklist playerBlocklist = nextBlocklist
if playersChanged then if playersChanged then
currentPlayerInstance = "" currentPlayerInstance = ""
fetchGeneration = fetchGeneration + 1
inFlight = nil inFlight = nil
poll() poll()
end end
if sourceChanged and currentTrack and not playersChanged then if sourceChanged and currentTrack and not playersChanged then
fetchGeneration = fetchGeneration + 1
inFlight = nil inFlight = nil
cache = {} cache = {}
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end end
end end
@@ -572,7 +754,7 @@ local function applyPushedLyrics(payload)
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics) local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
noctalia.state.set("lyrics", parsed) noctalia.state.set("lyrics", parsed)
end 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.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
return true return true
+331 -2
View File
@@ -1,10 +1,10 @@
id = "h465855hgg/lyrics" id = "h465855hgg/lyrics"
name = "Lyrics" name = "Lyrics"
version = "1.3.0" version = "1.4.0"
plugin_api = 3 plugin_api = 3
author = "h465855hgg" author = "h465855hgg"
license = "MIT" license = "MIT"
dependencies = ["playerctl", "python3", "cp"] dependencies = ["playerctl", "python3", "cp", "chmod"]
tags = ["bar", "service", "music", "media", "animation"] tags = ["bar", "service", "music", "media", "animation"]
icon = "music" icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources." 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 = "auto", label_key = "settings.lyrics_source.options.auto" },
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" }, { value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
{ value = "netease", label_key = "settings.lyrics_source.options.netease" }, { 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 = "mpris", label_key = "settings.lyrics_source.options.mpris" },
{ value = "custom", label_key = "settings.lyrics_source.options.custom" }, { value = "custom", label_key = "settings.lyrics_source.options.custom" },
{ value = "external", label_key = "settings.lyrics_source.options.external" }, { 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]] [[setting]]
key = "custom_url" key = "custom_url"
type = "string" type = "string"
@@ -55,6 +156,7 @@ label_key = "settings.custom_json_field.label"
description_key = "settings.custom_json_field.description" description_key = "settings.custom_json_field.description"
default = "syncedLyrics" default = "syncedLyrics"
visible_when = { key = "lyrics_source", values = ["custom"] } visible_when = { key = "lyrics_source", values = ["custom"] }
advanced = true
[[setting]] [[setting]]
key = "cue_text" key = "cue_text"
@@ -69,6 +171,7 @@ type = "select"
label_key = "settings.cue_font_mode.label" label_key = "settings.cue_font_mode.label"
description_key = "settings.cue_font_mode.description" description_key = "settings.cue_font_mode.description"
default = "follow" default = "follow"
advanced = true
options = [ options = [
{ value = "follow", label_key = "settings.cue_font_mode.options.follow" }, { value = "follow", label_key = "settings.cue_font_mode.options.follow" },
{ value = "custom", label_key = "settings.cue_font_mode.options.custom" }, { 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" description_key = "settings.cue_font_family.description"
default = "sans-serif" default = "sans-serif"
visible_when = { key = "cue_font_mode", values = ["custom"] } 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]] [[setting]]
key = "scroll_mode" key = "scroll_mode"
@@ -102,6 +300,7 @@ default = 30
min = 10 min = 10
max = 120 max = 120
description_key = "settings.marquee_speed.description" description_key = "settings.marquee_speed.description"
advanced = true
[[setting]] [[setting]]
key = "max_lines" key = "max_lines"
@@ -111,6 +310,7 @@ default = 1
min = 1 min = 1
max = 3 max = 3
description_key = "settings.max_lines.description" description_key = "settings.max_lines.description"
advanced = true
[[setting]] [[setting]]
key = "gradient" key = "gradient"
@@ -130,6 +330,9 @@ options = [
{ value = "cascade", label_key = "settings.animation.options.cascade" }, { value = "cascade", label_key = "settings.animation.options.cascade" },
{ value = "wave", label_key = "settings.animation.options.wave" }, { value = "wave", label_key = "settings.animation.options.wave" },
{ value = "fade", label_key = "settings.animation.options.fade" }, { 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" }, { value = "none", label_key = "settings.animation.options.none" },
] ]
@@ -141,6 +344,7 @@ default = 15
min = 8 min = 8
max = 80 max = 80
description_key = "settings.max_chars.description" description_key = "settings.max_chars.description"
advanced = true
[[setting]] [[setting]]
key = "char_width" key = "char_width"
@@ -150,6 +354,92 @@ default = 9
min = 4 min = 4
max = 24 max = 24
description_key = "settings.char_width.description" 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. # Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]] [[service]]
@@ -186,6 +476,38 @@ entry = "lyrics.luau"
description_key = "settings.show_cover.description" description_key = "settings.show_cover.description"
default = true 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]] [[widget.setting]]
key = "active_color" key = "active_color"
type = "color" type = "color"
@@ -199,3 +521,10 @@ entry = "lyrics.luau"
label_key = "settings.inactive_color.label" label_key = "settings.inactive_color.label"
description_key = "settings.inactive_color.description" description_key = "settings.inactive_color.description"
default = "on_surface_variant" 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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+3 -1
View File
@@ -46,9 +46,10 @@ MISSING_COMMANDS=""
need_command playerctl need_command playerctl
need_command python3 need_command python3
need_command cp need_command cp
need_command chmod
if [ -z "$MISSING_COMMANDS" ]; then 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 exit 0
fi fi
@@ -138,6 +139,7 @@ MISSING_COMMANDS=""
need_command playerctl need_command playerctl
need_command python3 need_command python3
need_command cp need_command cp
need_command chmod
if [ -n "$MISSING_COMMANDS" ]; then if [ -n "$MISSING_COMMANDS" ]; then
echo "Still missing after installation:$MISSING_COMMANDS" >&2 echo "Still missing after installation:$MISSING_COMMANDS" >&2
+134 -74
View File
@@ -1,113 +1,173 @@
{ {
"instrumental": "Instrumental", "title": "Lyrics",
"intro": "Intro",
"no_lyrics": "♪ No lyrics available", "no_lyrics": "♪ No lyrics available",
"intro": "Intro",
"instrumental": "Instrumental",
"source_label": "Lyrics source",
"settings": { "settings": {
"active_color": { "player_allowlist": {
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.", "label": "Player allowlist",
"label": "Active lyric color" "description": "Only use matching MPRIS players. Leave empty to allow all. Supports * wildcards and matches player names or instances."
}, },
"animation": { "player_blocklist": {
"description": "How lyrics animate on line change.", "label": "Player blocklist",
"label": "Animation", "description": "Ignore matching MPRIS players after applying the allowlist. Supports * wildcards, for example chromium* or firefox*."
"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"
}, },
"glyph": { "glyph": {
"label": "Glyph" "label": "Glyph"
}, },
"gradient": { "show_artist": {
"description": "Light up each character as the song progresses (karaoke style).", "label": "Show artist"
"label": "Per-character gradient"
}, },
"hide_when_paused": { "hide_when_paused": {
"label": "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": { "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": { "lyrics_source": {
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"label": "Lyrics source", "label": "Lyrics source",
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"options": { "options": {
"auto": "Automatic fallback", "auto": "Automatic fallback",
"custom": "Custom HTTP endpoint",
"external": "External IPC push",
"lrclib": "LRCLIB", "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)", "mpris": "Local player (MPRIS)",
"netease": "NetEase Music (public API)" "custom": "Custom HTTP endpoint",
"external": "External IPC push"
} }
}, },
"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." },
"description": "Pixels per second for long-line scrolling.", "translation_language": {
"label": "Marquee speed" "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": { "spotify_sp_dc": { "label": "Spotify sp_dc", "description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs." },
"description": "Visible width before a long lyric line scrolls (marquee).", "spotify_access_token": { "label": "Spotify access token", "description": "Optional manually supplied Spotify access token. Takes priority over sp_dc." },
"label": "Max characters" "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": { "custom_json_field": {
"description": "Maximum lyric lines to show at once.", "label": "JSON lyrics field",
"label": "Max lines" "description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC."
}, },
"player_allowlist": { "cue_text": {
"description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players.", "label": "Intro/interlude characters",
"label": "Allowed players" "description": "Text or characters highlighted during intros and interludes."
}, },
"player_blocklist": { "cue_font_mode": {
"description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist.", "label": "Intro/interlude font mode",
"label": "Blocked players" "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": { "scroll_mode": {
"description": "How lyrics scroll in the bar.",
"label": "Scroll mode", "label": "Scroll mode",
"description": "How lyrics scroll in the bar.",
"options": { "options": {
"auto": "Auto (synced)", "auto": "Auto (synced)",
"marquee": "Marquee", "marquee": "Marquee",
"static": "Static" "static": "Static"
} }
}, },
"show_artist": { "marquee_speed": {
"label": "Show artist" "label": "Marquee speed",
"description": "Pixels per second for long-line scrolling."
}, },
"show_cover": { "max_lines": {
"description": "Display the song's album art next to the lyrics.", "label": "Max lines",
"label": "Show album cover" "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"
} }
}, },
"title": "Lyrics" "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." }
}
} }