feat: add lyrics plugin
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# Lyrics
|
||||||
|
|
||||||
|
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke
|
||||||
|
highlighting, animated line changes, and configurable online or local sources.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `h465855hgg/lyrics` |
|
||||||
|
| Entries | Bar widget: `lyrics`; service: `service` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install `playerctl`, `python3`, `curl`, and `cp` on `PATH`. The active media
|
||||||
|
player must expose MPRIS metadata for automatic track and playback detection.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
|
||||||
|
settings. The background `service` detects the active MPRIS player, resolves
|
||||||
|
lyrics, downloads or caches album artwork, and publishes playback state to the
|
||||||
|
widget.
|
||||||
|
|
||||||
|
Left-click the widget to switch between lyrics and track information. Right-click
|
||||||
|
to pause or resume the active player. Paused content is dimmed and all lyric,
|
||||||
|
transition, and marquee animation stops until playback resumes.
|
||||||
|
|
||||||
|
When synchronized lyrics are unavailable, the widget displays
|
||||||
|
`track title + artist`. Long lines pause at each end while scrolling. Intro and
|
||||||
|
instrumental gaps can show a configurable cue such as `•••••`.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
|
||||||
|
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
|
||||||
|
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
|
||||||
|
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
|
||||||
|
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
|
||||||
|
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
|
||||||
|
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
|
||||||
|
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
|
||||||
|
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
|
||||||
|
| `max_chars` | `int` | `24` | Number of visible Unicode characters before marquee scrolling starts. |
|
||||||
|
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
|
||||||
|
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
|
||||||
|
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
|
||||||
|
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
|
||||||
|
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
External players can set `lyrics_source` to `external` and address the singleton
|
||||||
|
service with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg plugin h465855hgg/lyrics:service all <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.
|
||||||
|
|
||||||
|
Line timestamps and character timestamps are milliseconds. MPRIS track duration
|
||||||
|
and playback position are microseconds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"lines":[{"time":1200,"duration":1800,"text":"Hello","chars":[1200,1500,1800,2100,2400]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
|
||||||
|
HTTP mode contacts only the configured endpoint. The plugin never reads browser
|
||||||
|
cookies or player credentials.
|
||||||
|
|
||||||
|
The service runs `playerctl` to read and control MPRIS playback, `python3` for the
|
||||||
|
LRCLIB helper and dynamic-lyric parser, `curl` for the public NetEase API, and
|
||||||
|
`cp` to preserve temporary local cover files. Query scratch files and downloaded
|
||||||
|
cover images are written inside the plugin runtime directory. Remote code is
|
||||||
|
never downloaded or executed.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Decode NetEase KRC ("klyric") dynamic lyrics into per-character timings.
|
||||||
|
# Reads the klyric field (base64 of "krc1" + zlib stream) from argv[1],
|
||||||
|
# writes JSON: {"type":"krc","lines":[{"time":ms,"text":"...","chars":[ms,...]}]}
|
||||||
|
import sys, json, base64, zlib, re
|
||||||
|
|
||||||
|
def find_zlib(buf):
|
||||||
|
for i in range(len(buf) - 1):
|
||||||
|
if buf[i] == 0x78 and buf[i + 1] in (0x01, 0x9c, 0xda):
|
||||||
|
try:
|
||||||
|
return zlib.decompress(buf[i:])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def decode_krc(raw):
|
||||||
|
if isinstance(raw, str) and re.search(r"^\[\d+,\d+\]", raw, re.MULTILINE):
|
||||||
|
return raw
|
||||||
|
data = None
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(raw)
|
||||||
|
except Exception:
|
||||||
|
data = raw.encode("latin-1")
|
||||||
|
else:
|
||||||
|
data = raw
|
||||||
|
if data[:4] == b"krc1":
|
||||||
|
data = data[4:]
|
||||||
|
dec = find_zlib(data)
|
||||||
|
if dec is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dec.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return dec.decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
LINE_RE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
|
||||||
|
PREFIX_SYL_RE = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
|
||||||
|
SUFFIX_SYL_RE = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>")
|
||||||
|
|
||||||
|
def parse_krc(text):
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = LINE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
start = int(m.group(1))
|
||||||
|
dur = int(m.group(2))
|
||||||
|
body = m.group(3)
|
||||||
|
# NetEase has shipped both `(offset,duration,0)word` and
|
||||||
|
# `word<offset,duration>` variants of its word-synced format.
|
||||||
|
prefixed = PREFIX_SYL_RE.findall(body) if re.match(r"^[<(]\d+,", body) else []
|
||||||
|
if prefixed:
|
||||||
|
syl = [(word, int(offset), int(duration))
|
||||||
|
for offset, duration, word in prefixed]
|
||||||
|
else:
|
||||||
|
syl = [(word, int(offset), int(duration))
|
||||||
|
for word, offset, duration in SUFFIX_SYL_RE.findall(body)]
|
||||||
|
if not syl and body.strip() != "":
|
||||||
|
syl = [(body, 0, dur)]
|
||||||
|
chars = []
|
||||||
|
full = ""
|
||||||
|
for (word, so, sd) in syl:
|
||||||
|
n = len(word)
|
||||||
|
if n == 0:
|
||||||
|
continue
|
||||||
|
for j in range(n):
|
||||||
|
chars.append(start + so + (j * sd) // n)
|
||||||
|
full += word[j]
|
||||||
|
if full.strip() != "":
|
||||||
|
out.append({"time": start, "duration": dur, "text": full, "chars": chars})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"type": "none"}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
except Exception:
|
||||||
|
print(json.dumps({"type": "none"}))
|
||||||
|
return
|
||||||
|
text = decode_krc(raw)
|
||||||
|
if text is None:
|
||||||
|
print(json.dumps({"type": "none"}))
|
||||||
|
return
|
||||||
|
print(json.dumps({"type": "krc", "lines": parse_krc(text)}, ensure_ascii=False))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys, os, json, urllib.request, urllib.parse
|
||||||
|
|
||||||
|
LRCLIB = "https://lrclib.net/api/search"
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s):
|
||||||
|
return "".join(ch for ch in (s or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(url):
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"User-Agent": "lyrics-plugin/1.0",
|
||||||
|
"Accept": "application/json",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return r.read().decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
raw = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
title, artist, album = "test", "", ""
|
||||||
|
if raw and os.path.isfile(raw):
|
||||||
|
try:
|
||||||
|
with open(raw, encoding="utf-8") as f:
|
||||||
|
lines = [l.strip() for l in f.read().splitlines()]
|
||||||
|
title = lines[0] if lines else "test"
|
||||||
|
artist = lines[1] if len(lines) > 1 else ""
|
||||||
|
album = lines[2] if len(lines) > 2 else ""
|
||||||
|
except Exception as e:
|
||||||
|
out = {"type": "none", "lines": [], "lrc": "", "diag": [f"query_file_read_err={e!r}"]}
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
title = raw or "test"
|
||||||
|
|
||||||
|
out = {"type": "none", "lines": [], "lrc": "", "diag": []}
|
||||||
|
try:
|
||||||
|
q = urllib.parse.urlencode({"track_name": title, "artist_name": artist or title})
|
||||||
|
s = json.loads(http_get(LRCLIB + "?" + q))
|
||||||
|
if not s:
|
||||||
|
out["diag"].append("lrclib: no results")
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
nart = norm(artist)
|
||||||
|
nalb = norm(album)
|
||||||
|
ntitle = norm(title)
|
||||||
|
best = None
|
||||||
|
for c in s:
|
||||||
|
cn = norm(c.get("trackName", ""))
|
||||||
|
ca = norm(c.get("artistName", ""))
|
||||||
|
if ntitle and cn != ntitle and ntitle not in cn and cn not in ntitle:
|
||||||
|
continue
|
||||||
|
if nart and ca and (nart in ca or ca in nart):
|
||||||
|
best = c
|
||||||
|
break
|
||||||
|
if nalb and norm(c.get("albumName", "")) == nalb:
|
||||||
|
best = c
|
||||||
|
break
|
||||||
|
if best is None:
|
||||||
|
best = c
|
||||||
|
if best is None:
|
||||||
|
best = s[0]
|
||||||
|
|
||||||
|
lrc = best.get("syncedLyrics") or best.get("plainLyrics") or ""
|
||||||
|
if not lrc:
|
||||||
|
out["diag"].append("lrclib: empty lyrics")
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
out["lrc"] = lrc
|
||||||
|
out["type"] = "lrc"
|
||||||
|
out["diag"].append(f"lrclib: {best.get('trackName')} / {best.get('artistName')} synced={bool(best.get('syncedLyrics'))}")
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
out["diag"].append(f"lrclib ERR: {e!r}")
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- Synchronized lyrics bar widget with karaoke highlighting and long-line scrolling.
|
||||||
|
|
||||||
|
noctalia.setUpdateInterval(33)
|
||||||
|
|
||||||
|
local glyph = noctalia.getConfig("glyph")
|
||||||
|
local showArtist = noctalia.getConfig("show_artist")
|
||||||
|
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
|
||||||
|
local showCover = noctalia.getConfig("show_cover")
|
||||||
|
if showCover == nil then showCover = true end
|
||||||
|
|
||||||
|
-- These are plugin settings, not per-widget settings.
|
||||||
|
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
|
||||||
|
local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30
|
||||||
|
local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1
|
||||||
|
local maxChars = tonumber(noctalia.getConfig("max_chars")) or 24
|
||||||
|
local charWidth = tonumber(noctalia.getConfig("char_width")) or 9
|
||||||
|
local gradientOn = noctalia.getConfig("gradient")
|
||||||
|
if gradientOn == nil then gradientOn = true end
|
||||||
|
local animation = noctalia.getConfig("animation") or "karaoke"
|
||||||
|
local cueText = noctalia.getConfig("cue_text") or "•••••"
|
||||||
|
if cueText == "" then cueText = "•••••" end
|
||||||
|
|
||||||
|
local track = nil
|
||||||
|
local lyrics = nil
|
||||||
|
local cover = nil
|
||||||
|
local playing = false
|
||||||
|
local showMode = "auto"
|
||||||
|
local rendered = false
|
||||||
|
|
||||||
|
local baselinePosition = 0
|
||||||
|
local baselineClock = os.clock()
|
||||||
|
local lastClock = 0
|
||||||
|
local displayKey = ""
|
||||||
|
local displayedLine = nil
|
||||||
|
local outgoingLine = nil
|
||||||
|
local transitionElapsed = 0
|
||||||
|
local marqueeElapsed = 0
|
||||||
|
local smoothProgress = 0
|
||||||
|
|
||||||
|
local FADE_OUT_MS = 130
|
||||||
|
local FADE_IN_MS = 260
|
||||||
|
local MARQUEE_HOLD = 1.1
|
||||||
|
local BLEND_MS = 350
|
||||||
|
local INTERLUDE_GAP_MS = 7000
|
||||||
|
local INTRO_MIN_MS = 6000
|
||||||
|
local INTERLUDE_MIN_MS = 8000
|
||||||
|
|
||||||
|
local function clamp(value, low, high)
|
||||||
|
return math.min(high, math.max(low, value))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function easeOutCubic(t)
|
||||||
|
local u = 1 - clamp(t, 0, 1)
|
||||||
|
return 1 - u * u * u
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toChars(text)
|
||||||
|
local chars = {}
|
||||||
|
local i = 1
|
||||||
|
while i <= #text do
|
||||||
|
local byte = text:byte(i)
|
||||||
|
local length = 1
|
||||||
|
if byte >= 0xF0 then length = 4
|
||||||
|
elseif byte >= 0xE0 then length = 3
|
||||||
|
elseif byte >= 0xC0 then length = 2 end
|
||||||
|
chars[#chars + 1] = text:sub(i, i + length - 1)
|
||||||
|
i = i + length
|
||||||
|
end
|
||||||
|
return chars
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getProgressMs()
|
||||||
|
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
|
||||||
|
return baselinePosition / 1000 + elapsed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getLineInfo()
|
||||||
|
if not lyrics or #lyrics == 0 or not track then return nil end
|
||||||
|
|
||||||
|
local position = getProgressMs()
|
||||||
|
local synced = lyrics[1].time >= 0
|
||||||
|
if not synced then
|
||||||
|
return { key = "plain", index = 1, line = lyrics[1], progress = 0, synced = false }
|
||||||
|
end
|
||||||
|
|
||||||
|
local firstTime = lyrics[1].time
|
||||||
|
if firstTime >= INTRO_MIN_MS and position < firstTime then
|
||||||
|
return {
|
||||||
|
key = "intro",
|
||||||
|
index = 0,
|
||||||
|
line = { text = cueText },
|
||||||
|
progress = clamp(position / firstTime, 0, 1),
|
||||||
|
synced = true,
|
||||||
|
position = position,
|
||||||
|
cue = true,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local index = 1
|
||||||
|
for i = #lyrics, 1, -1 do
|
||||||
|
if lyrics[i].time <= position then
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local line = lyrics[index]
|
||||||
|
local startTime = line.time
|
||||||
|
local durationMs = track.duration and track.duration > 0 and track.duration / 1000 or nil
|
||||||
|
local nextLine = lyrics[index + 1]
|
||||||
|
local nextTime = nextLine and nextLine.time
|
||||||
|
or durationMs
|
||||||
|
or (startTime + 4000)
|
||||||
|
local lineEnd = nextTime
|
||||||
|
|
||||||
|
if line.duration and line.duration > 0 then
|
||||||
|
lineEnd = math.min(nextTime, startTime + line.duration)
|
||||||
|
elseif nextTime - startTime >= INTERLUDE_GAP_MS then
|
||||||
|
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap.
|
||||||
|
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
|
||||||
|
lineEnd = math.min(nextTime, startTime + estimatedDuration)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only show an interlude between two real lyric lines. The final lyric stays
|
||||||
|
-- visible through the outro instead of turning the whole song ending into dots.
|
||||||
|
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then
|
||||||
|
return {
|
||||||
|
key = "interlude-" .. tostring(index),
|
||||||
|
index = index,
|
||||||
|
line = { text = cueText },
|
||||||
|
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
|
||||||
|
synced = true,
|
||||||
|
position = position,
|
||||||
|
cue = true,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local progress = lineEnd > startTime and clamp((position - startTime) / (lineEnd - startTime), 0, 1) or 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
key = "line-" .. tostring(index),
|
||||||
|
index = index,
|
||||||
|
line = line,
|
||||||
|
progress = progress,
|
||||||
|
synced = true,
|
||||||
|
position = position,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getTrackLabel()
|
||||||
|
if not track then return "--" end
|
||||||
|
if not showArtist then return track.title end
|
||||||
|
if track.artist and track.artist ~= "" then
|
||||||
|
return track.artist .. " - " .. track.title
|
||||||
|
end
|
||||||
|
return track.title
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getFallbackLabel()
|
||||||
|
if not track then return "--" end
|
||||||
|
if track.artist and track.artist ~= "" then
|
||||||
|
return track.title .. " + " .. track.artist
|
||||||
|
end
|
||||||
|
return track.title
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shouldMarquee(text)
|
||||||
|
return scrollMode ~= "static" and #toChars(text) > maxChars
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getMarqueeOffset(length)
|
||||||
|
local distance = math.max(0, length - maxChars)
|
||||||
|
if distance == 0 then return 0 end
|
||||||
|
|
||||||
|
-- The setting is pixels per second; convert it to character cells per second.
|
||||||
|
local charsPerSecond = math.max(0.5, marqueeSpeed / math.max(1, charWidth))
|
||||||
|
local travelTime = distance / charsPerSecond
|
||||||
|
local cycle = MARQUEE_HOLD * 2 + travelTime * 2
|
||||||
|
local phase = marqueeElapsed % cycle
|
||||||
|
|
||||||
|
if phase < MARQUEE_HOLD then return 0 end
|
||||||
|
phase = phase - MARQUEE_HOLD
|
||||||
|
if phase < travelTime then return distance * (phase / travelTime) end
|
||||||
|
phase = phase - travelTime
|
||||||
|
if phase < MARQUEE_HOLD then return distance end
|
||||||
|
phase = phase - MARQUEE_HOLD
|
||||||
|
return distance * (1 - phase / travelTime)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function karaokeColor(charTime, charProgress, position, useGradient)
|
||||||
|
if not playing then return "on_surface_variant" end
|
||||||
|
local active = noctalia.isDarkMode() and "primary" or "on_surface"
|
||||||
|
local inactive = "on_surface_variant"
|
||||||
|
if charTime and position then
|
||||||
|
if not useGradient then
|
||||||
|
return position >= charTime and active or inactive
|
||||||
|
end
|
||||||
|
if position >= charTime then return active end
|
||||||
|
local distance = charTime - position
|
||||||
|
if distance >= BLEND_MS then return inactive end
|
||||||
|
local alpha = 1 - distance / BLEND_MS
|
||||||
|
if alpha <= 0.5 then return inactive end
|
||||||
|
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
|
||||||
|
end
|
||||||
|
|
||||||
|
if not useGradient then
|
||||||
|
return smoothProgress >= charProgress and active or inactive
|
||||||
|
end
|
||||||
|
local distance = charProgress - smoothProgress
|
||||||
|
if distance <= 0 then return active end
|
||||||
|
if distance >= 0.16 then return inactive end
|
||||||
|
local alpha = 1 - distance / 0.16
|
||||||
|
if alpha <= 0.5 then return inactive end
|
||||||
|
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildLineRow(line, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local text = type(line) == "table" and line.text or line
|
||||||
|
local times = type(line) == "table" and line.chars or nil
|
||||||
|
local chars = toChars(text or "")
|
||||||
|
if #chars == 0 then chars = { " " } end
|
||||||
|
|
||||||
|
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
|
||||||
|
local offset = math.floor(exactOffset)
|
||||||
|
local fraction = exactOffset - offset
|
||||||
|
local first = offset + 1
|
||||||
|
local last = math.min(#chars, first + maxChars)
|
||||||
|
local labels = {}
|
||||||
|
|
||||||
|
for sourceIndex = first, last do
|
||||||
|
local active = playing and (noctalia.isDarkMode() and "primary" or "on_surface") or "on_surface_variant"
|
||||||
|
local color = opts.dim and "on_surface_variant" or active
|
||||||
|
if not opts.solid and not opts.dim then
|
||||||
|
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
|
||||||
|
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Soft viewport edges make cell-by-cell marquee movement less abrupt.
|
||||||
|
local alpha = 1
|
||||||
|
local edgeAlpha = noctalia.isDarkMode() and 0.5 or 0.72
|
||||||
|
if opts.marquee and offset > 0 and sourceIndex == first then alpha = edgeAlpha end
|
||||||
|
if opts.marquee and last < #chars and sourceIndex == last then alpha = edgeAlpha end
|
||||||
|
if opts.cascade then
|
||||||
|
local charProgress = (sourceIndex - first) / math.max(1, last - first)
|
||||||
|
local cascadeProgress = clamp((transitionElapsed - FADE_OUT_MS) / 420, 0, 1)
|
||||||
|
alpha = alpha * clamp((cascadeProgress - charProgress * 0.45) * 2.4, 0.08, 1)
|
||||||
|
end
|
||||||
|
if opts.wave then
|
||||||
|
local waveProgress = clamp((transitionElapsed - FADE_OUT_MS) / 650, 0, 1)
|
||||||
|
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
|
||||||
|
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bar labels elide glyphs when constrained below their natural width, so
|
||||||
|
-- animate the viewport edges with opacity without clipping CJK characters.
|
||||||
|
if opts.marquee and fraction > 0 then
|
||||||
|
if sourceIndex == first then
|
||||||
|
alpha = alpha * (1 - fraction)
|
||||||
|
elseif sourceIndex == first + maxChars then
|
||||||
|
alpha = alpha * fraction
|
||||||
|
end
|
||||||
|
elseif sourceIndex == first + maxChars then
|
||||||
|
alpha = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
labels[#labels + 1] = ui.label({
|
||||||
|
key = "char-" .. tostring(sourceIndex),
|
||||||
|
text = chars[sourceIndex],
|
||||||
|
color = color,
|
||||||
|
opacity = alpha,
|
||||||
|
maxLines = 1,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.marquee then
|
||||||
|
while #labels < maxChars + 1 do
|
||||||
|
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return ui.row({
|
||||||
|
key = opts.key or "line",
|
||||||
|
gap = 0,
|
||||||
|
align = "center",
|
||||||
|
minWidth = maxChars * charWidth,
|
||||||
|
opacity = opts.opacity or 1,
|
||||||
|
}, labels)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentTransitionLine(info)
|
||||||
|
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
|
||||||
|
local opacity = animation == "none" and 1
|
||||||
|
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
|
||||||
|
return info and info.line or nil, opacity
|
||||||
|
end
|
||||||
|
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
if hideWhenPaused and not playing then
|
||||||
|
barWidget.setVisible(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
barWidget.setVisible(true)
|
||||||
|
|
||||||
|
local vertical = barWidget.isVertical()
|
||||||
|
local useGradient = (animation == "karaoke" or animation == "cascade" or animation == "wave") and gradientOn
|
||||||
|
local position = getProgressMs()
|
||||||
|
local prefix = {}
|
||||||
|
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
|
||||||
|
if coverPath then
|
||||||
|
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
|
||||||
|
else
|
||||||
|
prefix[#prefix + 1] = ui.glyph({
|
||||||
|
name = glyph,
|
||||||
|
size = 14,
|
||||||
|
color = playing and (noctalia.isDarkMode() and "primary" or "on_surface") or "on_surface_variant",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local lineNodes = {}
|
||||||
|
if showMode == "track" then
|
||||||
|
local label = getTrackLabel()
|
||||||
|
lineNodes[1] = buildLineRow(label, {
|
||||||
|
key = "track",
|
||||||
|
marquee = shouldMarquee(label),
|
||||||
|
solid = true,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
local info = getLineInfo()
|
||||||
|
if info then
|
||||||
|
local shownLine, opacity = currentTransitionLine(info)
|
||||||
|
if shownLine then
|
||||||
|
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
|
||||||
|
key = "current-" .. tostring(info.index),
|
||||||
|
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
|
||||||
|
position = position,
|
||||||
|
useGradient = shownLine == info.line and info.synced and useGradient,
|
||||||
|
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
|
||||||
|
cascade = shownLine == info.line and animation == "cascade",
|
||||||
|
wave = shownLine == info.line and animation == "wave",
|
||||||
|
opacity = opacity,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if vertical and maxLines > 1 and shownLine == info.line then
|
||||||
|
for i = 1, maxLines - 1 do
|
||||||
|
local nextLine = lyrics[info.index + i]
|
||||||
|
if not nextLine then break end
|
||||||
|
lineNodes[#lineNodes + 1] = buildLineRow(nextLine, {
|
||||||
|
key = "next-" .. tostring(info.index + i),
|
||||||
|
dim = true,
|
||||||
|
opacity = math.max(0.25, 0.58 - (i - 1) * 0.16),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local label = getFallbackLabel()
|
||||||
|
lineNodes[1] = buildLineRow(label, {
|
||||||
|
key = "fallback",
|
||||||
|
marquee = shouldMarquee(label),
|
||||||
|
solid = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if vertical then
|
||||||
|
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
|
||||||
|
ui.row({ gap = 6, align = "center" }, prefix),
|
||||||
|
ui.column({ gap = 2, align = "start" }, lineNodes),
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
local children = {}
|
||||||
|
for _, node in ipairs(prefix) do children[#children + 1] = node end
|
||||||
|
children[#children + 1] = lineNodes[1]
|
||||||
|
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
|
||||||
|
end
|
||||||
|
|
||||||
|
barWidget.setTooltip(getTrackLabel())
|
||||||
|
rendered = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
showMode = showMode == "auto" and "track" or "auto"
|
||||||
|
marqueeElapsed = 0
|
||||||
|
if rendered then render() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
noctalia.runAsync("playerctl play-pause", function(result)
|
||||||
|
if result.exitCode == 0 then
|
||||||
|
local wasPlaying = playing
|
||||||
|
if wasPlaying then baselinePosition = getProgressMs() * 1000 end
|
||||||
|
playing = not wasPlaying
|
||||||
|
noctalia.state.set("playing", playing)
|
||||||
|
baselineClock = os.clock()
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function update()
|
||||||
|
local now = os.clock()
|
||||||
|
local delta = lastClock > 0 and now - lastClock or 0
|
||||||
|
lastClock = now
|
||||||
|
|
||||||
|
track = noctalia.state.get("track")
|
||||||
|
lyrics = noctalia.state.get("lyrics")
|
||||||
|
cover = noctalia.state.get("cover")
|
||||||
|
playing = noctalia.state.get("playing") == true
|
||||||
|
|
||||||
|
local info = getLineInfo()
|
||||||
|
local nextKey = info and info.key or "fallback"
|
||||||
|
if nextKey ~= displayKey then
|
||||||
|
outgoingLine = displayedLine
|
||||||
|
displayKey = nextKey
|
||||||
|
transitionElapsed = animation == "none" and FADE_OUT_MS + FADE_IN_MS or 0
|
||||||
|
marqueeElapsed = 0
|
||||||
|
smoothProgress = info and info.progress or 0
|
||||||
|
else
|
||||||
|
local target = info and info.progress or 0
|
||||||
|
smoothProgress = smoothProgress + (target - smoothProgress) * math.min(1, delta * 8)
|
||||||
|
end
|
||||||
|
displayedLine = info and info.line or nil
|
||||||
|
|
||||||
|
if playing then
|
||||||
|
transitionElapsed = transitionElapsed + delta * 1000
|
||||||
|
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
|
||||||
|
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
|
||||||
|
end
|
||||||
|
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch("position", function(value)
|
||||||
|
baselinePosition = tonumber(value) or 0
|
||||||
|
baselineClock = os.clock()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("lyrics", function(value)
|
||||||
|
lyrics = value
|
||||||
|
displayKey = ""
|
||||||
|
displayedLine = nil
|
||||||
|
outgoingLine = nil
|
||||||
|
marqueeElapsed = 0
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("playing", function(value)
|
||||||
|
-- Preserve the interpolated position when pausing between service polls.
|
||||||
|
if playing and value ~= true then
|
||||||
|
baselinePosition = getProgressMs() * 1000
|
||||||
|
end
|
||||||
|
playing = value == true
|
||||||
|
baselineClock = os.clock()
|
||||||
|
end)
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- Lyrics — headless service.
|
||||||
|
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
|
||||||
|
-- via /api endpoints, publishes state. No polling delay.
|
||||||
|
|
||||||
|
noctalia.setUpdateInterval(250)
|
||||||
|
|
||||||
|
local cache = {}
|
||||||
|
local coverCache = {}
|
||||||
|
local lastTrackKey = ""
|
||||||
|
local inFlight = nil
|
||||||
|
local coverInFlight = nil
|
||||||
|
local krcTmp = (noctalia.pluginDir() or "/tmp") .. "/.krc_cache.tmp"
|
||||||
|
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||||
|
local customUrl = noctalia.getConfig("custom_url") or ""
|
||||||
|
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||||
|
local currentTrack = nil
|
||||||
|
local currentEmbeddedLyrics = ""
|
||||||
|
|
||||||
|
local function coverPathFor(track)
|
||||||
|
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
|
||||||
|
return noctalia.pluginDir() .. "/cover_" .. safe .. ".jpg"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellQuote(value)
|
||||||
|
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseLRC(lrcText)
|
||||||
|
local lines = {}
|
||||||
|
for line in lrcText:gmatch("[^\n]+") do
|
||||||
|
local mins, secs = line:match("%[(%d+):(%d+%.?%d*)%]")
|
||||||
|
if mins and secs then
|
||||||
|
local ms = math.floor(tonumber(mins) * 60000 + tonumber(secs) * 1000)
|
||||||
|
local text = line:gsub("%[%d+:%d+%.?%d*%]", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if text ~= "" then
|
||||||
|
local isMeta = ms < 5000 and (text:find(":") or text:find(":"))
|
||||||
|
if not isMeta then
|
||||||
|
lines[#lines + 1] = { time = ms, text = text }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #lines == 0 then return nil end
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parsePlain(text)
|
||||||
|
local lines = {}
|
||||||
|
local seenLyric = false
|
||||||
|
for line in text:gmatch("[^\n]+") do
|
||||||
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if trimmed ~= "" then
|
||||||
|
if seenLyric then
|
||||||
|
lines[#lines + 1] = { time = -1, text = trimmed }
|
||||||
|
elseif trimmed:find(":") or trimmed:find(":") then
|
||||||
|
-- skip metadata header line
|
||||||
|
else
|
||||||
|
seenLyric = true
|
||||||
|
lines[#lines + 1] = { time = -1, text = trimmed }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #lines == 0 then return nil end
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
local function evictCache()
|
||||||
|
local keys = {}
|
||||||
|
for k, _ in pairs(cache) do keys[#keys + 1] = k end
|
||||||
|
if #keys > 30 then
|
||||||
|
table.sort(keys)
|
||||||
|
for i = 1, #keys - 30 do cache[keys[i]] = nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||||
|
if inFlight then return end
|
||||||
|
inFlight = track
|
||||||
|
|
||||||
|
local tk = track.title .. "|" .. track.artist .. "|" .. track.album
|
||||||
|
|
||||||
|
local function tryFetch(query, fallback)
|
||||||
|
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
|
||||||
|
local cmd = 'curl -s --max-time 10 --connect-timeout 5 "' .. searchUrl .. '" -H "Referer: https://music.163.com"'
|
||||||
|
|
||||||
|
noctalia.runAsync(cmd, function(r1)
|
||||||
|
if not inFlight then return end
|
||||||
|
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||||
|
|
||||||
|
if r1.exitCode ~= 0 or not r1.stdout or #r1.stdout == 0 then
|
||||||
|
if fallback then fallback()
|
||||||
|
else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local data = noctalia.json.decode(r1.stdout)
|
||||||
|
if not data or not data.result or not data.result.songs or #data.result.songs == 0 then
|
||||||
|
if fallback then fallback()
|
||||||
|
else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local function songArtist(s)
|
||||||
|
if s.artists and #s.artists > 0 then
|
||||||
|
return (s.artists[1].name or ""):lower()
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local bestMatch = nil
|
||||||
|
local trackArtist = track.artist:lower()
|
||||||
|
for _, s in ipairs(data.result.songs) do
|
||||||
|
if songArtist(s):find(trackArtist, 1, true) then
|
||||||
|
bestMatch = s
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not bestMatch then
|
||||||
|
bestMatch = data.result.songs[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
local songId = bestMatch.id
|
||||||
|
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. songId .. "&lv=1&kv=1&tv=-1"
|
||||||
|
local lCmd = 'curl -s --max-time 10 --connect-timeout 5 "' .. lyricUrl .. '" -H "Referer: https://music.163.com"'
|
||||||
|
|
||||||
|
noctalia.runAsync(lCmd, function(r2)
|
||||||
|
if not inFlight then return end
|
||||||
|
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||||
|
|
||||||
|
local lyrics = nil
|
||||||
|
if r2.exitCode == 0 and r2.stdout and #r2.stdout > 0 then
|
||||||
|
local ldata = noctalia.json.decode(r2.stdout)
|
||||||
|
local klyricStr = ""
|
||||||
|
if ldata and ldata.klyric then
|
||||||
|
local k = ldata.klyric
|
||||||
|
if type(k) == "string" then
|
||||||
|
klyricStr = k
|
||||||
|
elseif type(k) == "table" then
|
||||||
|
klyricStr = (k.lyric and type(k.lyric) == "string") and k.lyric or ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if klyricStr ~= "" then
|
||||||
|
local ok = pcall(noctalia.writeFile, krcTmp, klyricStr)
|
||||||
|
if not ok then klyricStr = "" end
|
||||||
|
end
|
||||||
|
if klyricStr ~= "" then
|
||||||
|
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
|
||||||
|
noctalia.runAsync(py, function(r3)
|
||||||
|
if not inFlight then return end
|
||||||
|
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||||
|
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
|
||||||
|
if ok and parsed and parsed.type == "krc" and parsed.lines then
|
||||||
|
cache[tk] = parsed.lines
|
||||||
|
evictCache()
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", parsed.lines)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lrc = (ldata.lrc and ldata.lrc.lyric) or ""
|
||||||
|
local lyr = parseLRC(lrc)
|
||||||
|
if not lyr then lyr = parsePlain(lrc) end
|
||||||
|
if lyr then
|
||||||
|
cache[tk] = lyr
|
||||||
|
evictCache()
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", lyr)
|
||||||
|
elseif fallback then
|
||||||
|
fallback()
|
||||||
|
else
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if ldata and ldata.lrc and ldata.lrc.lyric and ldata.lrc.lyric ~= "" then
|
||||||
|
lyrics = parseLRC(ldata.lrc.lyric)
|
||||||
|
if not lyrics then
|
||||||
|
lyrics = parsePlain(ldata.lrc.lyric)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if lyrics then
|
||||||
|
cache[tk] = lyrics
|
||||||
|
evictCache()
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", lyrics)
|
||||||
|
elseif fallback then
|
||||||
|
fallback()
|
||||||
|
else
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
|
||||||
|
local qTmp = (noctalia.pluginDir() or "/tmp") .. "/.query_cache.tmp"
|
||||||
|
noctalia.writeFile(qTmp, query)
|
||||||
|
local dir = noctalia.pluginDir() or "/tmp"
|
||||||
|
|
||||||
|
local function applyParsed(parsed)
|
||||||
|
if parsed and parsed.type == "krc" and parsed.lines then
|
||||||
|
cache[tk] = parsed.lines
|
||||||
|
evictCache()
|
||||||
|
noctalia.state.set("lyrics", parsed.lines)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if parsed and parsed.type == "lrc" and parsed.lrc and parsed.lrc ~= "" then
|
||||||
|
local lyr = parseLRC(parsed.lrc)
|
||||||
|
if not lyr then lyr = parsePlain(parsed.lrc) end
|
||||||
|
if lyr then
|
||||||
|
cache[tk] = lyr
|
||||||
|
evictCache()
|
||||||
|
noctalia.state.set("lyrics", lyr)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runPy(script, cb)
|
||||||
|
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
|
||||||
|
noctalia.runAsync(py, function(r)
|
||||||
|
if not inFlight then return end
|
||||||
|
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||||
|
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
|
||||||
|
cb(ok and parsed or nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyText(text)
|
||||||
|
if not text or text == "" then return false end
|
||||||
|
local parsed = parseLRC(text)
|
||||||
|
if not parsed then parsed = parsePlain(text) end
|
||||||
|
if not parsed then return false end
|
||||||
|
cache[tk] = parsed
|
||||||
|
evictCache()
|
||||||
|
noctalia.state.set("lyrics", parsed)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetchCustom()
|
||||||
|
if customUrl == "" then
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local replacements = {
|
||||||
|
title = track.title,
|
||||||
|
artist = track.artist,
|
||||||
|
album = track.album,
|
||||||
|
duration = tostring(math.floor((track.duration or 0) / 1000000)),
|
||||||
|
}
|
||||||
|
local url = customUrl:gsub("{([%w_]+)}", function(key)
|
||||||
|
return noctalia.string.urlEncode(replacements[key] or "")
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
|
||||||
|
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||||
|
local text = response.ok and response.body or ""
|
||||||
|
if text ~= "" and customJsonField ~= "" then
|
||||||
|
local decoded = noctalia.json.decode(text)
|
||||||
|
for part in customJsonField:gmatch("[^.]+") do
|
||||||
|
decoded = type(decoded) == "table" and decoded[part] or nil
|
||||||
|
end
|
||||||
|
if type(decoded) == "table" then
|
||||||
|
cache[tk] = decoded
|
||||||
|
evictCache()
|
||||||
|
noctalia.state.set("lyrics", decoded)
|
||||||
|
inFlight = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
text = type(decoded) == "string" and decoded or ""
|
||||||
|
end
|
||||||
|
applyText(text)
|
||||||
|
inFlight = nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lyricsSource == "external" then
|
||||||
|
inFlight = nil
|
||||||
|
return
|
||||||
|
elseif lyricsSource == "mpris" then
|
||||||
|
applyText(embeddedLyrics)
|
||||||
|
inFlight = nil
|
||||||
|
return
|
||||||
|
elseif lyricsSource == "custom" then
|
||||||
|
fetchCustom()
|
||||||
|
return
|
||||||
|
elseif lyricsSource == "netease" then
|
||||||
|
tryFetch(track.title .. " " .. track.artist, function()
|
||||||
|
tryFetch(track.title, nil)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto: LRCLIB, then the public NetEase API.
|
||||||
|
runPy("lrclib_lyric.py", function(parsed)
|
||||||
|
if applyParsed(parsed) then inFlight = nil; return end
|
||||||
|
if lyricsSource == "lrclib" then
|
||||||
|
inFlight = nil
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
tryFetch(track.title .. " " .. track.artist, function()
|
||||||
|
tryFetch(track.title, nil)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetchCover(track, artUrl)
|
||||||
|
if coverInFlight then return end
|
||||||
|
coverInFlight = track.title .. "|" .. track.artist .. "|" .. track.album
|
||||||
|
|
||||||
|
local dest = coverPathFor(track)
|
||||||
|
|
||||||
|
local apply = function(path)
|
||||||
|
local tk = track.title .. "|" .. track.artist .. "|" .. track.album
|
||||||
|
coverInFlight = nil
|
||||||
|
if tk ~= lastTrackKey then return end
|
||||||
|
coverCache[tk] = path
|
||||||
|
noctalia.state.set("cover", path)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not artUrl or artUrl == "" then
|
||||||
|
noctalia.state.set("cover", nil)
|
||||||
|
coverInFlight = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if artUrl:sub(1, 7) == "file://" then
|
||||||
|
local source = noctalia.string.urlDecode(artUrl:sub(8))
|
||||||
|
if not noctalia.fileExists(source) then
|
||||||
|
apply(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
noctalia.runAsync("cp -- " .. shellQuote(source) .. " " .. shellQuote(dest), function(result)
|
||||||
|
if result.exitCode == 0 then apply(dest) else apply(source) end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.download(artUrl, dest, function(ok)
|
||||||
|
if ok then apply(dest) else apply(nil) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function poll()
|
||||||
|
local cmd = [[playerctl metadata --format $'{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}' 2>/dev/null]]
|
||||||
|
|
||||||
|
noctalia.runAsync(cmd, function(r)
|
||||||
|
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
|
||||||
|
noctalia.state.set("track", nil)
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
noctalia.state.set("cover", nil)
|
||||||
|
noctalia.state.set("playing", false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
local separator = string.char(31)
|
||||||
|
for field in (r.stdout .. separator):gmatch("(.-)" .. separator) do
|
||||||
|
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local status = parts[1] or "stopped"
|
||||||
|
local title = parts[2] or ""
|
||||||
|
local artist = parts[3] or ""
|
||||||
|
local album = parts[4] or ""
|
||||||
|
local artUrl = parts[7] or ""
|
||||||
|
local embeddedLyrics = parts[8] or ""
|
||||||
|
|
||||||
|
if title == "" and artist == "" then
|
||||||
|
noctalia.state.set("track", nil)
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
noctalia.state.set("playing", status == "playing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local playing = status == "playing"
|
||||||
|
local tk = title .. "|" .. artist .. "|" .. album
|
||||||
|
|
||||||
|
local t = {
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
album = album,
|
||||||
|
status = status,
|
||||||
|
position = tonumber(parts[5]) or 0,
|
||||||
|
duration = tonumber(parts[6]) or 0,
|
||||||
|
}
|
||||||
|
currentTrack = t
|
||||||
|
currentEmbeddedLyrics = embeddedLyrics
|
||||||
|
|
||||||
|
noctalia.state.set("position", t.position)
|
||||||
|
|
||||||
|
if tk ~= lastTrackKey then
|
||||||
|
lastTrackKey = tk
|
||||||
|
noctalia.state.set("track", t)
|
||||||
|
noctalia.state.set("playing", playing)
|
||||||
|
|
||||||
|
local cached = cache[tk]
|
||||||
|
if cached then
|
||||||
|
noctalia.state.set("lyrics", cached)
|
||||||
|
else
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
fetchLyricsNetEase(t, embeddedLyrics)
|
||||||
|
end
|
||||||
|
|
||||||
|
local cachedCover = coverCache[tk]
|
||||||
|
if cachedCover then
|
||||||
|
noctalia.state.set("cover", cachedCover)
|
||||||
|
else
|
||||||
|
noctalia.state.set("cover", nil)
|
||||||
|
fetchCover(t, artUrl)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
noctalia.state.set("track", t)
|
||||||
|
noctalia.state.set("playing", playing)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local pollTick = 0
|
||||||
|
|
||||||
|
function update()
|
||||||
|
if lyricsSource == "external" then return end
|
||||||
|
pollTick = pollTick + 1
|
||||||
|
if pollTick % 2 == 0 then
|
||||||
|
poll()
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
function onConfigChanged()
|
||||||
|
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||||
|
local nextUrl = noctalia.getConfig("custom_url") or ""
|
||||||
|
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||||
|
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
|
||||||
|
lyricsSource = nextSource
|
||||||
|
customUrl = nextUrl
|
||||||
|
customJsonField = nextField
|
||||||
|
if sourceChanged and currentTrack then
|
||||||
|
inFlight = nil
|
||||||
|
cache = {}
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyPushedLyrics(payload)
|
||||||
|
local decoded = noctalia.json.decode(payload or "")
|
||||||
|
if type(decoded) == "table" then
|
||||||
|
if decoded.track then
|
||||||
|
currentTrack = decoded.track
|
||||||
|
lastTrackKey = (decoded.track.title or "") .. "|" .. (decoded.track.artist or "") .. "|" .. (decoded.track.album or "")
|
||||||
|
noctalia.state.set("track", decoded.track)
|
||||||
|
end
|
||||||
|
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
|
||||||
|
if decoded.lyrics then
|
||||||
|
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
|
||||||
|
noctalia.state.set("lyrics", parsed)
|
||||||
|
end
|
||||||
|
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
|
||||||
|
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
|
||||||
|
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function onIpc(event, payload)
|
||||||
|
if event == "push-lrc" then
|
||||||
|
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
|
||||||
|
elseif event == "push-json" or event == "push-state" then
|
||||||
|
applyPushedLyrics(payload)
|
||||||
|
elseif event == "clear" then
|
||||||
|
noctalia.state.set("lyrics", nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if lyricsSource ~= "external" then poll() end
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
id = "h465855hgg/lyrics"
|
||||||
|
name = "Lyrics"
|
||||||
|
version = "1.0.0"
|
||||||
|
plugin_api = 3
|
||||||
|
author = "h465855hgg"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = ["playerctl", "python3", "curl", "cp"]
|
||||||
|
tags = ["bar", "service", "music", "media", "animation"]
|
||||||
|
icon = "music"
|
||||||
|
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "lyrics_source"
|
||||||
|
type = "select"
|
||||||
|
label_key = "settings.lyrics_source.label"
|
||||||
|
description_key = "settings.lyrics_source.description"
|
||||||
|
default = "auto"
|
||||||
|
options = [
|
||||||
|
{ value = "auto", label_key = "settings.lyrics_source.options.auto" },
|
||||||
|
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
|
||||||
|
{ value = "netease", label_key = "settings.lyrics_source.options.netease" },
|
||||||
|
{ value = "mpris", label_key = "settings.lyrics_source.options.mpris" },
|
||||||
|
{ value = "custom", label_key = "settings.lyrics_source.options.custom" },
|
||||||
|
{ value = "external", label_key = "settings.lyrics_source.options.external" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "custom_url"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.custom_url.label"
|
||||||
|
description_key = "settings.custom_url.description"
|
||||||
|
default = ""
|
||||||
|
visible_when = { key = "lyrics_source", values = ["custom"] }
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "custom_json_field"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.custom_json_field.label"
|
||||||
|
description_key = "settings.custom_json_field.description"
|
||||||
|
default = "syncedLyrics"
|
||||||
|
visible_when = { key = "lyrics_source", values = ["custom"] }
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "cue_text"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.cue_text.label"
|
||||||
|
description_key = "settings.cue_text.description"
|
||||||
|
default = "•••••"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "scroll_mode"
|
||||||
|
type = "select"
|
||||||
|
label_key = "settings.scroll_mode.label"
|
||||||
|
default = "auto"
|
||||||
|
description_key = "settings.scroll_mode.description"
|
||||||
|
options = [
|
||||||
|
{ value = "auto", label_key = "settings.scroll_mode.options.auto" },
|
||||||
|
{ value = "marquee", label_key = "settings.scroll_mode.options.marquee" },
|
||||||
|
{ value = "static", label_key = "settings.scroll_mode.options.static" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "marquee_speed"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.marquee_speed.label"
|
||||||
|
default = 30
|
||||||
|
min = 10
|
||||||
|
max = 120
|
||||||
|
description_key = "settings.marquee_speed.description"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "max_lines"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.max_lines.label"
|
||||||
|
default = 1
|
||||||
|
min = 1
|
||||||
|
max = 3
|
||||||
|
description_key = "settings.max_lines.description"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "gradient"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.gradient.label"
|
||||||
|
default = true
|
||||||
|
description_key = "settings.gradient.description"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "animation"
|
||||||
|
type = "select"
|
||||||
|
label_key = "settings.animation.label"
|
||||||
|
default = "karaoke"
|
||||||
|
description_key = "settings.animation.description"
|
||||||
|
options = [
|
||||||
|
{ value = "karaoke", label_key = "settings.animation.options.karaoke" },
|
||||||
|
{ value = "cascade", label_key = "settings.animation.options.cascade" },
|
||||||
|
{ value = "wave", label_key = "settings.animation.options.wave" },
|
||||||
|
{ value = "fade", label_key = "settings.animation.options.fade" },
|
||||||
|
{ value = "none", label_key = "settings.animation.options.none" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "max_chars"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.max_chars.label"
|
||||||
|
default = 24
|
||||||
|
min = 8
|
||||||
|
max = 80
|
||||||
|
description_key = "settings.max_chars.description"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "char_width"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.char_width.label"
|
||||||
|
default = 9
|
||||||
|
min = 4
|
||||||
|
max = 24
|
||||||
|
description_key = "settings.char_width.description"
|
||||||
|
|
||||||
|
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
|
||||||
|
[[service]]
|
||||||
|
id = "service"
|
||||||
|
entry = "lyrics_service.luau"
|
||||||
|
|
||||||
|
# Bar widget: thin presentation that mirrors the published lyrics state.
|
||||||
|
[[widget]]
|
||||||
|
id = "lyrics"
|
||||||
|
entry = "lyrics.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "settings.glyph.label"
|
||||||
|
default = "music"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "show_artist"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.show_artist.label"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "hide_when_paused"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hide_when_paused.label"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "show_cover"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.show_cover.label"
|
||||||
|
description_key = "settings.show_cover.description"
|
||||||
|
default = true
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"title": "Lyrics",
|
||||||
|
"no_lyrics": "♪ No lyrics available",
|
||||||
|
"intro": "Intro",
|
||||||
|
"instrumental": "Instrumental",
|
||||||
|
"settings": {
|
||||||
|
"glyph": {
|
||||||
|
"label": "Glyph"
|
||||||
|
},
|
||||||
|
"show_artist": {
|
||||||
|
"label": "Show artist"
|
||||||
|
},
|
||||||
|
"hide_when_paused": {
|
||||||
|
"label": "Hide when paused"
|
||||||
|
},
|
||||||
|
"show_cover": {
|
||||||
|
"label": "Show album cover",
|
||||||
|
"description": "Display the song's album art next to the lyrics."
|
||||||
|
},
|
||||||
|
"lyrics_source": {
|
||||||
|
"label": "Lyrics source",
|
||||||
|
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
|
||||||
|
"options": {
|
||||||
|
"auto": "Automatic fallback",
|
||||||
|
"lrclib": "LRCLIB",
|
||||||
|
"netease": "NetEase Music (public API)",
|
||||||
|
"mpris": "Local player (MPRIS)",
|
||||||
|
"custom": "Custom HTTP endpoint",
|
||||||
|
"external": "External IPC push"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_url": {
|
||||||
|
"label": "Custom endpoint URL",
|
||||||
|
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly."
|
||||||
|
},
|
||||||
|
"custom_json_field": {
|
||||||
|
"label": "JSON lyrics field",
|
||||||
|
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC."
|
||||||
|
},
|
||||||
|
"cue_text": {
|
||||||
|
"label": "Intro/interlude characters",
|
||||||
|
"description": "Text or characters highlighted during intros and interludes."
|
||||||
|
},
|
||||||
|
"scroll_mode": {
|
||||||
|
"label": "Scroll mode",
|
||||||
|
"description": "How lyrics scroll in the bar.",
|
||||||
|
"options": {
|
||||||
|
"auto": "Auto (synced)",
|
||||||
|
"marquee": "Marquee",
|
||||||
|
"static": "Static"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marquee_speed": {
|
||||||
|
"label": "Marquee speed",
|
||||||
|
"description": "Pixels per second for long-line scrolling."
|
||||||
|
},
|
||||||
|
"max_lines": {
|
||||||
|
"label": "Max lines",
|
||||||
|
"description": "Maximum lyric lines to show at once."
|
||||||
|
},
|
||||||
|
"gradient": {
|
||||||
|
"label": "Per-character gradient",
|
||||||
|
"description": "Light up each character as the song progresses (karaoke style)."
|
||||||
|
},
|
||||||
|
"animation": {
|
||||||
|
"label": "Animation",
|
||||||
|
"description": "How lyrics animate on line change.",
|
||||||
|
"options": {
|
||||||
|
"karaoke": "Karaoke gradient + fade",
|
||||||
|
"cascade": "Character cascade",
|
||||||
|
"wave": "Character wave reveal",
|
||||||
|
"fade": "Fade only",
|
||||||
|
"none": "No animation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"max_chars": {
|
||||||
|
"label": "Max characters",
|
||||||
|
"description": "Visible width before a long lyric line scrolls (marquee)."
|
||||||
|
},
|
||||||
|
"char_width": {
|
||||||
|
"label": "Character width (px)",
|
||||||
|
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user