Merge pull request #45 from h465855hgg/update-lyrics-1.4.0

feat(lyrics): update to 1.4.0 with multi-source and double-line lyrics
This commit is contained in:
Lysec
2026-07-18 15:29:24 +02:00
committed by GitHub
10 changed files with 1718 additions and 238 deletions
+144 -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
highlighting, animated line changes, and configurable online or local sources.
Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
translation and romanization layers, configurable sources, karaoke highlighting,
album artwork, and layout controls.
## Plugin
@@ -12,80 +13,155 @@ highlighting, animated line changes, and configurable online or local sources.
## Requirements
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media
player must expose MPRIS metadata for automatic track and playback detection.
Install these commands on `PATH`:
Noctalia installs the plugin files; it does not install system packages for you.
To check or install the runtime packages automatically, run:
```sh
sh scripts/setup-deps.sh --check
sh scripts/setup-deps.sh
```
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
`pacman`, `zypper`, `apk`, and `xbps-install`.
- `playerctl`: read and control MPRIS players.
- `python3`: run the unified lyric-source adapter and dynamic lyric parser.
- `cp`: preserve local MPRIS artwork in the plugin cache.
- `chmod`: secures the temporary request directory before credentials are
written.
## Usage
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
settings. The background `service` detects the active MPRIS player, resolves
lyrics, downloads or caches album artwork, and publishes playback state to the
widget.
When several players are available, the service prefers a playing player, then
a paused player, and keeps the current player when priorities are equal. The
optional allowlist and blocklist match player names or instances and support
`*` wildcards.
Left-click the widget to switch between lyrics and track information. Right-click
to pause or resume the active player. Paused content is dimmed and all lyric,
transition, and marquee animation stops until playback resumes.
When synchronized lyrics are unavailable, the widget displays
`track title + artist`. Long lines pause at each end while scrolling. Intro and
instrumental gaps can show a configurable cue such as `•••••`.
Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar
widget. Left-click switches between lyrics and track information when
`display_mode` is `toggle`; right-click pauses or resumes the selected player.
## Screenshots
Status-bar widget:
Double-line widget with translated or romanized lyrics:
![Lyrics bar widget](screenshots/widget.webp)
![Lyrics 1.4 widget](<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. The service applies mode `0700` to the
request directory before writing any credential-bearing file.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. |
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. |
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. |
| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. |
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
| `max_chars` | `int` | `15` | Number of visible Unicode characters before marquee scrolling starts. |
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
| `active_color` | `color` | `on_surface` | Colors the current and already-sung lyric characters. |
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
The plugin settings control source selection and fallback order, translation
language, lyric timing offset in milliseconds, MPRIS polling interval in
milliseconds, double-line translation and romanization, karaoke highlighting,
marquee behavior, transitions, fonts, spacing, and side padding.
## IPC
Per-widget settings control the fallback glyph, artist visibility, paused-state
visibility, album-cover shape and size, and primary, inactive, and secondary
lyric colors. Credential and source-specific fields are shown only when their
matching source is selected; source order, credentials, polling, marquee
metrics, player filters, and fine layout controls are under Advanced settings.
External players can set `lyrics_source` to `external` and address the singleton
service with:
## Feature test checklist
The normal settings view contains everyday source, lyric-layer, display, font,
and animation controls. Open Advanced settings for credentials, source order,
polling, marquee metrics, player filters, and fine padding.
1. Language: change Noctalia's global language and reload the config. Plugin
settings and runtime text follow the host language. Noctalia currently
supports English and Simplified Chinese from the originally requested locale
set; Japanese, Korean, and Traditional Chinese are not host language options.
Close and reopen Settings, then disable and enable the plugin if the runtime
tooltip still uses the previous language.
2. Chinese translation: use NetEase/QQ/Musixmatch with `show_translation=true`
and `translation_language=zh-Hans`.
3. Romanization: enable `show_romanization` and select a NetEase/QQ track that
returns romanized lyrics.
4. Sources: select each `lyrics_source` directly, then test custom fallback order
through `lyrics_sources`.
5. Delay: set `lyrics_offset_ms` to `1000` and `-1000`; positive values should
show the next lyric one second earlier.
6. Polling: test `poll_interval_ms` at `100`, `500`, and `2000`.
7. Cover: test circle, rounded, square, and custom radius; change `cover_size`.
8. Double line: enable `double_line` and switch `secondary_line_mode`.
On horizontal bars keep `double_line_auto_fit` enabled and lower
`double_line_height_budget` if the bar clips the second line.
9. Karaoke: toggle `karaoke_enabled`; line transitions must continue when off.
10. Hide layers: independently disable `show_translation` and
`show_romanization`.
11. Track-only: select `display_mode=track`; no lyric source request should run.
12. Font and double-line sizing: test `font_family`, `primary_font_size`,
`secondary_font_size`, `line_gap`, `font_weight`, and `font_style`.
13. Animations: test karaoke, cascade, wave, fade, typewriter, pulse, blink, and
none.
14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and
vertical bars.
## External protocol
Address the singleton service with:
```sh
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
@@ -93,26 +169,10 @@ noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
Supported events:
- `push-lrc`: accepts synchronized or plain LRC text.
- `push-json`: accepts JSON with a `lines` timed array or a `lyrics` LRC string.
- `push-state`: also accepts `track`, `position`, `playing`, and `cover` fields.
- `clear`: clears the currently published lyrics.
- `push-lrc`: synchronized or plain LRC text.
- `push-json`: JSON with `lines` or `lyrics`.
- `push-state`: also updates track, position, playing, and cover.
- `clear`: clears current lyrics.
Line timestamps and character timestamps are milliseconds. MPRIS track duration
and playback position are microseconds:
```json
{"lines":[{"time":1200,"duration":1800,"text":"Hello","chars":[1200,1500,1800,2100,2400]}]}
```
## Notes
Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
HTTP mode contacts only the configured endpoint. The plugin never reads browser
cookies or player credentials.
The service runs `playerctl` to select, read, and control MPRIS playback, `python3` for the
LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover
files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and
downloaded cover images are written inside the plugin runtime directory. Remote
code is never downloaded or executed.
MPRIS track duration and playback position are microseconds. Lyric line,
duration, and character timestamps are milliseconds.
+666
View File
@@ -0,0 +1,666 @@
#!/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 item.get("duration_inferred") is True:
normalized["duration_inferred"] = True
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"])
item["duration_inferred"] = True
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()
+192 -26
View File
@@ -8,8 +8,12 @@ local showArtist = noctalia.getConfig("show_artist")
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
local showCover = noctalia.getConfig("show_cover")
if showCover == nil then showCover = true end
local coverShape = noctalia.getConfig("cover_shape") or "circle"
local coverRadius = tonumber(noctalia.getConfig("cover_radius")) or 5
local coverSize = tonumber(noctalia.getConfig("cover_size")) or 18
local activeColor = noctalia.getConfig("active_color") or "on_surface"
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
local secondaryColor = noctalia.getConfig("secondary_color") or "on_surface_variant"
-- These are plugin settings, not per-widget settings.
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
@@ -25,14 +29,35 @@ if cueText == "" then cueText = "•••••" end
local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
if cueFontFamily == "" then cueFontFamily = "sans-serif" end
local displayMode = noctalia.getConfig("display_mode") or "toggle"
local doubleLine = noctalia.getConfig("double_line")
if doubleLine == nil then doubleLine = true end
local doubleLineAutoFit = noctalia.getConfig("double_line_auto_fit")
if doubleLineAutoFit == nil then doubleLineAutoFit = true end
local doubleLineHeightBudget = tonumber(noctalia.getConfig("double_line_height_budget")) or 26
local showTranslation = noctalia.getConfig("show_translation")
if showTranslation == nil then showTranslation = true end
local showRomanization = noctalia.getConfig("show_romanization") == true
local secondaryLineMode = noctalia.getConfig("secondary_line_mode") or "translation_first"
local karaokeEnabled = noctalia.getConfig("karaoke_enabled")
if karaokeEnabled == nil then karaokeEnabled = true end
local fontFamily = noctalia.getConfig("font_family") or ""
local primaryFontSize = tonumber(noctalia.getConfig("primary_font_size")) or 0
local secondaryFontSize = tonumber(noctalia.getConfig("secondary_font_size")) or 10
local fontWeight = noctalia.getConfig("font_weight") or "normal"
local fontStyle = noctalia.getConfig("font_style") or "normal"
local lineGap = tonumber(noctalia.getConfig("line_gap")) or 2
local paddingLeft = tonumber(noctalia.getConfig("padding_left")) or 0
local paddingRight = tonumber(noctalia.getConfig("padding_right")) or 0
local track = nil
local lyrics = nil
local cover = nil
local playing = false
local showMode = "auto"
local showMode = displayMode == "track" and "track" or "auto"
local rendered = false
local playerInstance = ""
local sourceUsed = ""
local baselinePosition = 0
local baselineClock = os.clock()
@@ -50,7 +75,8 @@ local MARQUEE_HOLD = 1.1
local BLEND_MS = 350
local INTERLUDE_GAP_MS = 7000
local INTRO_MIN_MS = 6000
local INTERLUDE_MIN_MS = 8000
local INTERLUDE_MIN_MS = 5000
local INTERLUDE_LEAD_MS = 1000
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
@@ -80,6 +106,23 @@ local function toChars(text)
return chars
end
local function charUnits(char)
if char == " " or char == "\t" then return 0.35 end
local byte = char:byte(1) or 0
if byte < 0x80 then
if char:match("[%.,:;!'|ilI%-%(%)]") then return 0.38 end
if char:match("[MW@#%%&]") then return 0.85 end
return 0.58
end
return 1
end
local function textUnits(text)
local units = 0
for _, char in ipairs(toChars(text or "")) do units = units + charUnits(char) end
return units
end
local function getProgressMs()
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
return baselinePosition / 1000 + elapsed
@@ -124,22 +167,28 @@ local function getLineInfo()
or (startTime + 4000)
local lineEnd = nextTime
if line.duration and line.duration > 0 then
if line.duration and line.duration > 0 and line.duration_inferred ~= true then
lineEnd = math.min(nextTime, startTime + line.duration)
elseif nextTime - startTime >= INTERLUDE_GAP_MS then
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap.
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
lineEnd = math.min(nextTime, startTime + estimatedDuration)
local lastCharTime = line.chars and tonumber(line.chars[#line.chars]) or nil
if lastCharTime and lastCharTime >= startTime and lastCharTime < nextTime then
lineEnd = math.min(nextTime, lastCharTime + 600)
else
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
lineEnd = math.min(nextTime, startTime + estimatedDuration)
end
end
-- Only show an interlude between two real lyric lines. The final lyric stays
-- visible through the outro instead of turning the whole song ending into dots.
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then
local interludeStart = math.max(startTime, lineEnd - INTERLUDE_LEAD_MS)
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= interludeStart then
return {
key = "interlude-" .. tostring(index),
index = index,
line = { text = cueText, cue = true },
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
progress = clamp((position - interludeStart) / (nextTime - interludeStart), 0, 1),
synced = true,
position = position,
cue = true,
@@ -176,11 +225,20 @@ local function getFallbackLabel()
end
local function shouldMarquee(text)
return scrollMode ~= "static" and #toChars(text) > maxChars
return scrollMode ~= "static" and textUnits(text) > maxChars
end
local function getMarqueeOffset(length)
local distance = math.max(0, length - maxChars)
local function getMarqueeOffset(chars)
local trailingChars = 0
local units = 0
for index = #chars, 1, -1 do
local char = chars[index]
local nextUnits = units + charUnits(char)
if nextUnits > maxChars then break end
units = nextUnits
trailingChars = trailingChars + 1
end
local distance = math.max(0, #chars - math.max(1, trailingChars))
if distance == 0 then return 0 end
-- The setting is pixels per second; convert it to character cells per second.
@@ -225,6 +283,38 @@ local function karaokeColor(charTime, charProgress, position, useGradient)
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end
local function labelBaseline()
if fontStyle == "fixed" then return "textFixedHeight" end
if fontStyle == "ink_centered" then return "inkCentered" end
return "text"
end
local function secondaryText(line)
if not doubleLine or type(line) ~= "table" then return "" end
local translation = showTranslation and (line.translation or "") or ""
local romanization = showRomanization and (line.romanization or "") or ""
if secondaryLineMode == "translation" then return translation end
if secondaryLineMode == "romanization" then return romanization end
if secondaryLineMode == "romanization_first" then
return romanization ~= "" and romanization or translation
end
return translation ~= "" and translation or romanization
end
local function secondaryRowEnabled(text)
return doubleLine and (showTranslation or showRomanization)
and type(text) == "string" and text:match("%S") ~= nil
end
local function compactMetrics(hasSecondary, vertical)
if not hasSecondary or vertical or not doubleLineAutoFit then return 1, lineGap end
local primaryBase = primaryFontSize > 0 and primaryFontSize or 13
local secondaryBase = secondaryFontSize > 0 and secondaryFontSize or 10
local naturalHeight = primaryBase * 1.25 + secondaryBase * 1.25 + lineGap
local scale = math.min(1, doubleLineHeightBudget / math.max(1, naturalHeight))
return scale, math.floor(lineGap * scale + 0.5)
end
local function buildLineRow(line, opts)
opts = opts or {}
local text = type(line) == "table" and line.text or line
@@ -233,16 +323,25 @@ local function buildLineRow(line, opts)
local chars = toChars(text or "")
if #chars == 0 then chars = { " " } end
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
local exactOffset = opts.marquee and getMarqueeOffset(chars) or 0
local offset = math.floor(exactOffset)
local fraction = exactOffset - offset
local first = offset + 1
local last = math.min(#chars, first + maxChars)
local last = #chars
if opts.marquee then
local units = 0
last = first - 1
for sourceIndex = first, #chars do
units = units + charUnits(chars[sourceIndex])
last = sourceIndex
if units > maxChars then break end
end
end
local labels = {}
for sourceIndex = first, last do
local active = playing and activeColor or inactiveColor
local color = opts.dim and inactiveColor or active
local color = opts.secondary and secondaryColor or (opts.dim and inactiveColor or active)
if not opts.solid and not opts.dim then
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
@@ -263,17 +362,24 @@ local function buildLineRow(line, opts)
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
end
if opts.typewriter then
local reveal = clamp((transitionElapsed - FADE_OUT_MS) / 520, 0, 1)
local threshold = (sourceIndex - first) / math.max(1, last - first + 1)
alpha = alpha * (reveal >= threshold and 1 or 0.08)
end
if opts.blink and transitionElapsed < FADE_OUT_MS + 540 then
local blink = math.floor(math.max(0, transitionElapsed - FADE_OUT_MS) / 90) % 2
alpha = alpha * (blink == 0 and 0.35 or 1)
end
-- Bar labels elide glyphs when constrained below their natural width, so
-- animate the viewport edges with opacity without clipping CJK characters.
if opts.marquee and fraction > 0 then
if sourceIndex == first then
alpha = alpha * (1 - fraction)
elseif sourceIndex == first + maxChars then
elseif sourceIndex == last and sourceIndex > first then
alpha = alpha * fraction
end
elseif sourceIndex == first + maxChars then
alpha = 0
end
labels[#labels + 1] = ui.label({
@@ -282,12 +388,17 @@ local function buildLineRow(line, opts)
color = color,
opacity = alpha,
maxLines = 1,
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil,
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or (fontFamily ~= "" and fontFamily or nil),
fontSize = opts.secondary and math.max(6, secondaryFontSize * (opts.fontScale or 1))
or ((primaryFontSize > 0 or (opts.fontScale or 1) < 1)
and math.max(6, (primaryFontSize > 0 and primaryFontSize or 13) * (opts.fontScale or 1)) or nil),
fontWeight = opts.secondary and "normal" or fontWeight,
baseline = labelBaseline(),
})
end
if opts.marquee then
while #labels < maxChars + 1 do
while #labels < math.ceil(maxChars) + 1 do
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
end
end
@@ -305,6 +416,9 @@ local function currentTransitionLine(info)
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
local opacity = animation == "none" and 1
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
if animation == "pulse" and transitionElapsed < FADE_OUT_MS + 600 then
opacity = opacity * (0.76 + 0.24 * math.sin(math.max(0, transitionElapsed - FADE_OUT_MS) / 75))
end
return info and info.line or nil, opacity
end
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
@@ -323,7 +437,11 @@ local function render()
local prefix = {}
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
if coverPath then
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
local radius = coverShape == "circle" and coverSize / 2
or coverShape == "rounded" and math.min(5, coverSize / 2)
or coverShape == "square" and 0
or math.min(coverRadius, coverSize / 2)
prefix[#prefix + 1] = ui.image({ path = coverPath, width = coverSize, height = coverSize, radius = radius, fit = "cover" })
else
prefix[#prefix + 1] = ui.glyph({
name = glyph,
@@ -333,7 +451,9 @@ local function render()
end
local lineNodes = {}
if showMode == "track" then
local renderLineGap = lineGap
local renderTextHeight = 0
if displayMode == "track" or showMode == "track" then
local label = getTrackLabel()
lineNodes[1] = buildLineRow(label, {
key = "track",
@@ -345,17 +465,38 @@ local function render()
if info then
local shownLine, opacity = currentTransitionLine(info)
if shownLine then
local secondary = secondaryText(shownLine)
local showSecondaryRow = secondaryRowEnabled(secondary) and not shownLine.cue and not info.cue
local fontScale, effectiveLineGap = compactMetrics(showSecondaryRow, vertical)
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
key = "current-" .. tostring(info.index),
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
position = position,
useGradient = shownLine == info.line and info.synced and useGradient,
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
useGradient = karaokeEnabled and shownLine == info.line and info.synced and useGradient,
solid = not karaokeEnabled or not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
cascade = shownLine == info.line and animation == "cascade",
wave = shownLine == info.line and animation == "wave",
typewriter = shownLine == info.line and animation == "typewriter",
blink = shownLine == info.line and animation == "blink",
opacity = opacity,
cue = shownLine == info.line and info.cue == true,
fontScale = fontScale,
compact = showSecondaryRow and not vertical and doubleLineAutoFit,
})
if showSecondaryRow then
lineNodes[#lineNodes + 1] = buildLineRow({ text = secondary }, {
key = "secondary-" .. tostring(info.index),
marquee = secondary ~= "" and shouldMarquee(secondary),
solid = true,
dim = true,
secondary = true,
opacity = secondary ~= "" and math.min(0.82, opacity) or 0,
fontScale = fontScale,
compact = not vertical and doubleLineAutoFit,
})
renderLineGap = effectiveLineGap
if not vertical and doubleLineAutoFit then renderTextHeight = doubleLineHeightBudget end
end
end
if vertical and maxLines > 1 and shownLine == info.line then
@@ -382,20 +523,41 @@ local function render()
if vertical then
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
ui.row({ gap = 6, align = "center" }, prefix),
ui.column({ gap = 2, align = "start" }, lineNodes),
ui.column({
gap = renderLineGap,
align = "start",
justify = renderTextHeight > 0 and "center" or nil,
height = renderTextHeight > 0 and renderTextHeight or nil,
}, lineNodes),
}))
else
local children = {}
if paddingLeft > 0 then children[#children + 1] = ui.spacer({ width = paddingLeft }) end
for _, node in ipairs(prefix) do children[#children + 1] = node end
children[#children + 1] = lineNodes[1]
children[#children + 1] = ui.column({
gap = renderLineGap,
align = "start",
justify = renderTextHeight > 0 and "center" or nil,
height = renderTextHeight > 0 and renderTextHeight or nil,
}, lineNodes)
if paddingRight > 0 then children[#children + 1] = ui.spacer({ width = paddingRight }) end
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
end
barWidget.setTooltip(getTrackLabel())
local sourceNames = {
lrclib = "LRCLIB", netease = "NetEase", qqmusic = "QQ Music", kugou = "Kugou",
qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify",
musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache",
}
local sourceLabel = noctalia.tr("source_label")
local tooltip = getTrackLabel()
if sourceUsed ~= "" then tooltip = tooltip .. "\n" .. sourceLabel .. ": " .. (sourceNames[sourceUsed] or sourceUsed) end
barWidget.setTooltip(tooltip)
rendered = true
end
function onClick()
if displayMode ~= "toggle" then return end
showMode = showMode == "auto" and "track" or "auto"
marqueeElapsed = 0
if rendered then render() end
@@ -425,6 +587,7 @@ function update()
cover = noctalia.state.get("cover")
playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or ""
sourceUsed = noctalia.state.get("lyrics_source_used") or ""
local info = getLineInfo()
local nextKey = info and info.key or "fallback"
@@ -443,7 +606,10 @@ function update()
if playing then
transitionElapsed = transitionElapsed + delta * 1000
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
local secondary = showMode ~= "track" and info and secondaryText(info.line) or ""
if shouldMarquee(text) or (secondary ~= "" and shouldMarquee(secondary)) then
marqueeElapsed = marqueeElapsed + delta
end
end
render()
+246 -49
View File
@@ -3,23 +3,40 @@
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay.
noctalia.setUpdateInterval(250)
local updateIntervalMs = 100
noctalia.setUpdateInterval(updateIntervalMs)
local cache = {}
local coverCache = {}
local lastTrackKey = ""
local inFlight = nil
local fetchGeneration = 0
local pollInFlight = false
local coverInFlight = nil
local pluginDir = noctalia.pluginDir() or "/tmp"
local cacheDir = pluginDir .. "/.cache"
noctalia.mkdirAll(cacheDir)
local requestDir = cacheDir .. "/requests"
noctalia.mkdirAll(requestDir)
local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local lyricsSources = noctalia.getConfig("lyrics_sources") or {
"lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"
}
local customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local pollIntervalMs = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
local lyricsOffsetMs = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
local displayMode = noctalia.getConfig("display_mode") or "toggle"
local credentialSignature = ""
local currentTrack = nil
local currentEmbeddedLyrics = ""
local currentPlayerInstance = ""
local currentArtUrl = ""
for _, name in ipairs(noctalia.listDir(requestDir) or {}) do
if name:match("^source_request_.*%.json$") then noctalia.removeFile(requestDir .. "/" .. name) end
end
local function normalizePatterns(value)
if type(value) ~= "table" then return {} end
@@ -72,9 +89,11 @@ local function selectPlayer(players)
end
local function clearPlayerState()
fetchGeneration = fetchGeneration + 1
currentPlayerInstance = ""
currentTrack = nil
currentEmbeddedLyrics = ""
currentArtUrl = ""
lastTrackKey = ""
inFlight = nil
coverInFlight = nil
@@ -86,9 +105,14 @@ local function clearPlayerState()
noctalia.state.set("playing", false)
end
local function coverPathFor(track)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
return cacheDir .. "/cover_" .. safe .. ".jpg"
local function stableHash(value)
local hash = 5381
for index = 1, #value do hash = (hash * 33 + value:byte(index)) % 4294967296 end
return string.format("%08x", hash)
end
local function coverPathFor(track, artUrl)
return cacheDir .. "/cover_" .. stableHash(trackKey(track) .. "|" .. (artUrl or "")) .. ".jpg"
end
local function shellQuote(value)
@@ -134,6 +158,46 @@ local function parsePlain(text)
return lines
end
local function credentialsFor(source)
if source == "spotify" then
return {
spotify_sp_dc = noctalia.getConfig("spotify_sp_dc") or "",
spotify_access_token = noctalia.getConfig("spotify_access_token") or "",
}
elseif source == "apple_music" then
return {
apple_developer_token = noctalia.getConfig("apple_developer_token") or "",
apple_user_token = noctalia.getConfig("apple_user_token") or "",
apple_storefront = noctalia.getConfig("apple_storefront") or "us",
}
elseif source == "musixmatch" then
return { musixmatch_token = noctalia.getConfig("musixmatch_token") or "" }
elseif source == "qishui" then
return {
qishui_token = noctalia.getConfig("qishui_token") or "",
qishui_api_url = noctalia.getConfig("qishui_api_url") or "",
}
end
return {}
end
local function normalizedSources()
if lyricsSource ~= "auto" then return { lyricsSource } end
local result = {}
local seen = {}
if type(lyricsSources) == "table" then
for _, source in ipairs(lyricsSources) do
source = tostring(source):lower():gsub("^%s+", ""):gsub("%s+$", "")
if source ~= "" and not seen[source] then
result[#result + 1] = source
seen[source] = true
end
end
end
if #result == 0 then result = { "lrclib", "netease" } end
return result
end
local function evictCache()
local keys = {}
for k, _ in pairs(cache) do keys[#keys + 1] = k end
@@ -145,14 +209,15 @@ end
local function fetchLyricsNetEase(track, embeddedLyrics)
local tk = trackKey(track)
if inFlight == tk then return end
inFlight = tk
fetchGeneration = fetchGeneration + 1
local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight
local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
@@ -196,7 +261,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil
@@ -216,9 +281,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
if not ok then klyricStr = "" end
end
if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
local py = "python3 " .. shellQuote(pluginDir .. "/krc_decode.py") .. " " .. shellQuote(krcTmp)
noctalia.runAsync(py, function(r3)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then
@@ -294,9 +359,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end
local function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
local py = "python3 " .. shellQuote(dir .. "/" .. script) .. " " .. shellQuote(qTmp)
noctalia.runAsync(py, function(r)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil)
@@ -314,10 +379,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
return true
end
local function fetchCustom()
local function fetchCustom(fallback)
if customUrl == "" then
inFlight = nil
noctalia.state.set("lyrics", nil)
if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
@@ -332,6 +396,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end)
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local text = response.ok and response.body or ""
if text ~= "" and customJsonField ~= "" then
@@ -339,17 +404,25 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
for part in customJsonField:gmatch("[^.]+") do
decoded = type(decoded) == "table" and decoded[part] or nil
end
if type(decoded) == "table" then
if type(decoded) == "table" then
cache[tk] = decoded
evictCache()
noctalia.state.set("lyrics", decoded)
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil
return
end
text = type(decoded) == "string" and decoded or ""
end
applyText(text)
inFlight = nil
if applyText(text) then
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
end
@@ -357,48 +430,111 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
inFlight = nil
return
elseif lyricsSource == "mpris" then
applyText(embeddedLyrics)
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom()
return
elseif lyricsSource == "netease" then
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
fetchCustom(nil)
return
end
-- Auto: LRCLIB, then the public NetEase API.
runPy("lrclib_lyric.py", function(parsed)
if applyParsed(parsed) then inFlight = nil; return end
if lyricsSource == "lrclib" then
local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
local sources = normalizedSources()
local request = {
track = track,
options = {
translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
},
}
local function trySource(index)
if inFlight ~= flight then return end
local source = sources[index]
if not source then
inFlight = nil
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
return
end
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
end)
if source == "mpris" then
if applyText(embeddedLyrics) then
inFlight = nil
noctalia.state.set("lyrics_source_used", "mpris")
else
trySource(index + 1)
end
return
end
if source == "custom" then
fetchCustom(function() trySource(index + 1) end)
return
end
request.source = source
request.credentials = credentialsFor(source)
local encoded = noctalia.json.encode(request)
if not encoded then
trySource(index + 1)
return
end
-- Secure the containing directory before any credentials are written.
local secured = noctalia.runAsync("chmod 700 " .. shellQuote(requestDir), function(chmodResult)
if inFlight ~= flight or tk ~= lastTrackKey then return end
if chmodResult.exitCode ~= 0 or not noctalia.writeFile(requestPath, encoded) then
trySource(index + 1)
return
end
local command = "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, 5000)
if not secured then
trySource(index + 1)
end
end
trySource(1)
end
local function fetchCover(track, artUrl)
local tk = trackKey(track)
if coverInFlight == tk then return end
coverInFlight = tk
local ck = tk .. "|" .. (artUrl or "")
if coverInFlight == ck then return end
coverInFlight = ck
local dest = coverPathFor(track)
local dest = coverPathFor(track, artUrl)
local apply = function(path)
if coverInFlight ~= tk then return end
if coverInFlight ~= ck then return end
coverInFlight = nil
if tk ~= lastTrackKey then return end
coverCache[tk] = path
coverCache[ck] = path
noctalia.state.set("cover", path)
end
if noctalia.fileExists(dest) then
apply(dest)
return
end
if not artUrl or artUrl == "" then
noctalia.state.set("cover", nil)
coverInFlight = nil
@@ -417,15 +553,38 @@ local function fetchCover(track, artUrl)
return
end
noctalia.download(artUrl, dest, function(ok)
if ok then apply(dest) else apply(nil) end
local downloadUrl = artUrl
local fallbackUrl = nil
if artUrl:find("music%.126%.net", 1) then
downloadUrl = artUrl:gsub("https?://p%d+%.music%.126%.net", "https://p1.music.126.net")
if downloadUrl:find("[?&]param=%d+y%d+") then
downloadUrl = downloadUrl:gsub("param=%d+y%d+", "param=400y400")
else
downloadUrl = downloadUrl .. (downloadUrl:find("?", 1, true) and "&" or "?") .. "param=400y400"
end
fallbackUrl = downloadUrl:gsub("https://p1%.music%.126%.net", "https://p3.music.126.net")
end
noctalia.download(downloadUrl, dest, function(ok)
if ok then
apply(dest)
elseif fallbackUrl then
noctalia.download(fallbackUrl, dest, function(fallbackOk)
if fallbackOk then apply(dest) else apply(nil) end
end)
else
apply(nil)
end
end)
end
local function poll()
if pollInFlight then return end
pollInFlight = true
local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
noctalia.runAsync(cmd, function(r)
local started = noctalia.runAsync(cmd, function(r)
pollInFlight = false
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
clearPlayerState()
return
@@ -486,8 +645,10 @@ local function poll()
local tk = trackKey(t)
currentTrack = t
currentEmbeddedLyrics = selected.embeddedLyrics
local previousArtUrl = currentArtUrl
currentArtUrl = selected.artUrl
noctalia.state.set("position", t.position)
noctalia.state.set("position", t.position + lyricsOffsetMs * 1000)
if tk ~= lastTrackKey then
lastTrackKey = tk
@@ -495,14 +656,19 @@ local function poll()
noctalia.state.set("playing", playing)
local cached = cache[tk]
if cached then
if displayMode == "track" then
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
elseif cached then
noctalia.state.set("lyrics", cached)
noctalia.state.set("lyrics_source_used", "cache")
else
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, selected.embeddedLyrics)
end
local cachedCover = coverCache[tk]
local coverKey = tk .. "|" .. selected.artUrl
local cachedCover = coverCache[coverKey]
if cachedCover then
noctalia.state.set("cover", cachedCover)
else
@@ -512,16 +678,22 @@ local function poll()
else
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
if selected.artUrl ~= previousArtUrl then
noctalia.state.set("cover", nil)
fetchCover(t, selected.artUrl)
end
end
end)
if not started then pollInFlight = false end
end
local pollTick = 0
local pollElapsedMs = pollIntervalMs
function update()
if lyricsSource == "external" then return end
pollTick = pollTick + 1
if pollTick % 2 == 0 then
pollElapsedMs = pollElapsedMs + updateIntervalMs
if pollElapsedMs >= pollIntervalMs then
pollElapsedMs = 0
poll()
end
@@ -531,26 +703,51 @@ function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local nextSources = noctalia.getConfig("lyrics_sources") or lyricsSources
local nextPollInterval = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
local nextOffset = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
local nextDisplayMode = noctalia.getConfig("display_mode") or "toggle"
local nextCredentialSignature = table.concat({
noctalia.getConfig("spotify_sp_dc") or "",
noctalia.getConfig("spotify_access_token") or "",
noctalia.getConfig("apple_developer_token") or "",
noctalia.getConfig("apple_user_token") or "",
noctalia.getConfig("apple_storefront") or "us",
noctalia.getConfig("musixmatch_token") or "",
noctalia.getConfig("qishui_token") or "",
noctalia.getConfig("qishui_api_url") or "",
noctalia.getConfig("translation_language") or "zh-Hans",
}, "\0")
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
or table.concat(nextSources, "\n") ~= table.concat(lyricsSources, "\n")
or nextCredentialSignature ~= credentialSignature
or nextDisplayMode ~= displayMode
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
lyricsSource = nextSource
customUrl = nextUrl
customJsonField = nextField
lyricsSources = nextSources
pollIntervalMs = math.max(100, nextPollInterval)
lyricsOffsetMs = nextOffset
displayMode = nextDisplayMode
credentialSignature = nextCredentialSignature
playerAllowlist = nextAllowlist
playerBlocklist = nextBlocklist
if playersChanged then
currentPlayerInstance = ""
fetchGeneration = fetchGeneration + 1
inFlight = nil
poll()
end
if sourceChanged and currentTrack and not playersChanged then
fetchGeneration = fetchGeneration + 1
inFlight = nil
cache = {}
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end
end
@@ -572,7 +769,7 @@ local function applyPushedLyrics(payload)
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
noctalia.state.set("lyrics", parsed)
end
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
if decoded.position ~= nil then noctalia.state.set("position", decoded.position + lyricsOffsetMs * 1000) end
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
return true
+331 -2
View File
@@ -1,10 +1,10 @@
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.3.0"
version = "1.4.0"
plugin_api = 3
author = "h465855hgg"
license = "MIT"
dependencies = ["playerctl", "python3", "cp"]
dependencies = ["playerctl", "python3", "cp", "chmod"]
tags = ["bar", "service", "music", "media", "animation"]
icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
@@ -35,11 +35,112 @@ options = [
{ value = "auto", label_key = "settings.lyrics_source.options.auto" },
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
{ value = "netease", label_key = "settings.lyrics_source.options.netease" },
{ value = "qqmusic", label_key = "settings.lyrics_source.options.qqmusic" },
{ value = "kugou", label_key = "settings.lyrics_source.options.kugou" },
{ value = "qishui", label_key = "settings.lyrics_source.options.qishui" },
{ value = "apple_music", label_key = "settings.lyrics_source.options.apple_music" },
{ value = "spotify", label_key = "settings.lyrics_source.options.spotify" },
{ value = "musixmatch", label_key = "settings.lyrics_source.options.musixmatch" },
{ value = "mpris", label_key = "settings.lyrics_source.options.mpris" },
{ value = "custom", label_key = "settings.lyrics_source.options.custom" },
{ value = "external", label_key = "settings.lyrics_source.options.external" },
]
[[setting]]
key = "lyrics_sources"
type = "string_list"
label_key = "settings.lyrics_sources.label"
description_key = "settings.lyrics_sources.description"
default = ["lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"]
visible_when = { key = "lyrics_source", values = ["auto"] }
advanced = true
[[setting]]
key = "translation_language"
type = "select"
label_key = "settings.translation_language.label"
description_key = "settings.translation_language.description"
default = "zh-Hans"
options = [
{ value = "zh-Hans", label_key = "settings.translation_language.options.zh-Hans" },
{ value = "zh-Hant", label_key = "settings.translation_language.options.zh-Hant" },
{ value = "en", label_key = "settings.translation_language.options.en" },
{ value = "ja", label_key = "settings.translation_language.options.ja" },
{ value = "ko", label_key = "settings.translation_language.options.ko" },
]
[[setting]]
key = "spotify_sp_dc"
type = "string"
label_key = "settings.spotify_sp_dc.label"
description_key = "settings.spotify_sp_dc.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["spotify"] }
[[setting]]
key = "spotify_access_token"
type = "string"
label_key = "settings.spotify_access_token.label"
description_key = "settings.spotify_access_token.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["spotify"] }
[[setting]]
key = "apple_developer_token"
type = "string"
label_key = "settings.apple_developer_token.label"
description_key = "settings.apple_developer_token.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["apple_music"] }
[[setting]]
key = "apple_user_token"
type = "string"
label_key = "settings.apple_user_token.label"
description_key = "settings.apple_user_token.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["apple_music"] }
[[setting]]
key = "apple_storefront"
type = "string"
label_key = "settings.apple_storefront.label"
description_key = "settings.apple_storefront.description"
default = "us"
advanced = true
visible_when = { key = "lyrics_source", values = ["apple_music"] }
[[setting]]
key = "musixmatch_token"
type = "string"
label_key = "settings.musixmatch_token.label"
description_key = "settings.musixmatch_token.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["musixmatch"] }
[[setting]]
key = "qishui_api_url"
type = "string"
label_key = "settings.qishui_api_url.label"
description_key = "settings.qishui_api_url.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["qishui"] }
[[setting]]
key = "qishui_token"
type = "string"
label_key = "settings.qishui_token.label"
description_key = "settings.qishui_token.description"
default = ""
advanced = true
visible_when = { key = "lyrics_source", values = ["qishui"] }
[[setting]]
key = "custom_url"
type = "string"
@@ -55,6 +156,7 @@ label_key = "settings.custom_json_field.label"
description_key = "settings.custom_json_field.description"
default = "syncedLyrics"
visible_when = { key = "lyrics_source", values = ["custom"] }
advanced = true
[[setting]]
key = "cue_text"
@@ -69,6 +171,7 @@ type = "select"
label_key = "settings.cue_font_mode.label"
description_key = "settings.cue_font_mode.description"
default = "follow"
advanced = true
options = [
{ value = "follow", label_key = "settings.cue_font_mode.options.follow" },
{ value = "custom", label_key = "settings.cue_font_mode.options.custom" },
@@ -81,6 +184,101 @@ label_key = "settings.cue_font_family.label"
description_key = "settings.cue_font_family.description"
default = "sans-serif"
visible_when = { key = "cue_font_mode", values = ["custom"] }
advanced = true
[[setting]]
key = "lyrics_offset_ms"
type = "int"
label_key = "settings.lyrics_offset_ms.label"
description_key = "settings.lyrics_offset_ms.description"
default = 0
min = -10000
max = 10000
[[setting]]
key = "poll_interval_ms"
type = "int"
label_key = "settings.poll_interval_ms.label"
description_key = "settings.poll_interval_ms.description"
default = 500
min = 100
max = 5000
advanced = true
[[setting]]
key = "display_mode"
type = "select"
label_key = "settings.display_mode.label"
description_key = "settings.display_mode.description"
default = "toggle"
options = [
{ value = "toggle", label_key = "settings.display_mode.options.toggle" },
{ value = "lyrics", label_key = "settings.display_mode.options.lyrics" },
{ value = "track", label_key = "settings.display_mode.options.track" },
]
[[setting]]
key = "double_line"
type = "bool"
label_key = "settings.double_line.label"
description_key = "settings.double_line.description"
default = true
[[setting]]
key = "double_line_auto_fit"
type = "bool"
label_key = "settings.double_line_auto_fit.label"
description_key = "settings.double_line_auto_fit.description"
default = true
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "double_line_height_budget"
type = "int"
label_key = "settings.double_line_height_budget.label"
description_key = "settings.double_line_height_budget.description"
default = 26
min = 18
max = 40
visible_when = { key = "double_line", values = ["true"] }
advanced = true
[[setting]]
key = "show_translation"
type = "bool"
label_key = "settings.show_translation.label"
description_key = "settings.show_translation.description"
default = true
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "show_romanization"
type = "bool"
label_key = "settings.show_romanization.label"
description_key = "settings.show_romanization.description"
default = false
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "secondary_line_mode"
type = "select"
label_key = "settings.secondary_line_mode.label"
description_key = "settings.secondary_line_mode.description"
default = "translation_first"
options = [
{ value = "translation_first", label_key = "settings.secondary_line_mode.options.translation_first" },
{ value = "romanization_first", label_key = "settings.secondary_line_mode.options.romanization_first" },
{ value = "translation", label_key = "settings.secondary_line_mode.options.translation" },
{ value = "romanization", label_key = "settings.secondary_line_mode.options.romanization" },
]
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "karaoke_enabled"
type = "bool"
label_key = "settings.karaoke_enabled.label"
description_key = "settings.karaoke_enabled.description"
default = true
[[setting]]
key = "scroll_mode"
@@ -102,6 +300,7 @@ default = 30
min = 10
max = 120
description_key = "settings.marquee_speed.description"
advanced = true
[[setting]]
key = "max_lines"
@@ -111,6 +310,7 @@ default = 1
min = 1
max = 3
description_key = "settings.max_lines.description"
advanced = true
[[setting]]
key = "gradient"
@@ -130,6 +330,9 @@ options = [
{ value = "cascade", label_key = "settings.animation.options.cascade" },
{ value = "wave", label_key = "settings.animation.options.wave" },
{ value = "fade", label_key = "settings.animation.options.fade" },
{ value = "typewriter", label_key = "settings.animation.options.typewriter" },
{ value = "pulse", label_key = "settings.animation.options.pulse" },
{ value = "blink", label_key = "settings.animation.options.blink" },
{ value = "none", label_key = "settings.animation.options.none" },
]
@@ -141,6 +344,7 @@ default = 15
min = 8
max = 80
description_key = "settings.max_chars.description"
advanced = true
[[setting]]
key = "char_width"
@@ -150,6 +354,92 @@ default = 9
min = 4
max = 24
description_key = "settings.char_width.description"
advanced = true
[[setting]]
key = "font_family"
type = "string"
label_key = "settings.font_family.label"
description_key = "settings.font_family.description"
default = ""
[[setting]]
key = "primary_font_size"
type = "int"
label_key = "settings.primary_font_size.label"
description_key = "settings.primary_font_size.description"
default = 0
min = 0
max = 48
[[setting]]
key = "font_weight"
type = "select"
label_key = "settings.font_weight.label"
description_key = "settings.font_weight.description"
default = "normal"
options = [
{ value = "thin", label_key = "settings.font_weight.options.thin" },
{ value = "light", label_key = "settings.font_weight.options.light" },
{ value = "normal", label_key = "settings.font_weight.options.normal" },
{ value = "medium", label_key = "settings.font_weight.options.medium" },
{ value = "semibold", label_key = "settings.font_weight.options.semibold" },
{ value = "bold", label_key = "settings.font_weight.options.bold" },
{ value = "heavy", label_key = "settings.font_weight.options.heavy" },
]
[[setting]]
key = "secondary_font_size"
type = "int"
label_key = "settings.secondary_font_size.label"
description_key = "settings.secondary_font_size.description"
default = 10
min = 6
max = 36
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "font_style"
type = "select"
label_key = "settings.font_style.label"
description_key = "settings.font_style.description"
default = "normal"
advanced = true
options = [
{ value = "normal", label_key = "settings.font_style.options.normal" },
{ value = "fixed", label_key = "settings.font_style.options.fixed" },
{ value = "ink_centered", label_key = "settings.font_style.options.ink_centered" },
]
[[setting]]
key = "line_gap"
type = "int"
label_key = "settings.line_gap.label"
description_key = "settings.line_gap.description"
default = 2
min = 0
max = 24
visible_when = { key = "double_line", values = ["true"] }
[[setting]]
key = "padding_left"
type = "int"
label_key = "settings.padding_left.label"
description_key = "settings.padding_left.description"
default = 0
min = 0
max = 64
advanced = true
[[setting]]
key = "padding_right"
type = "int"
label_key = "settings.padding_right.label"
description_key = "settings.padding_right.description"
default = 0
min = 0
max = 64
advanced = true
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]]
@@ -186,6 +476,38 @@ entry = "lyrics.luau"
description_key = "settings.show_cover.description"
default = true
[[widget.setting]]
key = "cover_shape"
type = "select"
label_key = "settings.cover_shape.label"
description_key = "settings.cover_shape.description"
default = "circle"
options = [
{ value = "circle", label_key = "settings.cover_shape.options.circle" },
{ value = "rounded", label_key = "settings.cover_shape.options.rounded" },
{ value = "square", label_key = "settings.cover_shape.options.square" },
{ value = "custom", label_key = "settings.cover_shape.options.custom" },
]
[[widget.setting]]
key = "cover_radius"
type = "int"
label_key = "settings.cover_radius.label"
description_key = "settings.cover_radius.description"
default = 5
min = 0
max = 32
visible_when = { key = "cover_shape", values = ["custom"] }
[[widget.setting]]
key = "cover_size"
type = "int"
label_key = "settings.cover_size.label"
description_key = "settings.cover_size.description"
default = 18
min = 12
max = 32
[[widget.setting]]
key = "active_color"
type = "color"
@@ -199,3 +521,10 @@ entry = "lyrics.luau"
label_key = "settings.inactive_color.label"
description_key = "settings.inactive_color.description"
default = "on_surface_variant"
[[widget.setting]]
key = "secondary_color"
type = "color"
label_key = "settings.secondary_color.label"
description_key = "settings.secondary_color.description"
default = "on_surface_variant"
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 python3
need_command cp
need_command chmod
if [ -z "$MISSING_COMMANDS" ]; then
echo "All runtime commands are installed: playerctl python3 cp"
echo "All runtime commands are installed: playerctl python3 cp chmod"
exit 0
fi
@@ -138,6 +139,7 @@ MISSING_COMMANDS=""
need_command playerctl
need_command python3
need_command cp
need_command chmod
if [ -n "$MISSING_COMMANDS" ]; then
echo "Still missing after installation:$MISSING_COMMANDS" >&2
+136 -76
View File
@@ -1,113 +1,173 @@
{
"instrumental": "Instrumental",
"intro": "Intro",
"title": "Lyrics",
"no_lyrics": "♪ No lyrics available",
"intro": "Intro",
"instrumental": "Instrumental",
"source_label": "Lyrics source",
"settings": {
"active_color": {
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
"label": "Active lyric color"
"player_allowlist": {
"label": "Player allowlist",
"description": "Only use matching MPRIS players. Leave empty to allow all. Supports * wildcards and matches player names or instances."
},
"animation": {
"description": "How lyrics animate on line change.",
"label": "Animation",
"options": {
"cascade": "Character cascade",
"fade": "Fade only",
"karaoke": "Karaoke gradient + fade",
"none": "No animation",
"wave": "Character wave reveal"
}
},
"char_width": {
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling.",
"label": "Character width (px)"
},
"cue_font_family": {
"description": "Installed font family used only for intro and interlude characters in custom-font mode.",
"label": "Intro/interlude font"
},
"cue_font_mode": {
"description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.",
"label": "Intro/interlude font mode",
"options": {
"custom": "Custom font",
"follow": "Follow interface font"
}
},
"cue_text": {
"description": "Text or characters highlighted during intros and interludes.",
"label": "Intro/interlude characters"
},
"custom_json_field": {
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC.",
"label": "JSON lyrics field"
},
"custom_url": {
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly.",
"label": "Custom endpoint URL"
"player_blocklist": {
"label": "Player blocklist",
"description": "Ignore matching MPRIS players after applying the allowlist. Supports * wildcards, for example chromium* or firefox*."
},
"glyph": {
"label": "Glyph"
},
"gradient": {
"description": "Light up each character as the song progresses (karaoke style).",
"label": "Per-character gradient"
"show_artist": {
"label": "Show artist"
},
"hide_when_paused": {
"label": "Hide when paused"
},
"show_cover": {
"label": "Show album cover",
"description": "Display the song's album art next to the lyrics."
},
"active_color": {
"label": "Active lyric color",
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color."
},
"inactive_color": {
"description": "Color used for upcoming lyrics, paused playback, and secondary lines.",
"label": "Inactive lyric color"
"label": "Inactive lyric color",
"description": "Color used for upcoming lyrics, paused playback, and secondary lines."
},
"lyrics_source": {
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"label": "Lyrics source",
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"options": {
"auto": "Automatic fallback",
"custom": "Custom HTTP endpoint",
"external": "External IPC push",
"lrclib": "LRCLIB",
"netease": "NetEase Music",
"qqmusic": "QQ Music",
"kugou": "Kugou Music",
"qishui": "Qishui Music",
"apple_music": "Apple Music",
"spotify": "Spotify",
"musixmatch": "Musixmatch",
"mpris": "Local player (MPRIS)",
"netease": "NetEase Music (public API)"
"custom": "Custom HTTP endpoint",
"external": "External IPC push"
}
},
"marquee_speed": {
"description": "Pixels per second for long-line scrolling.",
"label": "Marquee speed"
"lyrics_sources": { "label": "Automatic source order", "description": "Source IDs tried from top to bottom: lrclib, netease, qqmusic, kugou, qishui, apple_music, spotify, musixmatch, mpris, or custom." },
"translation_language": {
"label": "Translation language", "description": "Preferred translated-lyric language when supported by the source.",
"options": { "zh-Hans": "Simplified Chinese", "zh-Hant": "Traditional Chinese", "en": "English", "ja": "Japanese", "ko": "Korean" }
},
"max_chars": {
"description": "Visible width before a long lyric line scrolls (marquee).",
"label": "Max characters"
"spotify_sp_dc": { "label": "Spotify sp_dc", "description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs." },
"spotify_access_token": { "label": "Spotify access token", "description": "Optional manually supplied Spotify access token. Takes priority over sp_dc." },
"apple_developer_token": { "label": "Apple Music developer token", "description": "Developer token used for Apple Music catalog and lyrics requests." },
"apple_user_token": { "label": "Apple Music user token", "description": "Optional Music-User-Token for lyrics requiring a user subscription." },
"apple_storefront": { "label": "Apple Music storefront", "description": "Apple Music storefront code, for example us, cn, jp, or hk." },
"musixmatch_token": { "label": "Musixmatch user token", "description": "User token for the Musixmatch desktop subtitle endpoint." },
"qishui_api_url": { "label": "Qishui API URL", "description": "Endpoint template supporting {title}, {artist}, and {album}. Required because public Qishui endpoints vary." },
"qishui_token": { "label": "Qishui token", "description": "Optional Bearer token sent only to the configured Qishui endpoint." },
"custom_url": {
"label": "Custom endpoint URL",
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly."
},
"max_lines": {
"description": "Maximum lyric lines to show at once.",
"label": "Max lines"
"custom_json_field": {
"label": "JSON lyrics field",
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC."
},
"player_allowlist": {
"description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players.",
"label": "Allowed players"
"cue_text": {
"label": "Intro/interlude characters",
"description": "Text or characters highlighted during intros and interludes."
},
"player_blocklist": {
"description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist.",
"label": "Blocked players"
"cue_font_mode": {
"label": "Intro/interlude font mode",
"description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.",
"options": {
"follow": "Follow interface font",
"custom": "Custom font"
}
},
"cue_font_family": {
"label": "Intro/interlude font",
"description": "Installed font family used only for intro and interlude characters in custom-font mode."
},
"lyrics_offset_ms": { "label": "Lyrics offset (ms)", "description": "Positive values show lyrics earlier; negative values show them later." },
"poll_interval_ms": { "label": "Player polling interval (ms)", "description": "How often the service refreshes MPRIS metadata and playback position." },
"display_mode": {
"label": "Display mode", "description": "Always show lyrics, always show track information, or allow left-click switching.",
"options": { "toggle": "Click to switch", "lyrics": "Lyrics only", "track": "Track title and artist only" }
},
"double_line": { "label": "Double-line lyrics", "description": "Show translation or romanization below the current original lyric." },
"double_line_auto_fit": { "label": "Compact double lines", "description": "Reduce both font sizes and their gap on horizontal bars so capsule backgrounds do not clip the second line. Vertical bars are unchanged." },
"double_line_height_budget": { "label": "Double-line height budget", "description": "Approximate total text height available on a horizontal bar. Lower values make both lines more compact." },
"show_translation": { "label": "Show translation", "description": "Allow translated lyrics to appear on the secondary line." },
"show_romanization": { "label": "Show romanization", "description": "Allow romanized or transliterated lyrics to appear on the secondary line." },
"secondary_line_mode": {
"label": "Secondary line content", "description": "Choose which additional lyric layer is preferred.",
"options": { "translation_first": "Translation, then romanization", "romanization_first": "Romanization, then translation", "translation": "Translation only", "romanization": "Romanization only" }
},
"karaoke_enabled": { "label": "Per-character karaoke", "description": "Use source character timings or simulated line progress for per-character highlighting." },
"scroll_mode": {
"description": "How lyrics scroll in the bar.",
"label": "Scroll mode",
"description": "How lyrics scroll in the bar.",
"options": {
"auto": "Auto (synced)",
"marquee": "Marquee",
"static": "Static"
}
},
"show_artist": {
"label": "Show artist"
"marquee_speed": {
"label": "Marquee speed",
"description": "Pixels per second for long-line scrolling."
},
"show_cover": {
"description": "Display the song's album art next to the lyrics.",
"label": "Show album cover"
}
},
"title": "Lyrics"
"max_lines": {
"label": "Max lines",
"description": "Maximum lyric lines to show at once."
},
"gradient": {
"label": "Per-character gradient",
"description": "Light up each character as the song progresses (karaoke style)."
},
"animation": {
"label": "Animation",
"description": "How lyrics animate on line change.",
"options": {
"karaoke": "Karaoke gradient + fade",
"cascade": "Character cascade",
"wave": "Character wave reveal",
"fade": "Fade only",
"typewriter": "Typewriter reveal",
"pulse": "Pulse fade",
"blink": "Blink reveal",
"none": "No animation"
}
},
"max_chars": {
"label": "Max characters",
"description": "Visible width before a long lyric line scrolls (marquee)."
},
"char_width": {
"label": "Character width (px)",
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling."
},
"font_family": { "label": "Lyrics font family", "description": "Installed font family for all lyrics. Leave empty to follow Noctalia." },
"primary_font_size": { "label": "Primary lyric font size", "description": "Font size for the original lyric line. Zero follows Noctalia." },
"font_weight": {
"label": "Lyrics font weight", "description": "Weight used for primary lyrics.",
"options": { "thin": "Thin", "light": "Light", "normal": "Normal", "medium": "Medium", "semibold": "Semibold", "bold": "Bold", "heavy": "Heavy" }
},
"secondary_font_size": { "label": "Secondary font size", "description": "Font size for translations and romanization." },
"font_style": {
"label": "Font layout style", "description": "Text baseline style supported by the Noctalia renderer.",
"options": { "normal": "Normal", "fixed": "Fixed text height", "ink_centered": "Ink centered" }
},
"line_gap": { "label": "Line gap", "description": "Spacing between original, translation, romanization, and context lines." },
"padding_left": { "label": "Left padding", "description": "Empty space before the widget content." },
"padding_right": { "label": "Right padding", "description": "Empty space after the widget content." },
"cover_shape": {
"label": "Cover shape", "description": "Shape of the album artwork.",
"options": { "circle": "Circle", "rounded": "Rounded square", "square": "Square", "custom": "Custom radius" }
},
"cover_radius": { "label": "Cover corner radius", "description": "Corner radius used by the custom cover shape." },
"cover_size": { "label": "Cover size", "description": "Album artwork width and height in logical pixels." },
"secondary_color": { "label": "Secondary lyric color", "description": "Color used for translation and romanization lines." }
}
}