feat(lyrics): expand sources and display controls
This commit is contained in:
+142
-84
@@ -1,7 +1,8 @@
|
||||
# Lyrics
|
||||
# Noctalia Lyrics 1.4.0
|
||||
|
||||
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke
|
||||
highlighting, animated line changes, and configurable online or local sources.
|
||||
Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
|
||||
translation and romanization layers, configurable sources, karaoke highlighting,
|
||||
album artwork, and layout controls.
|
||||
|
||||
## Plugin
|
||||
|
||||
@@ -12,80 +13,153 @@ highlighting, animated line changes, and configurable online or local sources.
|
||||
|
||||
## Requirements
|
||||
|
||||
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media
|
||||
player must expose MPRIS metadata for automatic track and playback detection.
|
||||
Install these commands on `PATH`:
|
||||
|
||||
Noctalia installs the plugin files; it does not install system packages for you.
|
||||
To check or install the runtime packages automatically, run:
|
||||
|
||||
```sh
|
||||
sh scripts/setup-deps.sh --check
|
||||
sh scripts/setup-deps.sh
|
||||
```
|
||||
|
||||
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
|
||||
`pacman`, `zypper`, `apk`, and `xbps-install`.
|
||||
- `playerctl`: read and control MPRIS players.
|
||||
- `python3`: run the unified lyric-source adapter and dynamic lyric parser.
|
||||
- `cp`: preserve local MPRIS artwork in the plugin cache.
|
||||
- `chmod`: restrict temporary credential request files to the current user.
|
||||
|
||||
## Usage
|
||||
|
||||
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
|
||||
settings. The background `service` detects the active MPRIS player, resolves
|
||||
lyrics, downloads or caches album artwork, and publishes playback state to the
|
||||
widget.
|
||||
|
||||
When several players are available, the service prefers a playing player, then
|
||||
a paused player, and keeps the current player when priorities are equal. The
|
||||
optional allowlist and blocklist match player names or instances and support
|
||||
`*` wildcards.
|
||||
|
||||
Left-click the widget to switch between lyrics and track information. Right-click
|
||||
to pause or resume the active player. Paused content is dimmed and all lyric,
|
||||
transition, and marquee animation stops until playback resumes.
|
||||
|
||||
When synchronized lyrics are unavailable, the widget displays
|
||||
`track title + artist`. Long lines pause at each end while scrolling. Intro and
|
||||
instrumental gaps can show a configurable cue such as `•••••`.
|
||||
Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar
|
||||
widget. Left-click switches between lyrics and track information when
|
||||
`display_mode` is `toggle`; right-click pauses or resumes the selected player.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Status-bar widget:
|
||||
Double-line widget with translated or romanized lyrics:
|
||||
|
||||

|
||||

|
||||
|
||||
Plugin settings:
|
||||
Plugin-wide settings:
|
||||
|
||||

|
||||

|
||||
|
||||
Per-widget bar settings:
|
||||
|
||||

|
||||
|
||||
## Local development
|
||||
|
||||
Add the parent directory of this checkout as a local Noctalia source:
|
||||
|
||||
```sh
|
||||
noctalia msg plugins source add lyrics-dev path /path/to/plugin-parent
|
||||
noctalia msg plugins enable h465855hgg/lyrics
|
||||
noctalia msg config-reload
|
||||
```
|
||||
|
||||
Run validation after every change:
|
||||
|
||||
```sh
|
||||
python3 .github/workflows/validate-plugins.py
|
||||
noctalia plugins lint lyrics
|
||||
sh lyrics/scripts/setup-deps.sh --check
|
||||
cd lyrics
|
||||
python3 -m py_compile lyric_sources.py krc_decode.py lrclib_lyric.py
|
||||
```
|
||||
|
||||
## Lyrics model
|
||||
|
||||
Every source is normalized to lines with independent original, translation,
|
||||
romanization, and character-timing fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"time": 1200,
|
||||
"duration": 1800,
|
||||
"text": "Original lyric",
|
||||
"translation": "Translated lyric",
|
||||
"romanization": "romanized lyric",
|
||||
"chars": [1200, 1500, 1800]
|
||||
}
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
`auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported
|
||||
IDs are:
|
||||
|
||||
- `lrclib`: public LRCLIB search.
|
||||
- `netease`: public NetEase search, synchronized lyrics, translations, and
|
||||
romanization when returned.
|
||||
- `qqmusic`: public QQ Music search and lyric endpoint.
|
||||
- `kugou`: public Kugou search and lyric download endpoint.
|
||||
- `qishui`: user-configured HTTP endpoint supporting `{title}`, `{artist}`, and
|
||||
`{album}` placeholders.
|
||||
- `apple_music`: Apple Music catalog and lyrics request using manually supplied
|
||||
developer and optional user tokens.
|
||||
- `spotify`: Spotify search and color-lyrics request using a manually supplied
|
||||
access token or `sp_dc`.
|
||||
- `musixmatch`: Musixmatch subtitle request using a manually supplied usertoken.
|
||||
- `mpris`: embedded `xesam:asText` lyrics from the selected player.
|
||||
- `custom`: existing generic HTTP endpoint.
|
||||
- `external`: lyrics pushed through Noctalia IPC.
|
||||
|
||||
Source APIs can change or reject requests by region or account. Failure of one
|
||||
source in automatic mode moves to the next source without logging credentials.
|
||||
|
||||
## Credential warning
|
||||
|
||||
Noctalia currently exposes these as normal string settings, not secret fields.
|
||||
Spotify, Apple Music, Musixmatch, and Qishui credentials may therefore be stored
|
||||
in plaintext in Noctalia's settings. The plugin never scans browser cookies,
|
||||
never logs credential values, and deletes its temporary credential request file
|
||||
as soon as the source adapter reads it.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. |
|
||||
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. |
|
||||
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
|
||||
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
|
||||
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
|
||||
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
|
||||
| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. |
|
||||
| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. |
|
||||
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
|
||||
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
|
||||
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
|
||||
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
|
||||
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
|
||||
| `max_chars` | `int` | `15` | Number of visible Unicode characters before marquee scrolling starts. |
|
||||
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
|
||||
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
|
||||
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
|
||||
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
|
||||
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
|
||||
| `active_color` | `color` | `on_surface` | Colors the current and already-sung lyric characters. |
|
||||
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
|
||||
The plugin settings control source selection and fallback order, translation
|
||||
language, lyric timing offset in milliseconds, MPRIS polling interval in
|
||||
milliseconds, double-line translation and romanization, karaoke highlighting,
|
||||
marquee behavior, transitions, fonts, spacing, and side padding.
|
||||
|
||||
## IPC
|
||||
Per-widget settings control the fallback glyph, artist visibility, paused-state
|
||||
visibility, album-cover shape and size, and primary, inactive, and secondary
|
||||
lyric colors. Credential and source-specific fields are shown only when their
|
||||
matching source is selected; source order, credentials, polling, marquee
|
||||
metrics, player filters, and fine layout controls are under Advanced settings.
|
||||
|
||||
External players can set `lyrics_source` to `external` and address the singleton
|
||||
service with:
|
||||
## Feature test checklist
|
||||
|
||||
The normal settings view contains everyday source, lyric-layer, display, font,
|
||||
and animation controls. Open Advanced settings for credentials, source order,
|
||||
polling, marquee metrics, player filters, and fine padding.
|
||||
|
||||
1. Language: change Noctalia's global language and reload the config. Plugin
|
||||
settings and runtime text follow the host language. Noctalia currently
|
||||
supports English and Simplified Chinese from the originally requested locale
|
||||
set; Japanese, Korean, and Traditional Chinese are not host language options.
|
||||
Close and reopen Settings, then disable and enable the plugin if the runtime
|
||||
tooltip still uses the previous language.
|
||||
2. Chinese translation: use NetEase/QQ/Musixmatch with `show_translation=true`
|
||||
and `translation_language=zh-Hans`.
|
||||
3. Romanization: enable `show_romanization` and select a NetEase/QQ track that
|
||||
returns romanized lyrics.
|
||||
4. Sources: select each `lyrics_source` directly, then test custom fallback order
|
||||
through `lyrics_sources`.
|
||||
5. Delay: set `lyrics_offset_ms` to `1000` and `-1000`; positive values should
|
||||
show the next lyric one second earlier.
|
||||
6. Polling: test `poll_interval_ms` at `100`, `500`, and `2000`.
|
||||
7. Cover: test circle, rounded, square, and custom radius; change `cover_size`.
|
||||
8. Double line: enable `double_line` and switch `secondary_line_mode`.
|
||||
On horizontal bars keep `double_line_auto_fit` enabled and lower
|
||||
`double_line_height_budget` if the bar clips the second line.
|
||||
9. Karaoke: toggle `karaoke_enabled`; line transitions must continue when off.
|
||||
10. Hide layers: independently disable `show_translation` and
|
||||
`show_romanization`.
|
||||
11. Track-only: select `display_mode=track`; no lyric source request should run.
|
||||
12. Font and double-line sizing: test `font_family`, `primary_font_size`,
|
||||
`secondary_font_size`, `line_gap`, `font_weight`, and `font_style`.
|
||||
13. Animations: test karaoke, cascade, wave, fade, typewriter, pulse, blink, and
|
||||
none.
|
||||
14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and
|
||||
vertical bars.
|
||||
|
||||
## External protocol
|
||||
|
||||
Address the singleton service with:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
|
||||
@@ -93,26 +167,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.
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone, standard-library lyric source adapter for the Noctalia plugin."""
|
||||
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
USER_AGENT = "Noctalia-Lyrics/1.0"
|
||||
TIME_TAG = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]")
|
||||
KRC_LINE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
|
||||
PREFIX_WORD = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
|
||||
SUFFIX_WORD = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>")
|
||||
ENHANCED_WORD = re.compile(r"<(?:(\d+):)?(\d{1,2}(?:[.:]\d{1,3})?)>([^<]*)")
|
||||
META_TAG = re.compile(r"^\[(ar|al|ti|by|re|ve|length|offset):", re.I)
|
||||
CREDIT_LINE = re.compile(r"^(词|曲|作词|作曲|编曲|制作人|lyricist|composer|arranger)\s*[::]", re.I)
|
||||
|
||||
|
||||
def empty(source, *diag):
|
||||
return {"type": "none", "source": source, "lines": [], "diag": list(diag)}
|
||||
|
||||
|
||||
def clean_text(value):
|
||||
if value is None:
|
||||
return ""
|
||||
return html.unescape(str(value)).replace("\ufeff", "").strip()
|
||||
|
||||
|
||||
def number(value, default=0):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return default
|
||||
|
||||
|
||||
def normalize(value):
|
||||
return "".join(c.lower() for c in clean_text(value) if c.isalnum())
|
||||
|
||||
|
||||
def timestamp_ms(minutes, seconds):
|
||||
seconds = str(seconds).replace(":", ".")
|
||||
return int(round(number(minutes) * 60000 + float(seconds) * 1000))
|
||||
|
||||
|
||||
def duration_ms(value):
|
||||
value = number(value, 0)
|
||||
if value <= 0:
|
||||
return 0
|
||||
# Track duration is commonly milliseconds, but MPRIS callers may send microseconds.
|
||||
return value // 1000 if value > 10_000_000 else value
|
||||
|
||||
|
||||
def line(time=-1, duration=0, text="", translation="", romanization="", chars=None):
|
||||
return {
|
||||
"time": number(time, -1),
|
||||
"duration": max(0, number(duration)),
|
||||
"text": clean_text(text),
|
||||
"translation": clean_text(translation),
|
||||
"romanization": clean_text(romanization),
|
||||
"chars": [number(item) for item in (chars or [])],
|
||||
}
|
||||
|
||||
|
||||
def finalize(lines, total_duration=0):
|
||||
cleaned = []
|
||||
for item in lines or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized = line(
|
||||
item.get("time", item.get("start", item.get("startTimeMs", -1))),
|
||||
item.get("duration", item.get("durationMs", 0)),
|
||||
item.get("text", item.get("words", item.get("lyric", ""))),
|
||||
item.get("translation", item.get("translated", "")),
|
||||
item.get("romanization", item.get("romanized", item.get("romaji", ""))),
|
||||
item.get("chars", item.get("charTimes", [])),
|
||||
)
|
||||
if normalized["text"] or normalized["translation"] or normalized["romanization"]:
|
||||
cleaned.append(normalized)
|
||||
cleaned.sort(key=lambda item: (item["time"] < 0, item["time"] if item["time"] >= 0 else 0))
|
||||
for index, item in enumerate(cleaned):
|
||||
if item["duration"] > 0 or item["time"] < 0:
|
||||
continue
|
||||
next_time = next(
|
||||
(other["time"] for other in cleaned[index + 1:] if other["time"] > item["time"]),
|
||||
total_duration if total_duration > item["time"] else 0,
|
||||
)
|
||||
if next_time:
|
||||
item["duration"] = max(0, next_time - item["time"])
|
||||
return cleaned
|
||||
|
||||
|
||||
def merge_timed(primary, secondary, field, tolerance=500):
|
||||
if not primary or not secondary:
|
||||
return primary
|
||||
untimed = [item for item in secondary if item.get("time", -1) < 0]
|
||||
timed = [item for item in secondary if item.get("time", -1) >= 0]
|
||||
for index, target in enumerate(primary):
|
||||
value = ""
|
||||
if target.get("time", -1) >= 0 and timed:
|
||||
candidate = min(timed, key=lambda item: abs(item["time"] - target["time"]))
|
||||
if abs(candidate["time"] - target["time"]) <= tolerance:
|
||||
value = candidate.get("text", "")
|
||||
elif index < len(untimed):
|
||||
value = untimed[index].get("text", "")
|
||||
if value and not target.get(field):
|
||||
target[field] = value
|
||||
return primary
|
||||
|
||||
|
||||
def parse_plain(text):
|
||||
return [line(-1, text=value) for value in str(text or "").splitlines() if clean_text(value)]
|
||||
|
||||
|
||||
def parse_lrc(text):
|
||||
text = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
offset = 0
|
||||
match = re.search(r"\[offset:([+-]?\d+)\]", text, re.I)
|
||||
if match:
|
||||
offset = number(match.group(1))
|
||||
result = []
|
||||
for raw in text.splitlines():
|
||||
raw = raw.strip()
|
||||
if not raw or META_TAG.match(raw):
|
||||
continue
|
||||
krc = KRC_LINE.match(raw)
|
||||
if krc:
|
||||
start, duration, body = number(krc.group(1)), number(krc.group(2)), krc.group(3)
|
||||
words = PREFIX_WORD.findall(body) if re.match(r"^[<(]\d+,", body) else []
|
||||
if words:
|
||||
pieces = [(word, number(word_offset), number(word_duration))
|
||||
for word_offset, word_duration, word in words]
|
||||
else:
|
||||
pieces = [(word, number(word_offset), number(word_duration))
|
||||
for word, word_offset, word_duration in SUFFIX_WORD.findall(body)]
|
||||
if pieces:
|
||||
content, chars = "", []
|
||||
for word, word_offset, word_duration in pieces:
|
||||
for index, character in enumerate(word):
|
||||
content += character
|
||||
chars.append(start + word_offset + (index * word_duration // max(1, len(word))))
|
||||
if clean_text(content):
|
||||
result.append(line(start + offset, duration, content, chars=chars))
|
||||
continue
|
||||
if clean_text(body):
|
||||
result.append(line(start + offset, duration, body))
|
||||
continue
|
||||
tags = list(TIME_TAG.finditer(raw))
|
||||
if not tags:
|
||||
continue
|
||||
body = TIME_TAG.sub("", raw).strip()
|
||||
enhanced = list(ENHANCED_WORD.finditer(body))
|
||||
visible = clean_text(ENHANCED_WORD.sub(lambda item: item.group(3), body)) if enhanced else clean_text(body)
|
||||
if not visible:
|
||||
continue
|
||||
for tag in tags:
|
||||
start = timestamp_ms(tag.group(1), tag.group(2)) + offset
|
||||
if CREDIT_LINE.match(visible) or (start <= 1000 and " - " in visible):
|
||||
continue
|
||||
chars = []
|
||||
if enhanced:
|
||||
for word in enhanced:
|
||||
word_time = timestamp_ms(word.group(1) or tag.group(1), word.group(2)) + offset
|
||||
chars.extend([word_time] * len(word.group(3)))
|
||||
result.append(line(start, text=visible, chars=chars))
|
||||
return finalize(result)
|
||||
|
||||
|
||||
def parse_time_expression(value):
|
||||
value = clean_text(value)
|
||||
if not value:
|
||||
return -1
|
||||
if value.endswith("ms"):
|
||||
return number(value[:-2], -1)
|
||||
if value.endswith("s"):
|
||||
try:
|
||||
return int(float(value[:-1]) * 1000)
|
||||
except ValueError:
|
||||
return -1
|
||||
parts = value.split(":")
|
||||
try:
|
||||
if len(parts) == 3:
|
||||
return int((float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])) * 1000)
|
||||
if len(parts) == 2:
|
||||
return int((float(parts[0]) * 60 + float(parts[1])) * 1000)
|
||||
return int(float(value) * 1000)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
|
||||
def parse_ttml(text):
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except (ET.ParseError, TypeError):
|
||||
return []
|
||||
result = []
|
||||
for node in root.iter():
|
||||
if node.tag.rsplit("}", 1)[-1] != "p":
|
||||
continue
|
||||
start = parse_time_expression(node.attrib.get("begin", ""))
|
||||
end = parse_time_expression(node.attrib.get("end", ""))
|
||||
content = clean_text("".join(node.itertext()))
|
||||
if not content:
|
||||
continue
|
||||
chars = []
|
||||
for child in node.iter():
|
||||
if child is node or child.tag.rsplit("}", 1)[-1] != "span":
|
||||
continue
|
||||
child_text = "".join(child.itertext())
|
||||
child_start = parse_time_expression(child.attrib.get("begin", ""))
|
||||
if child_text and child_start >= 0:
|
||||
chars.extend([child_start] * len(child_text))
|
||||
role = " ".join(str(value) for key, value in node.attrib.items() if "role" in key.lower()).lower()
|
||||
item = line(start, max(0, end - start) if end >= start >= 0 else 0, content, chars=chars)
|
||||
if "translation" in role:
|
||||
item["_kind"] = "translation"
|
||||
elif "roman" in role:
|
||||
item["_kind"] = "romanization"
|
||||
result.append(item)
|
||||
primary = [item for item in result if not item.get("_kind")]
|
||||
translations = [item for item in result if item.get("_kind") == "translation"]
|
||||
romanizations = [item for item in result if item.get("_kind") == "romanization"]
|
||||
if not primary:
|
||||
primary = translations or romanizations
|
||||
merge_timed(primary, translations, "translation")
|
||||
merge_timed(primary, romanizations, "romanization")
|
||||
for item in primary:
|
||||
item.pop("_kind", None)
|
||||
return finalize(primary)
|
||||
|
||||
|
||||
def first_value(data, names):
|
||||
if isinstance(data, dict):
|
||||
for name in names:
|
||||
if name in data and data[name] not in (None, "", [], {}):
|
||||
return data[name]
|
||||
for value in data.values():
|
||||
found = first_value(value, names)
|
||||
if found not in (None, "", [], {}):
|
||||
return found
|
||||
elif isinstance(data, list):
|
||||
for value in data:
|
||||
found = first_value(value, names)
|
||||
if found not in (None, "", [], {}):
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def parse_json_lines(value):
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("<") and ("<tt" in stripped[:300] or "<p" in stripped[:300]):
|
||||
return parse_ttml(stripped)
|
||||
if "[" in stripped and (TIME_TAG.search(stripped) or KRC_LINE.search(stripped)):
|
||||
return parse_lrc(stripped)
|
||||
return parse_plain(stripped)
|
||||
if isinstance(value, dict):
|
||||
direct = first_value(value, ("lines", "lyricLines", "lyricsLines", "sentences"))
|
||||
if direct is not None and direct is not value:
|
||||
parsed = parse_json_lines(direct)
|
||||
if parsed:
|
||||
return parsed
|
||||
lyric = first_value(value, ("syncedLyrics", "synced_lyrics", "subtitle_body", "ttml", "lyric", "lyrics", "lrc", "content"))
|
||||
if lyric is not None and lyric is not value:
|
||||
return parse_json_lines(lyric)
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result = []
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
result.append(line(-1, text=item))
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
start = first_value(item, ("time", "start", "startTime", "startTimeMs", "start_time", "begin", "timestamp"))
|
||||
duration = first_value(item, ("duration", "durationMs", "duration_ms"))
|
||||
end = first_value(item, ("end", "endTime", "endTimeMs", "end_time"))
|
||||
text = first_value(item, ("text", "words", "lyric", "content", "line"))
|
||||
translation = first_value(item, ("translation", "translated", "translatedLyric"))
|
||||
romanization = first_value(item, ("romanization", "romanized", "romaji", "transliteration"))
|
||||
if isinstance(start, str) and (":" in start or start.endswith(("s", "ms"))):
|
||||
start = parse_time_expression(start)
|
||||
start = number(start, -1)
|
||||
duration = number(duration, 0)
|
||||
if not duration and end is not None:
|
||||
if isinstance(end, str) and (":" in end or end.endswith(("s", "ms"))):
|
||||
end = parse_time_expression(end)
|
||||
duration = max(0, number(end) - start)
|
||||
chars = first_value(item, ("chars", "charTimes", "syllables", "wordsTiming")) or []
|
||||
char_times = []
|
||||
if isinstance(chars, list):
|
||||
for char in chars:
|
||||
if isinstance(char, dict):
|
||||
char_times.append(number(first_value(char, ("time", "start", "startTimeMs"))))
|
||||
elif isinstance(char, (int, float, str)):
|
||||
char_times.append(number(char))
|
||||
result.append(line(start, duration, text, translation, romanization, char_times))
|
||||
return finalize(result)
|
||||
|
||||
|
||||
def parse_payload(payload):
|
||||
if isinstance(payload, bytes):
|
||||
payload = payload.decode("utf-8", "replace")
|
||||
if isinstance(payload, str):
|
||||
stripped = payload.strip().lstrip("\ufeff")
|
||||
if stripped.startswith("<"):
|
||||
parsed = parse_ttml(stripped)
|
||||
if parsed:
|
||||
return parsed
|
||||
try:
|
||||
payload = json.loads(stripped)
|
||||
except (ValueError, TypeError):
|
||||
return parse_lrc(stripped) or parse_plain(stripped)
|
||||
return parse_json_lines(payload)
|
||||
|
||||
|
||||
def request_data(url, headers=None, data=None, method=None, timeout=15):
|
||||
safe_headers = {"User-Agent": USER_AGENT, "Accept": "application/json, text/plain, application/xml, text/xml"}
|
||||
safe_headers.update(headers or {})
|
||||
body = None
|
||||
if data is not None:
|
||||
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=body, headers=safe_headers, method=method)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read(), response.headers.get_content_charset() or "utf-8"
|
||||
|
||||
|
||||
def request_json(url, headers=None, data=None, method=None):
|
||||
body, charset = request_data(url, headers, data, method)
|
||||
text = body.decode(charset, "replace").strip()
|
||||
if text.startswith("callback(") and text.endswith(")"):
|
||||
text = text[9:-1]
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def query_url(base, params):
|
||||
return base + ("&" if "?" in base else "?") + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
def best_match(items, track, title_key, artist_key, album_key=None):
|
||||
if not items:
|
||||
return None
|
||||
wanted_title, wanted_artist, wanted_album = map(normalize, (
|
||||
track.get("title"), track.get("artist"), track.get("album")
|
||||
))
|
||||
best, best_score = None, -1
|
||||
for item in items:
|
||||
title = normalize(title_key(item))
|
||||
artist = normalize(artist_key(item))
|
||||
album = normalize(album_key(item)) if album_key else ""
|
||||
score = 0
|
||||
if wanted_title and title:
|
||||
title_score = 6 if title == wanted_title else 3 if wanted_title in title or title in wanted_title else 0
|
||||
if title_score == 0:
|
||||
continue
|
||||
score += title_score
|
||||
if wanted_artist and artist:
|
||||
score += 4 if artist == wanted_artist else 2 if wanted_artist in artist or artist in wanted_artist else 0
|
||||
if wanted_album and album:
|
||||
score += 2 if album == wanted_album else 1 if wanted_album in album or album in wanted_album else 0
|
||||
if score > best_score:
|
||||
best, best_score = item, score
|
||||
return best if best_score >= 3 else None
|
||||
|
||||
|
||||
def success(source, lines, diag, total=0):
|
||||
lines = finalize(lines, total)
|
||||
return {"type": "lyrics", "source": source, "lines": lines, "diag": diag} if lines else empty(source, *diag)
|
||||
|
||||
|
||||
def adapter_lrclib(track, credentials, options):
|
||||
source = "lrclib"
|
||||
params = {"track_name": track.get("title", ""), "artist_name": track.get("artist", "")}
|
||||
if track.get("album"):
|
||||
params["album_name"] = track["album"]
|
||||
data = request_json(query_url("https://lrclib.net/api/search", params))
|
||||
best = best_match(data, track, lambda x: x.get("trackName", ""), lambda x: x.get("artistName", ""),
|
||||
lambda x: x.get("albumName", ""))
|
||||
if not best:
|
||||
return empty(source, "lrclib: no match")
|
||||
lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or ""
|
||||
return success(source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_netease(track, credentials, options):
|
||||
source = "netease"
|
||||
search = request_json(query_url("https://music.163.com/api/search/get", {
|
||||
"type": 1, "s": " ".join(filter(None, (track.get("title"), track.get("artist")))), "limit": 10
|
||||
}), {"Referer": "https://music.163.com/"})
|
||||
songs = search.get("result", {}).get("songs", [])
|
||||
best = best_match(songs, track, lambda x: x.get("name", ""),
|
||||
lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])),
|
||||
lambda x: x.get("album", {}).get("name", ""))
|
||||
if not best:
|
||||
return empty(source, "netease: no match")
|
||||
data = request_json(query_url("https://music.163.com/api/song/lyric", {
|
||||
"id": best.get("id"), "lv": 1, "kv": 1, "tv": 1, "rv": 1, "yv": 1
|
||||
}), {"Referer": "https://music.163.com/"})
|
||||
lines = []
|
||||
for name in ("yrc", "klyric", "lrc"):
|
||||
main = data.get(name, {})
|
||||
main = main.get("lyric", "") if isinstance(main, dict) else main
|
||||
lines = parse_lrc(main) if main else []
|
||||
if lines:
|
||||
break
|
||||
translation = data.get("tlyric", {})
|
||||
romanization = data.get("romalrc", {})
|
||||
merge_timed(lines, parse_lrc(translation.get("lyric", "") if isinstance(translation, dict) else translation), "translation")
|
||||
merge_timed(lines, parse_lrc(romanization.get("lyric", "") if isinstance(romanization, dict) else romanization), "romanization")
|
||||
return success(source, lines, ["netease: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_qqmusic(track, credentials, options):
|
||||
source = "qqmusic"
|
||||
search = request_json(query_url("https://c.y.qq.com/soso/fcgi-bin/client_search_cp", {
|
||||
"format": "json", "p": 1, "n": 10, "w": " ".join(filter(None, (track.get("title"), track.get("artist"))))
|
||||
}), {"Referer": "https://y.qq.com/"})
|
||||
songs = search.get("data", {}).get("song", {}).get("list", [])
|
||||
best = best_match(songs, track, lambda x: x.get("songname", x.get("title", "")),
|
||||
lambda x: " ".join(a.get("name", "") for a in x.get("singer", [])),
|
||||
lambda x: x.get("albumname", ""))
|
||||
if not best:
|
||||
return empty(source, "qqmusic: no match")
|
||||
data = request_json(query_url("https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg", {
|
||||
"songmid": best.get("songmid", best.get("mid", "")), "format": "json", "nobase64": 1,
|
||||
"g_tk": 5381
|
||||
}), {"Referer": "https://y.qq.com/portal/player.html"})
|
||||
def decoded(name):
|
||||
value = data.get(name, "")
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return base64.b64decode(value).decode("utf-8", "replace") if not TIME_TAG.search(value) else value
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
lines = parse_lrc(decoded("lyric"))
|
||||
merge_timed(lines, parse_lrc(decoded("trans")), "translation")
|
||||
merge_timed(lines, parse_lrc(decoded("roma")), "romanization")
|
||||
return success(source, lines, ["qqmusic: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_kugou(track, credentials, options):
|
||||
source = "kugou"
|
||||
keyword = " ".join(filter(None, (track.get("title"), track.get("artist"))))
|
||||
search = request_json(query_url("https://mobilecdn.kugou.com/api/v3/search/song", {
|
||||
"format": "json", "keyword": keyword, "page": 1, "pagesize": 10, "showtype": 1
|
||||
}))
|
||||
songs = search.get("data", {}).get("info", [])
|
||||
best = best_match(songs, track, lambda x: x.get("songname", x.get("filename", "")),
|
||||
lambda x: x.get("singername", ""), lambda x: x.get("album_name", ""))
|
||||
if not best:
|
||||
return empty(source, "kugou: no match")
|
||||
candidates = request_json(query_url("https://lyrics.kugou.com/search", {
|
||||
"ver": 1, "man": "yes", "client": "pc", "keyword": keyword,
|
||||
"duration": best.get("duration", duration_ms(track.get("duration"))), "hash": best.get("hash", "")
|
||||
})).get("candidates", [])
|
||||
if not candidates:
|
||||
return empty(source, "kugou: lyrics unavailable")
|
||||
candidate = candidates[0]
|
||||
data = request_json(query_url("https://lyrics.kugou.com/download", {
|
||||
"ver": 1, "client": "pc", "id": candidate.get("id"), "accesskey": candidate.get("accesskey"),
|
||||
"fmt": "lrc", "charset": "utf8"
|
||||
}))
|
||||
content = data.get("content", "")
|
||||
try:
|
||||
content = base64.b64decode(content).decode("utf-8", "replace")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return success(source, parse_lrc(content), ["kugou: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_qishui(track, credentials, options):
|
||||
source = "qishui"
|
||||
template = clean_text(credentials.get("qishui_api_url"))
|
||||
if not template:
|
||||
return empty(source, "qishui: endpoint required")
|
||||
replacements = {key: urllib.parse.quote(str(track.get(key, "")), safe="") for key in ("title", "artist", "album")}
|
||||
try:
|
||||
url = template.format(**replacements)
|
||||
except (KeyError, ValueError):
|
||||
return empty(source, "qishui: invalid endpoint template")
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return empty(source, "qishui: invalid endpoint")
|
||||
headers = {}
|
||||
if credentials.get("qishui_token"):
|
||||
headers["Authorization"] = "Bearer " + str(credentials["qishui_token"])
|
||||
body, charset = request_data(url, headers)
|
||||
lines = parse_payload(body.decode(charset, "replace"))
|
||||
return success(source, lines, ["qishui: response parsed"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def spotify_token(credentials):
|
||||
token = clean_text(credentials.get("spotify_access_token"))
|
||||
if token:
|
||||
return token
|
||||
cookie = clean_text(credentials.get("spotify_sp_dc"))
|
||||
if not cookie:
|
||||
return ""
|
||||
data = request_json("https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
|
||||
{"Cookie": "sp_dc=" + cookie, "Referer": "https://open.spotify.com/"})
|
||||
return clean_text(data.get("accessToken"))
|
||||
|
||||
|
||||
def adapter_spotify(track, credentials, options):
|
||||
source = "spotify"
|
||||
token = spotify_token(credentials)
|
||||
if not token:
|
||||
return empty(source, "spotify: credentials required")
|
||||
headers = {"Authorization": "Bearer " + token}
|
||||
search = request_json(query_url("https://api.spotify.com/v1/search", {
|
||||
"q": " ".join(filter(None, (track.get("title"), track.get("artist")))), "type": "track", "limit": 10
|
||||
}), headers)
|
||||
items = search.get("tracks", {}).get("items", [])
|
||||
best = best_match(items, track, lambda x: x.get("name", ""),
|
||||
lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])),
|
||||
lambda x: x.get("album", {}).get("name", ""))
|
||||
if not best:
|
||||
return empty(source, "spotify: no match")
|
||||
data = request_json(query_url("https://spclient.wg.spotify.com/color-lyrics/v2/track/" + urllib.parse.quote(best["id"]), {
|
||||
"format": "json", "market": "from_token"
|
||||
}), headers)
|
||||
lines = parse_json_lines(data.get("lyrics", {}).get("lines", []))
|
||||
alternatives = data.get("lyrics", {}).get("alternatives", [])
|
||||
if alternatives and isinstance(alternatives[0], dict):
|
||||
merge_timed(lines, parse_json_lines(alternatives[0].get("lines", [])), "translation")
|
||||
return success(source, lines, ["spotify: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_apple_music(track, credentials, options):
|
||||
source = "apple_music"
|
||||
developer = clean_text(credentials.get("apple_developer_token"))
|
||||
if not developer:
|
||||
return empty(source, "apple_music: developer token required")
|
||||
storefront = clean_text(credentials.get("apple_storefront")) or "us"
|
||||
if not re.fullmatch(r"[A-Za-z0-9-]+", storefront):
|
||||
return empty(source, "apple_music: invalid storefront")
|
||||
headers = {"Authorization": "Bearer " + developer, "Origin": "https://music.apple.com"}
|
||||
if credentials.get("apple_user_token"):
|
||||
headers["Music-User-Token"] = str(credentials["apple_user_token"])
|
||||
search = request_json(query_url("https://api.music.apple.com/v1/catalog/" + storefront + "/search", {
|
||||
"term": " ".join(filter(None, (track.get("title"), track.get("artist")))), "types": "songs", "limit": 10
|
||||
}), headers)
|
||||
songs = search.get("results", {}).get("songs", {}).get("data", [])
|
||||
best = best_match(songs, track, lambda x: x.get("attributes", {}).get("name", ""),
|
||||
lambda x: x.get("attributes", {}).get("artistName", ""),
|
||||
lambda x: x.get("attributes", {}).get("albumName", ""))
|
||||
if not best:
|
||||
return empty(source, "apple_music: no match")
|
||||
body, charset = request_data(
|
||||
"https://amp-api.music.apple.com/v1/catalog/" + storefront + "/songs/" + urllib.parse.quote(str(best["id"])) + "/lyrics",
|
||||
headers,
|
||||
)
|
||||
text = body.decode(charset, "replace")
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
lyric_data = first_value(payload, ("ttml", "syllableLyrics", "lyrics", "content"))
|
||||
lines = parse_payload(lyric_data) if lyric_data is not None else parse_json_lines(payload)
|
||||
except ValueError:
|
||||
lines = parse_ttml(text)
|
||||
return success(source, lines, ["apple_music: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
def adapter_musixmatch(track, credentials, options):
|
||||
source = "musixmatch"
|
||||
token = clean_text(credentials.get("musixmatch_token"))
|
||||
if not token:
|
||||
return empty(source, "musixmatch: usertoken required")
|
||||
params = {
|
||||
"app_id": "web-desktop-app-v1.0", "usertoken": token,
|
||||
"q_track": track.get("title", ""), "q_artist": track.get("artist", ""),
|
||||
"q_album": track.get("album", ""), "subtitle_format": "lrc", "page_size": 5,
|
||||
}
|
||||
language = clean_text(options.get("translation_language"))
|
||||
if language:
|
||||
params["selected_language"] = language
|
||||
data = request_json(query_url("https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get", params),
|
||||
{"Origin": "https://www.musixmatch.com", "Referer": "https://www.musixmatch.com/"})
|
||||
subtitle = first_value(data, ("subtitle_body",))
|
||||
if not subtitle:
|
||||
return empty(source, "musixmatch: lyrics unavailable")
|
||||
lines = parse_lrc(subtitle)
|
||||
translated = first_value(data, ("translation_list", "translations"))
|
||||
if isinstance(translated, list):
|
||||
translated_lines = []
|
||||
for item in translated:
|
||||
value = item.get("translation", item) if isinstance(item, dict) else item
|
||||
if isinstance(value, dict):
|
||||
text = value.get("description", value.get("translation", ""))
|
||||
time = value.get("time", value.get("matched_line", -1))
|
||||
translated_lines.append(line(time, text=text))
|
||||
merge_timed(lines, translated_lines, "translation")
|
||||
return success(source, lines, ["musixmatch: match"], duration_ms(track.get("duration")))
|
||||
|
||||
|
||||
ADAPTERS = {
|
||||
"lrclib": adapter_lrclib,
|
||||
"netease": adapter_netease,
|
||||
"netease_public": adapter_netease,
|
||||
"qq": adapter_qqmusic,
|
||||
"qqmusic": adapter_qqmusic,
|
||||
"kugou": adapter_kugou,
|
||||
"qishui": adapter_qishui,
|
||||
"apple": adapter_apple_music,
|
||||
"apple_music": adapter_apple_music,
|
||||
"spotify": adapter_spotify,
|
||||
"musixmatch": adapter_musixmatch,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
source = ""
|
||||
response = None
|
||||
if len(sys.argv) != 2:
|
||||
response = empty(source, "request: expected one file path")
|
||||
else:
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as request_file:
|
||||
request = json.load(request_file)
|
||||
try:
|
||||
os.remove(sys.argv[1])
|
||||
except OSError:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
response = empty(source, "request: file not found")
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
response = empty(source, "request: unreadable or invalid JSON")
|
||||
else:
|
||||
try:
|
||||
if not isinstance(request, dict):
|
||||
response = empty(source, "request: invalid JSON object")
|
||||
else:
|
||||
source = clean_text(request.get("source")).lower()
|
||||
track = request.get("track") if isinstance(request.get("track"), dict) else {}
|
||||
credentials = request.get("credentials") if isinstance(request.get("credentials"), dict) else {}
|
||||
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
||||
adapter = ADAPTERS.get(source)
|
||||
if not adapter:
|
||||
response = empty(source, "request: unknown source")
|
||||
elif not clean_text(track.get("title")):
|
||||
response = empty(source, "request: track title required")
|
||||
else:
|
||||
response = adapter(track, credentials, options)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = empty(source, "source: HTTP " + str(error.code))
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
response = empty(source, "source: network failure")
|
||||
except (ValueError, ET.ParseError):
|
||||
response = empty(source, "source: invalid response")
|
||||
except Exception:
|
||||
response = empty(source, "source: unexpected failure")
|
||||
print(json.dumps(response or empty(source, "source: empty response"), ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+178
-20
@@ -8,8 +8,12 @@ local showArtist = noctalia.getConfig("show_artist")
|
||||
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
|
||||
local showCover = noctalia.getConfig("show_cover")
|
||||
if showCover == nil then showCover = true end
|
||||
local coverShape = noctalia.getConfig("cover_shape") or "circle"
|
||||
local coverRadius = tonumber(noctalia.getConfig("cover_radius")) or 5
|
||||
local coverSize = tonumber(noctalia.getConfig("cover_size")) or 18
|
||||
local activeColor = noctalia.getConfig("active_color") or "on_surface"
|
||||
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
|
||||
local secondaryColor = noctalia.getConfig("secondary_color") or "on_surface_variant"
|
||||
|
||||
-- These are plugin settings, not per-widget settings.
|
||||
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
|
||||
@@ -25,14 +29,35 @@ if cueText == "" then cueText = "•••••" end
|
||||
local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
|
||||
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
|
||||
if cueFontFamily == "" then cueFontFamily = "sans-serif" end
|
||||
local displayMode = noctalia.getConfig("display_mode") or "toggle"
|
||||
local doubleLine = noctalia.getConfig("double_line")
|
||||
if doubleLine == nil then doubleLine = true end
|
||||
local doubleLineAutoFit = noctalia.getConfig("double_line_auto_fit")
|
||||
if doubleLineAutoFit == nil then doubleLineAutoFit = true end
|
||||
local doubleLineHeightBudget = tonumber(noctalia.getConfig("double_line_height_budget")) or 26
|
||||
local showTranslation = noctalia.getConfig("show_translation")
|
||||
if showTranslation == nil then showTranslation = true end
|
||||
local showRomanization = noctalia.getConfig("show_romanization") == true
|
||||
local secondaryLineMode = noctalia.getConfig("secondary_line_mode") or "translation_first"
|
||||
local karaokeEnabled = noctalia.getConfig("karaoke_enabled")
|
||||
if karaokeEnabled == nil then karaokeEnabled = true end
|
||||
local fontFamily = noctalia.getConfig("font_family") or ""
|
||||
local primaryFontSize = tonumber(noctalia.getConfig("primary_font_size")) or 0
|
||||
local secondaryFontSize = tonumber(noctalia.getConfig("secondary_font_size")) or 10
|
||||
local fontWeight = noctalia.getConfig("font_weight") or "normal"
|
||||
local fontStyle = noctalia.getConfig("font_style") or "normal"
|
||||
local lineGap = tonumber(noctalia.getConfig("line_gap")) or 2
|
||||
local paddingLeft = tonumber(noctalia.getConfig("padding_left")) or 0
|
||||
local paddingRight = tonumber(noctalia.getConfig("padding_right")) or 0
|
||||
|
||||
local track = nil
|
||||
local lyrics = nil
|
||||
local cover = nil
|
||||
local playing = false
|
||||
local showMode = "auto"
|
||||
local showMode = displayMode == "track" and "track" or "auto"
|
||||
local rendered = false
|
||||
local playerInstance = ""
|
||||
local sourceUsed = ""
|
||||
|
||||
local baselinePosition = 0
|
||||
local baselineClock = os.clock()
|
||||
@@ -80,6 +105,23 @@ local function toChars(text)
|
||||
return chars
|
||||
end
|
||||
|
||||
local function charUnits(char)
|
||||
if char == " " or char == "\t" then return 0.35 end
|
||||
local byte = char:byte(1) or 0
|
||||
if byte < 0x80 then
|
||||
if char:match("[%.,:;!'|ilI%-%(%)]") then return 0.38 end
|
||||
if char:match("[MW@#%%&]") then return 0.85 end
|
||||
return 0.58
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function textUnits(text)
|
||||
local units = 0
|
||||
for _, char in ipairs(toChars(text or "")) do units = units + charUnits(char) end
|
||||
return units
|
||||
end
|
||||
|
||||
local function getProgressMs()
|
||||
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
|
||||
return baselinePosition / 1000 + elapsed
|
||||
@@ -176,11 +218,20 @@ local function getFallbackLabel()
|
||||
end
|
||||
|
||||
local function shouldMarquee(text)
|
||||
return scrollMode ~= "static" and #toChars(text) > maxChars
|
||||
return scrollMode ~= "static" and textUnits(text) > maxChars
|
||||
end
|
||||
|
||||
local function getMarqueeOffset(length)
|
||||
local distance = math.max(0, length - maxChars)
|
||||
local function getMarqueeOffset(chars)
|
||||
local trailingChars = 0
|
||||
local units = 0
|
||||
for index = #chars, 1, -1 do
|
||||
local char = chars[index]
|
||||
local nextUnits = units + charUnits(char)
|
||||
if nextUnits > maxChars then break end
|
||||
units = nextUnits
|
||||
trailingChars = trailingChars + 1
|
||||
end
|
||||
local distance = math.max(0, #chars - math.max(1, trailingChars))
|
||||
if distance == 0 then return 0 end
|
||||
|
||||
-- The setting is pixels per second; convert it to character cells per second.
|
||||
@@ -225,6 +276,37 @@ local function karaokeColor(charTime, charProgress, position, useGradient)
|
||||
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
|
||||
end
|
||||
|
||||
local function labelBaseline()
|
||||
if fontStyle == "fixed" then return "textFixedHeight" end
|
||||
if fontStyle == "ink_centered" then return "inkCentered" end
|
||||
return "text"
|
||||
end
|
||||
|
||||
local function secondaryText(line)
|
||||
if not doubleLine or type(line) ~= "table" then return "" end
|
||||
local translation = showTranslation and (line.translation or "") or ""
|
||||
local romanization = showRomanization and (line.romanization or "") or ""
|
||||
if secondaryLineMode == "translation" then return translation end
|
||||
if secondaryLineMode == "romanization" then return romanization end
|
||||
if secondaryLineMode == "romanization_first" then
|
||||
return romanization ~= "" and romanization or translation
|
||||
end
|
||||
return translation ~= "" and translation or romanization
|
||||
end
|
||||
|
||||
local function secondaryRowEnabled()
|
||||
return doubleLine and (showTranslation or showRomanization)
|
||||
end
|
||||
|
||||
local function compactMetrics(hasSecondary, vertical)
|
||||
if not hasSecondary or vertical or not doubleLineAutoFit then return 1, lineGap end
|
||||
local primaryBase = primaryFontSize > 0 and primaryFontSize or 13
|
||||
local secondaryBase = secondaryFontSize > 0 and secondaryFontSize or 10
|
||||
local naturalHeight = primaryBase * 1.25 + secondaryBase * 1.25 + lineGap
|
||||
local scale = math.min(1, doubleLineHeightBudget / math.max(1, naturalHeight))
|
||||
return scale, math.floor(lineGap * scale + 0.5)
|
||||
end
|
||||
|
||||
local function buildLineRow(line, opts)
|
||||
opts = opts or {}
|
||||
local text = type(line) == "table" and line.text or line
|
||||
@@ -233,16 +315,25 @@ local function buildLineRow(line, opts)
|
||||
local chars = toChars(text or "")
|
||||
if #chars == 0 then chars = { " " } end
|
||||
|
||||
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
|
||||
local exactOffset = opts.marquee and getMarqueeOffset(chars) or 0
|
||||
local offset = math.floor(exactOffset)
|
||||
local fraction = exactOffset - offset
|
||||
local first = offset + 1
|
||||
local last = math.min(#chars, first + maxChars)
|
||||
local last = #chars
|
||||
if opts.marquee then
|
||||
local units = 0
|
||||
last = first - 1
|
||||
for sourceIndex = first, #chars do
|
||||
units = units + charUnits(chars[sourceIndex])
|
||||
last = sourceIndex
|
||||
if units > maxChars then break end
|
||||
end
|
||||
end
|
||||
local labels = {}
|
||||
|
||||
for sourceIndex = first, last do
|
||||
local active = playing and activeColor or inactiveColor
|
||||
local color = opts.dim and inactiveColor or active
|
||||
local color = opts.secondary and secondaryColor or (opts.dim and inactiveColor or active)
|
||||
if not opts.solid and not opts.dim then
|
||||
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
|
||||
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
|
||||
@@ -263,17 +354,24 @@ local function buildLineRow(line, opts)
|
||||
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
|
||||
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
|
||||
end
|
||||
if opts.typewriter then
|
||||
local reveal = clamp((transitionElapsed - FADE_OUT_MS) / 520, 0, 1)
|
||||
local threshold = (sourceIndex - first) / math.max(1, last - first + 1)
|
||||
alpha = alpha * (reveal >= threshold and 1 or 0.08)
|
||||
end
|
||||
if opts.blink and transitionElapsed < FADE_OUT_MS + 540 then
|
||||
local blink = math.floor(math.max(0, transitionElapsed - FADE_OUT_MS) / 90) % 2
|
||||
alpha = alpha * (blink == 0 and 0.35 or 1)
|
||||
end
|
||||
|
||||
-- Bar labels elide glyphs when constrained below their natural width, so
|
||||
-- animate the viewport edges with opacity without clipping CJK characters.
|
||||
if opts.marquee and fraction > 0 then
|
||||
if sourceIndex == first then
|
||||
alpha = alpha * (1 - fraction)
|
||||
elseif sourceIndex == first + maxChars then
|
||||
elseif sourceIndex == last and sourceIndex > first then
|
||||
alpha = alpha * fraction
|
||||
end
|
||||
elseif sourceIndex == first + maxChars then
|
||||
alpha = 0
|
||||
end
|
||||
|
||||
labels[#labels + 1] = ui.label({
|
||||
@@ -282,12 +380,17 @@ local function buildLineRow(line, opts)
|
||||
color = color,
|
||||
opacity = alpha,
|
||||
maxLines = 1,
|
||||
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil,
|
||||
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or (fontFamily ~= "" and fontFamily or nil),
|
||||
fontSize = opts.secondary and math.max(6, secondaryFontSize * (opts.fontScale or 1))
|
||||
or ((primaryFontSize > 0 or (opts.fontScale or 1) < 1)
|
||||
and math.max(6, (primaryFontSize > 0 and primaryFontSize or 13) * (opts.fontScale or 1)) or nil),
|
||||
fontWeight = opts.secondary and "normal" or fontWeight,
|
||||
baseline = labelBaseline(),
|
||||
})
|
||||
end
|
||||
|
||||
if opts.marquee then
|
||||
while #labels < maxChars + 1 do
|
||||
while #labels < math.ceil(maxChars) + 1 do
|
||||
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
|
||||
end
|
||||
end
|
||||
@@ -305,6 +408,9 @@ local function currentTransitionLine(info)
|
||||
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
|
||||
local opacity = animation == "none" and 1
|
||||
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
|
||||
if animation == "pulse" and transitionElapsed < FADE_OUT_MS + 600 then
|
||||
opacity = opacity * (0.76 + 0.24 * math.sin(math.max(0, transitionElapsed - FADE_OUT_MS) / 75))
|
||||
end
|
||||
return info and info.line or nil, opacity
|
||||
end
|
||||
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
|
||||
@@ -323,7 +429,11 @@ local function render()
|
||||
local prefix = {}
|
||||
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
|
||||
if coverPath then
|
||||
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
|
||||
local radius = coverShape == "circle" and coverSize / 2
|
||||
or coverShape == "rounded" and math.min(5, coverSize / 2)
|
||||
or coverShape == "square" and 0
|
||||
or math.min(coverRadius, coverSize / 2)
|
||||
prefix[#prefix + 1] = ui.image({ path = coverPath, width = coverSize, height = coverSize, radius = radius, fit = "cover" })
|
||||
else
|
||||
prefix[#prefix + 1] = ui.glyph({
|
||||
name = glyph,
|
||||
@@ -333,7 +443,9 @@ local function render()
|
||||
end
|
||||
|
||||
local lineNodes = {}
|
||||
if showMode == "track" then
|
||||
local renderLineGap = lineGap
|
||||
local renderTextHeight = 0
|
||||
if displayMode == "track" or showMode == "track" then
|
||||
local label = getTrackLabel()
|
||||
lineNodes[1] = buildLineRow(label, {
|
||||
key = "track",
|
||||
@@ -345,17 +457,38 @@ local function render()
|
||||
if info then
|
||||
local shownLine, opacity = currentTransitionLine(info)
|
||||
if shownLine then
|
||||
local showSecondaryRow = secondaryRowEnabled() and not shownLine.cue and not info.cue
|
||||
local secondary = showSecondaryRow and secondaryText(shownLine) or ""
|
||||
local fontScale, effectiveLineGap = compactMetrics(showSecondaryRow, vertical)
|
||||
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
|
||||
key = "current-" .. tostring(info.index),
|
||||
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
|
||||
position = position,
|
||||
useGradient = shownLine == info.line and info.synced and useGradient,
|
||||
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
|
||||
useGradient = karaokeEnabled and shownLine == info.line and info.synced and useGradient,
|
||||
solid = not karaokeEnabled or not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
|
||||
cascade = shownLine == info.line and animation == "cascade",
|
||||
wave = shownLine == info.line and animation == "wave",
|
||||
typewriter = shownLine == info.line and animation == "typewriter",
|
||||
blink = shownLine == info.line and animation == "blink",
|
||||
opacity = opacity,
|
||||
cue = shownLine == info.line and info.cue == true,
|
||||
fontScale = fontScale,
|
||||
compact = showSecondaryRow and not vertical and doubleLineAutoFit,
|
||||
})
|
||||
if showSecondaryRow then
|
||||
lineNodes[#lineNodes + 1] = buildLineRow({ text = secondary }, {
|
||||
key = "secondary-" .. tostring(info.index),
|
||||
marquee = secondary ~= "" and shouldMarquee(secondary),
|
||||
solid = true,
|
||||
dim = true,
|
||||
secondary = true,
|
||||
opacity = secondary ~= "" and math.min(0.82, opacity) or 0,
|
||||
fontScale = fontScale,
|
||||
compact = not vertical and doubleLineAutoFit,
|
||||
})
|
||||
renderLineGap = effectiveLineGap
|
||||
if not vertical and doubleLineAutoFit then renderTextHeight = doubleLineHeightBudget end
|
||||
end
|
||||
end
|
||||
|
||||
if vertical and maxLines > 1 and shownLine == info.line then
|
||||
@@ -382,20 +515,41 @@ local function render()
|
||||
if vertical then
|
||||
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
|
||||
ui.row({ gap = 6, align = "center" }, prefix),
|
||||
ui.column({ gap = 2, align = "start" }, lineNodes),
|
||||
ui.column({
|
||||
gap = renderLineGap,
|
||||
align = "start",
|
||||
justify = renderTextHeight > 0 and "center" or nil,
|
||||
height = renderTextHeight > 0 and renderTextHeight or nil,
|
||||
}, lineNodes),
|
||||
}))
|
||||
else
|
||||
local children = {}
|
||||
if paddingLeft > 0 then children[#children + 1] = ui.spacer({ width = paddingLeft }) end
|
||||
for _, node in ipairs(prefix) do children[#children + 1] = node end
|
||||
children[#children + 1] = lineNodes[1]
|
||||
children[#children + 1] = ui.column({
|
||||
gap = renderLineGap,
|
||||
align = "start",
|
||||
justify = renderTextHeight > 0 and "center" or nil,
|
||||
height = renderTextHeight > 0 and renderTextHeight or nil,
|
||||
}, lineNodes)
|
||||
if paddingRight > 0 then children[#children + 1] = ui.spacer({ width = paddingRight }) end
|
||||
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
|
||||
end
|
||||
|
||||
barWidget.setTooltip(getTrackLabel())
|
||||
local sourceNames = {
|
||||
lrclib = "LRCLIB", netease = "NetEase", qqmusic = "QQ Music", kugou = "Kugou",
|
||||
qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify",
|
||||
musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache",
|
||||
}
|
||||
local sourceLabel = noctalia.tr("source_label")
|
||||
local tooltip = getTrackLabel()
|
||||
if sourceUsed ~= "" then tooltip = tooltip .. "\n" .. sourceLabel .. ": " .. (sourceNames[sourceUsed] or sourceUsed) end
|
||||
barWidget.setTooltip(tooltip)
|
||||
rendered = true
|
||||
end
|
||||
|
||||
function onClick()
|
||||
if displayMode ~= "toggle" then return end
|
||||
showMode = showMode == "auto" and "track" or "auto"
|
||||
marqueeElapsed = 0
|
||||
if rendered then render() end
|
||||
@@ -425,6 +579,7 @@ function update()
|
||||
cover = noctalia.state.get("cover")
|
||||
playing = noctalia.state.get("playing") == true
|
||||
playerInstance = noctalia.state.get("player_instance") or ""
|
||||
sourceUsed = noctalia.state.get("lyrics_source_used") or ""
|
||||
|
||||
local info = getLineInfo()
|
||||
local nextKey = info and info.key or "fallback"
|
||||
@@ -443,7 +598,10 @@ function update()
|
||||
if playing then
|
||||
transitionElapsed = transitionElapsed + delta * 1000
|
||||
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
|
||||
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
|
||||
local secondary = showMode ~= "track" and info and secondaryText(info.line) or ""
|
||||
if shouldMarquee(text) or (secondary ~= "" and shouldMarquee(secondary)) then
|
||||
marqueeElapsed = marqueeElapsed + delta
|
||||
end
|
||||
end
|
||||
|
||||
render()
|
||||
|
||||
+229
-47
@@ -3,23 +3,38 @@
|
||||
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
|
||||
-- via /api endpoints, publishes state. No polling delay.
|
||||
|
||||
noctalia.setUpdateInterval(250)
|
||||
local updateIntervalMs = 100
|
||||
noctalia.setUpdateInterval(updateIntervalMs)
|
||||
|
||||
local cache = {}
|
||||
local coverCache = {}
|
||||
local lastTrackKey = ""
|
||||
local inFlight = nil
|
||||
local fetchGeneration = 0
|
||||
local pollInFlight = false
|
||||
local coverInFlight = nil
|
||||
local pluginDir = noctalia.pluginDir() or "/tmp"
|
||||
local cacheDir = pluginDir .. "/.cache"
|
||||
noctalia.mkdirAll(cacheDir)
|
||||
local krcTmp = cacheDir .. "/krc.tmp"
|
||||
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||
local lyricsSources = noctalia.getConfig("lyrics_sources") or {
|
||||
"lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"
|
||||
}
|
||||
local customUrl = noctalia.getConfig("custom_url") or ""
|
||||
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||
local pollIntervalMs = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
|
||||
local lyricsOffsetMs = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
|
||||
local displayMode = noctalia.getConfig("display_mode") or "toggle"
|
||||
local credentialSignature = ""
|
||||
local currentTrack = nil
|
||||
local currentEmbeddedLyrics = ""
|
||||
local currentPlayerInstance = ""
|
||||
local currentArtUrl = ""
|
||||
|
||||
for _, name in ipairs(noctalia.listDir(cacheDir) or {}) do
|
||||
if name:match("^source_request_.*%.json$") then noctalia.removeFile(cacheDir .. "/" .. name) end
|
||||
end
|
||||
|
||||
local function normalizePatterns(value)
|
||||
if type(value) ~= "table" then return {} end
|
||||
@@ -72,9 +87,11 @@ local function selectPlayer(players)
|
||||
end
|
||||
|
||||
local function clearPlayerState()
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
currentPlayerInstance = ""
|
||||
currentTrack = nil
|
||||
currentEmbeddedLyrics = ""
|
||||
currentArtUrl = ""
|
||||
lastTrackKey = ""
|
||||
inFlight = nil
|
||||
coverInFlight = nil
|
||||
@@ -86,9 +103,14 @@ local function clearPlayerState()
|
||||
noctalia.state.set("playing", false)
|
||||
end
|
||||
|
||||
local function coverPathFor(track)
|
||||
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
|
||||
return cacheDir .. "/cover_" .. safe .. ".jpg"
|
||||
local function stableHash(value)
|
||||
local hash = 5381
|
||||
for index = 1, #value do hash = (hash * 33 + value:byte(index)) % 4294967296 end
|
||||
return string.format("%08x", hash)
|
||||
end
|
||||
|
||||
local function coverPathFor(track, artUrl)
|
||||
return cacheDir .. "/cover_" .. stableHash(trackKey(track) .. "|" .. (artUrl or "")) .. ".jpg"
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
@@ -134,6 +156,46 @@ local function parsePlain(text)
|
||||
return lines
|
||||
end
|
||||
|
||||
local function credentialsFor(source)
|
||||
if source == "spotify" then
|
||||
return {
|
||||
spotify_sp_dc = noctalia.getConfig("spotify_sp_dc") or "",
|
||||
spotify_access_token = noctalia.getConfig("spotify_access_token") or "",
|
||||
}
|
||||
elseif source == "apple_music" then
|
||||
return {
|
||||
apple_developer_token = noctalia.getConfig("apple_developer_token") or "",
|
||||
apple_user_token = noctalia.getConfig("apple_user_token") or "",
|
||||
apple_storefront = noctalia.getConfig("apple_storefront") or "us",
|
||||
}
|
||||
elseif source == "musixmatch" then
|
||||
return { musixmatch_token = noctalia.getConfig("musixmatch_token") or "" }
|
||||
elseif source == "qishui" then
|
||||
return {
|
||||
qishui_token = noctalia.getConfig("qishui_token") or "",
|
||||
qishui_api_url = noctalia.getConfig("qishui_api_url") or "",
|
||||
}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
local function normalizedSources()
|
||||
if lyricsSource ~= "auto" then return { lyricsSource } end
|
||||
local result = {}
|
||||
local seen = {}
|
||||
if type(lyricsSources) == "table" then
|
||||
for _, source in ipairs(lyricsSources) do
|
||||
source = tostring(source):lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if source ~= "" and not seen[source] then
|
||||
result[#result + 1] = source
|
||||
seen[source] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
if #result == 0 then result = { "lrclib", "netease" } end
|
||||
return result
|
||||
end
|
||||
|
||||
local function evictCache()
|
||||
local keys = {}
|
||||
for k, _ in pairs(cache) do keys[#keys + 1] = k end
|
||||
@@ -145,14 +207,15 @@ end
|
||||
|
||||
local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
local tk = trackKey(track)
|
||||
if inFlight == tk then return end
|
||||
inFlight = tk
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
local flight = tk .. "|" .. tostring(fetchGeneration)
|
||||
inFlight = flight
|
||||
|
||||
local function tryFetch(query, fallback)
|
||||
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
|
||||
|
||||
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
|
||||
if inFlight ~= tk then return end
|
||||
if inFlight ~= flight then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
|
||||
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
|
||||
@@ -196,7 +259,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
|
||||
|
||||
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
|
||||
if inFlight ~= tk then return end
|
||||
if inFlight ~= flight then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
|
||||
local lyrics = nil
|
||||
@@ -218,7 +281,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
if klyricStr ~= "" then
|
||||
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
|
||||
noctalia.runAsync(py, function(r3)
|
||||
if inFlight ~= tk then return end
|
||||
if inFlight ~= flight then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
|
||||
if ok and parsed and parsed.type == "krc" and parsed.lines then
|
||||
@@ -296,7 +359,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
local function runPy(script, cb)
|
||||
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
|
||||
noctalia.runAsync(py, function(r)
|
||||
if inFlight ~= tk then return end
|
||||
if inFlight ~= flight then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
|
||||
cb(ok and parsed or nil)
|
||||
@@ -314,10 +377,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
return true
|
||||
end
|
||||
|
||||
local function fetchCustom()
|
||||
local function fetchCustom(fallback)
|
||||
if customUrl == "" then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -332,6 +394,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
end)
|
||||
|
||||
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
|
||||
if inFlight ~= flight then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local text = response.ok and response.body or ""
|
||||
if text ~= "" and customJsonField ~= "" then
|
||||
@@ -339,17 +402,25 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
for part in customJsonField:gmatch("[^.]+") do
|
||||
decoded = type(decoded) == "table" and decoded[part] or nil
|
||||
end
|
||||
if type(decoded) == "table" then
|
||||
if type(decoded) == "table" then
|
||||
cache[tk] = decoded
|
||||
evictCache()
|
||||
noctalia.state.set("lyrics", decoded)
|
||||
noctalia.state.set("lyrics_source_used", "custom")
|
||||
inFlight = nil
|
||||
return
|
||||
end
|
||||
text = type(decoded) == "string" and decoded or ""
|
||||
end
|
||||
applyText(text)
|
||||
inFlight = nil
|
||||
if applyText(text) then
|
||||
noctalia.state.set("lyrics_source_used", "custom")
|
||||
inFlight = nil
|
||||
elseif fallback then
|
||||
fallback()
|
||||
else
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -357,48 +428,98 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "mpris" then
|
||||
applyText(embeddedLyrics)
|
||||
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "custom" then
|
||||
fetchCustom()
|
||||
return
|
||||
elseif lyricsSource == "netease" then
|
||||
tryFetch(track.title .. " " .. track.artist, function()
|
||||
tryFetch(track.title, nil)
|
||||
end)
|
||||
fetchCustom(nil)
|
||||
return
|
||||
end
|
||||
|
||||
-- Auto: LRCLIB, then the public NetEase API.
|
||||
runPy("lrclib_lyric.py", function(parsed)
|
||||
if applyParsed(parsed) then inFlight = nil; return end
|
||||
if lyricsSource == "lrclib" then
|
||||
local requestPath = cacheDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
|
||||
local sources = normalizedSources()
|
||||
local request = {
|
||||
track = track,
|
||||
options = {
|
||||
translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
|
||||
},
|
||||
}
|
||||
|
||||
local function trySource(index)
|
||||
if inFlight ~= flight then return end
|
||||
local source = sources[index]
|
||||
if not source then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
return
|
||||
end
|
||||
tryFetch(track.title .. " " .. track.artist, function()
|
||||
tryFetch(track.title, nil)
|
||||
end)
|
||||
end)
|
||||
if source == "mpris" then
|
||||
if applyText(embeddedLyrics) then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics_source_used", "mpris")
|
||||
else
|
||||
trySource(index + 1)
|
||||
end
|
||||
return
|
||||
end
|
||||
if source == "custom" then
|
||||
fetchCustom(function() trySource(index + 1) end)
|
||||
return
|
||||
end
|
||||
request.source = source
|
||||
request.credentials = credentialsFor(source)
|
||||
local encoded = noctalia.json.encode(request)
|
||||
if not encoded or not noctalia.writeFile(requestPath, encoded) then
|
||||
trySource(index + 1)
|
||||
return
|
||||
end
|
||||
local command = "chmod 600 " .. shellQuote(requestPath) .. " && python3 "
|
||||
.. shellQuote(pluginDir .. "/lyric_sources.py") .. " " .. shellQuote(requestPath)
|
||||
local started = noctalia.runAsync(command, function(result)
|
||||
noctalia.removeFile(requestPath)
|
||||
if inFlight ~= flight or tk ~= lastTrackKey then return end
|
||||
local parsed = noctalia.json.decode(result.stdout or "")
|
||||
if type(parsed) == "table" and parsed.type == "lyrics" and type(parsed.lines) == "table" and #parsed.lines > 0 then
|
||||
cache[tk] = parsed.lines
|
||||
evictCache()
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", parsed.lines)
|
||||
noctalia.state.set("lyrics_source_used", parsed.source or source)
|
||||
else
|
||||
trySource(index + 1)
|
||||
end
|
||||
end, 30000)
|
||||
if not started then
|
||||
noctalia.removeFile(requestPath)
|
||||
trySource(index + 1)
|
||||
end
|
||||
end
|
||||
|
||||
trySource(1)
|
||||
end
|
||||
|
||||
local function fetchCover(track, artUrl)
|
||||
local tk = trackKey(track)
|
||||
if coverInFlight == tk then return end
|
||||
coverInFlight = tk
|
||||
local ck = tk .. "|" .. (artUrl or "")
|
||||
if coverInFlight == ck then return end
|
||||
coverInFlight = ck
|
||||
|
||||
local dest = coverPathFor(track)
|
||||
local dest = coverPathFor(track, artUrl)
|
||||
|
||||
local apply = function(path)
|
||||
if coverInFlight ~= tk then return end
|
||||
if coverInFlight ~= ck then return end
|
||||
coverInFlight = nil
|
||||
if tk ~= lastTrackKey then return end
|
||||
coverCache[tk] = path
|
||||
coverCache[ck] = path
|
||||
noctalia.state.set("cover", path)
|
||||
end
|
||||
|
||||
if noctalia.fileExists(dest) then
|
||||
apply(dest)
|
||||
return
|
||||
end
|
||||
|
||||
if not artUrl or artUrl == "" then
|
||||
noctalia.state.set("cover", nil)
|
||||
coverInFlight = nil
|
||||
@@ -417,15 +538,38 @@ local function fetchCover(track, artUrl)
|
||||
return
|
||||
end
|
||||
|
||||
noctalia.download(artUrl, dest, function(ok)
|
||||
if ok then apply(dest) else apply(nil) end
|
||||
local downloadUrl = artUrl
|
||||
local fallbackUrl = nil
|
||||
if artUrl:find("music%.126%.net", 1) then
|
||||
downloadUrl = artUrl:gsub("https?://p%d+%.music%.126%.net", "https://p1.music.126.net")
|
||||
if downloadUrl:find("[?&]param=%d+y%d+") then
|
||||
downloadUrl = downloadUrl:gsub("param=%d+y%d+", "param=400y400")
|
||||
else
|
||||
downloadUrl = downloadUrl .. (downloadUrl:find("?", 1, true) and "&" or "?") .. "param=400y400"
|
||||
end
|
||||
fallbackUrl = downloadUrl:gsub("https://p1%.music%.126%.net", "https://p3.music.126.net")
|
||||
end
|
||||
|
||||
noctalia.download(downloadUrl, dest, function(ok)
|
||||
if ok then
|
||||
apply(dest)
|
||||
elseif fallbackUrl then
|
||||
noctalia.download(fallbackUrl, dest, function(fallbackOk)
|
||||
if fallbackOk then apply(dest) else apply(nil) end
|
||||
end)
|
||||
else
|
||||
apply(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function poll()
|
||||
if pollInFlight then return end
|
||||
pollInFlight = true
|
||||
local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
|
||||
|
||||
noctalia.runAsync(cmd, function(r)
|
||||
local started = noctalia.runAsync(cmd, function(r)
|
||||
pollInFlight = false
|
||||
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
|
||||
clearPlayerState()
|
||||
return
|
||||
@@ -486,8 +630,10 @@ local function poll()
|
||||
local tk = trackKey(t)
|
||||
currentTrack = t
|
||||
currentEmbeddedLyrics = selected.embeddedLyrics
|
||||
local previousArtUrl = currentArtUrl
|
||||
currentArtUrl = selected.artUrl
|
||||
|
||||
noctalia.state.set("position", t.position)
|
||||
noctalia.state.set("position", t.position + lyricsOffsetMs * 1000)
|
||||
|
||||
if tk ~= lastTrackKey then
|
||||
lastTrackKey = tk
|
||||
@@ -495,14 +641,19 @@ local function poll()
|
||||
noctalia.state.set("playing", playing)
|
||||
|
||||
local cached = cache[tk]
|
||||
if cached then
|
||||
if displayMode == "track" then
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
elseif cached then
|
||||
noctalia.state.set("lyrics", cached)
|
||||
noctalia.state.set("lyrics_source_used", "cache")
|
||||
else
|
||||
noctalia.state.set("lyrics", nil)
|
||||
fetchLyricsNetEase(t, selected.embeddedLyrics)
|
||||
end
|
||||
|
||||
local cachedCover = coverCache[tk]
|
||||
local coverKey = tk .. "|" .. selected.artUrl
|
||||
local cachedCover = coverCache[coverKey]
|
||||
if cachedCover then
|
||||
noctalia.state.set("cover", cachedCover)
|
||||
else
|
||||
@@ -512,16 +663,22 @@ local function poll()
|
||||
else
|
||||
noctalia.state.set("track", t)
|
||||
noctalia.state.set("playing", playing)
|
||||
if selected.artUrl ~= previousArtUrl then
|
||||
noctalia.state.set("cover", nil)
|
||||
fetchCover(t, selected.artUrl)
|
||||
end
|
||||
end
|
||||
end)
|
||||
if not started then pollInFlight = false end
|
||||
end
|
||||
|
||||
local pollTick = 0
|
||||
local pollElapsedMs = pollIntervalMs
|
||||
|
||||
function update()
|
||||
if lyricsSource == "external" then return end
|
||||
pollTick = pollTick + 1
|
||||
if pollTick % 2 == 0 then
|
||||
pollElapsedMs = pollElapsedMs + updateIntervalMs
|
||||
if pollElapsedMs >= pollIntervalMs then
|
||||
pollElapsedMs = 0
|
||||
poll()
|
||||
end
|
||||
|
||||
@@ -531,26 +688,51 @@ function onConfigChanged()
|
||||
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||
local nextUrl = noctalia.getConfig("custom_url") or ""
|
||||
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||
local nextSources = noctalia.getConfig("lyrics_sources") or lyricsSources
|
||||
local nextPollInterval = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
|
||||
local nextOffset = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
|
||||
local nextDisplayMode = noctalia.getConfig("display_mode") or "toggle"
|
||||
local nextCredentialSignature = table.concat({
|
||||
noctalia.getConfig("spotify_sp_dc") or "",
|
||||
noctalia.getConfig("spotify_access_token") or "",
|
||||
noctalia.getConfig("apple_developer_token") or "",
|
||||
noctalia.getConfig("apple_user_token") or "",
|
||||
noctalia.getConfig("apple_storefront") or "us",
|
||||
noctalia.getConfig("musixmatch_token") or "",
|
||||
noctalia.getConfig("qishui_token") or "",
|
||||
noctalia.getConfig("qishui_api_url") or "",
|
||||
noctalia.getConfig("translation_language") or "zh-Hans",
|
||||
}, "\0")
|
||||
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
|
||||
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
|
||||
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
|
||||
or table.concat(nextSources, "\n") ~= table.concat(lyricsSources, "\n")
|
||||
or nextCredentialSignature ~= credentialSignature
|
||||
or nextDisplayMode ~= displayMode
|
||||
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
|
||||
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
|
||||
lyricsSource = nextSource
|
||||
customUrl = nextUrl
|
||||
customJsonField = nextField
|
||||
lyricsSources = nextSources
|
||||
pollIntervalMs = math.max(100, nextPollInterval)
|
||||
lyricsOffsetMs = nextOffset
|
||||
displayMode = nextDisplayMode
|
||||
credentialSignature = nextCredentialSignature
|
||||
playerAllowlist = nextAllowlist
|
||||
playerBlocklist = nextBlocklist
|
||||
if playersChanged then
|
||||
currentPlayerInstance = ""
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
inFlight = nil
|
||||
poll()
|
||||
end
|
||||
if sourceChanged and currentTrack and not playersChanged then
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
inFlight = nil
|
||||
cache = {}
|
||||
noctalia.state.set("lyrics", nil)
|
||||
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
|
||||
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -572,7 +754,7 @@ local function applyPushedLyrics(payload)
|
||||
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
|
||||
noctalia.state.set("lyrics", parsed)
|
||||
end
|
||||
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
|
||||
if decoded.position ~= nil then noctalia.state.set("position", decoded.position + lyricsOffsetMs * 1000) end
|
||||
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
|
||||
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
|
||||
return true
|
||||
|
||||
+331
-2
@@ -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 |
@@ -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
@@ -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." }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user