feat(lyrics): add selectable LRCLIB results (#102)

This commit is contained in:
rrzt
2026-07-25 20:04:21 -04:00
committed by GitHub
parent 0c971b1121
commit 4c113f7370
10 changed files with 552 additions and 31 deletions
+14 -3
View File
@@ -1,4 +1,4 @@
# Noctalia Lyrics 1.4.3 # Noctalia Lyrics 1.4.4
Synchronized lyrics for the Noctalia bar, with multiple MPRIS players, Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
translation and romanization layers, configurable sources, karaoke highlighting, translation and romanization layers, configurable sources, karaoke highlighting,
@@ -9,7 +9,7 @@ album artwork, and layout controls.
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| ID | `h465855hgg/lyrics` | | ID | `h465855hgg/lyrics` |
| Entries | Bar widget: `lyrics`; service: `service` | | Entries | Bar widget: `lyrics`; panel: `selector`; shortcut: `open_selector`; service: `service` |
## Requirements ## Requirements
@@ -26,6 +26,14 @@ Install these commands on `PATH`:
Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar
widget. Left-click switches between lyrics and track information when widget. Left-click switches between lyrics and track information when
`display_mode` is `toggle`; right-click pauses or resumes the selected player. `display_mode` is `toggle`; right-click pauses or resumes the selected player.
When LRCLIB returns multiple matches, hover over the lyrics widget briefly to
open the `selector` panel and apply another result.
Bind the `open_selector` shortcut to a key combination in Noctalia's shortcuts
settings, or open the selector directly through IPC:
```sh
noctalia msg panel-toggle h465855hgg/lyrics:selector
```
## Screenshots ## Screenshots
@@ -83,7 +91,8 @@ romanization, and character-timing fields:
`auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported `auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported
IDs are: IDs are:
- `lrclib`: public LRCLIB search. - `lrclib`: public LRCLIB search with automatic synchronized-result ranking and
manual result selection.
- `netease`: public NetEase search, synchronized lyrics, translations, and - `netease`: public NetEase search, synchronized lyrics, translations, and
romanization when returned. romanization when returned.
- `splayer`: SPlayer's complete current lyric data, including line and word - `splayer`: SPlayer's complete current lyric data, including line and word
@@ -167,6 +176,8 @@ polling, marquee metrics, player filters, and fine padding.
none. none.
14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and 14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and
vertical bars. vertical bars.
15. LRCLIB selection: play a track with multiple results, briefly hover over the widget,
and switch between synchronized and plain entries without changing tracks.
## External protocol ## External protocol
+91 -3
View File
@@ -461,6 +461,85 @@ def best_match(items, track, title_key, artist_key, album_key=None):
return best if best_score >= 3 else None return best if best_score >= 3 else None
def lrclib_candidates(items, track):
wanted_title, wanted_artist, wanted_album = map(normalize, (
track.get("title"), track.get("artist"), track.get("album")
))
wanted_duration = duration_ms(track.get("duration")) / 1000
ranked = []
seen_ids = set()
for index, item in enumerate(items if isinstance(items, list) else []):
if not isinstance(item, dict):
continue
candidate_id = clean_text(item.get("id"))
if not candidate_id or candidate_id in seen_ids:
continue
synced = bool(clean_text(item.get("syncedLyrics")))
plain = bool(clean_text(item.get("plainLyrics")))
if not synced and not plain:
continue
title = normalize(item.get("trackName"))
artist = normalize(item.get("artistName"))
album = normalize(item.get("albumName"))
if wanted_title and not title:
continue
identity_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
identity_score += title_score
if wanted_artist and artist:
artist_score = 4 if artist == wanted_artist else 2 if wanted_artist in artist or artist in wanted_artist else 0
if artist_score == 0:
continue
identity_score += artist_score
if wanted_album and album:
identity_score += 2 if album == wanted_album else 1 if wanted_album in album or album in wanted_album else 0
candidate_duration = number(item.get("duration"), 0)
duration_bucket = 5
duration_difference = float("inf")
if wanted_duration > 0 and candidate_duration > 0:
duration_difference = abs(wanted_duration - candidate_duration)
if duration_difference <= 2:
duration_bucket = 0
elif duration_difference <= 5:
duration_bucket = 1
elif duration_difference <= 10:
duration_bucket = 2
elif duration_difference <= 20:
duration_bucket = 3
else:
duration_bucket = 4
if identity_score >= 3:
seen_ids.add(candidate_id)
ranked.append((
-identity_score,
duration_bucket,
-int(synced),
duration_difference,
index,
item,
))
ranked.sort(key=lambda entry: entry[:-1])
return [entry[-1] for entry in ranked]
def lrclib_candidate_metadata(item):
return {
"id": clean_text(item.get("id")),
"track_name": clean_text(item.get("trackName")),
"artist_name": clean_text(item.get("artistName")),
"album_name": clean_text(item.get("albumName")),
"duration": max(0, number(item.get("duration"), 0)),
"synced": bool(clean_text(item.get("syncedLyrics"))),
}
def first_cover(*values): def first_cover(*values):
for value in values: for value in values:
if isinstance(value, dict): if isinstance(value, dict):
@@ -529,15 +608,24 @@ def adapter_lrclib(track, credentials, options):
if track.get("album"): if track.get("album"):
params["album_name"] = track["album"] params["album_name"] = track["album"]
data = request_json(query_url("https://lrclib.net/api/search", params)) 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", ""), matches = lrclib_candidates(data, track)
lambda x: x.get("albumName", "")) requested_id = clean_text(options.get("lyrics_candidate_id"))
best = next((item for item in matches if clean_text(item.get("id")) == requested_id), None)
if requested_id and best is None:
return empty(source, "lrclib: requested match unavailable")
if best is None and matches:
best = matches[0]
if not best: if not best:
return empty(source, "lrclib: no match") return empty(source, "lrclib: no match")
lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or "" lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or ""
return success( response = success(
source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"], source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"],
duration_ms(track.get("duration")), itunes_cover(track), duration_ms(track.get("duration")), itunes_cover(track),
) )
if response.get("type") == "lyrics":
response["candidates"] = [lrclib_candidate_metadata(item) for item in matches]
response["selected_candidate_id"] = clean_text(best.get("id"))
return response
def adapter_netease(track, credentials, options): def adapter_netease(track, credentials, options):
+34 -1
View File
@@ -60,6 +60,18 @@ local showMode = displayMode == "track" and "track" or "auto"
local rendered = false local rendered = false
local playerInstance = "" local playerInstance = ""
local sourceUsed = "" local sourceUsed = ""
local lyricsCandidates = {}
local hovering = false
local hoverElapsed = 0
local selectorOpenRequested = false
local function trackKey(value)
if type(value) ~= "table" then return "" end
local durationSeconds = math.floor((tonumber(value.duration) or 0) / 1000000)
return (value.playerInstance or "") .. "|" .. (value.trackId or "") .. "|"
.. (value.title or "") .. "|" .. (value.artist or "") .. "|" .. (value.album or "")
.. "|" .. tostring(durationSeconds)
end
local baselinePosition = 0 local baselinePosition = 0
local baselineClock = os.clock() local baselineClock = os.clock()
@@ -454,7 +466,6 @@ local function render()
color = playing and activeColor or inactiveColor, color = playing and activeColor or inactiveColor,
}) })
end end
local lineNodes = {} local lineNodes = {}
local renderLineGap = lineGap local renderLineGap = lineGap
local renderTextHeight = 0 local renderTextHeight = 0
@@ -568,6 +579,14 @@ function onClick()
if rendered then render() end if rendered then render() end
end end
function onHover(value)
hovering = value == true
if not hovering then
hoverElapsed = 0
selectorOpenRequested = false
end
end
function onRightClick() function onRightClick()
if playerInstance == "" then return end if playerInstance == "" then return end
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result) noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
@@ -601,6 +620,20 @@ function update()
playing = noctalia.state.get("playing") == true playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or "" playerInstance = noctalia.state.get("player_instance") or ""
sourceUsed = noctalia.state.get("lyrics_source_used") or "" sourceUsed = noctalia.state.get("lyrics_source_used") or ""
local candidateState = noctalia.state.get("lyrics_candidate_state")
lyricsCandidates = type(candidateState) == "table" and candidateState.track_key == trackKey(track)
and candidateState.candidates or {}
local selectorOpen = noctalia.state.get("lyrics_selector_open") == true
if hovering and #lyricsCandidates > 1 and not selectorOpen and not selectorOpenRequested then
hoverElapsed = hoverElapsed + delta
if hoverElapsed >= 0.4 then
selectorOpenRequested = true
hoverElapsed = 0
noctalia.togglePanel("h465855hgg/lyrics:selector")
end
else
hoverElapsed = 0
end
local info = getLineInfo() local info = getLineInfo()
local nextKey = info and info.key or "fallback" local nextKey = info and info.key or "fallback"
+146
View File
@@ -0,0 +1,146 @@
--!nonstrict
local candidateState = noctalia.state.get("lyrics_candidate_state") or {}
local track = noctalia.state.get("track")
local requestCounter = 0
local dirty = true
local function trackKey(value)
if type(value) ~= "table" then return "" end
local durationSeconds = math.floor((tonumber(value.duration) or 0) / 1000000)
return (value.playerInstance or "") .. "|" .. (value.trackId or "") .. "|"
.. (value.title or "") .. "|" .. (value.artist or "") .. "|" .. (value.album or "")
.. "|" .. tostring(durationSeconds)
end
local function stateForTrack()
if type(candidateState) ~= "table" or candidateState.track_key ~= trackKey(track) then return {} end
return candidateState
end
local function durationLabel(value)
local seconds = math.max(0, math.floor(tonumber(value) or 0))
return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60)
end
local function candidateLabel(candidate)
local parts = {}
local title = tostring(candidate.track_name or "")
local artist = tostring(candidate.artist_name or "")
if title ~= "" then parts[#parts + 1] = title end
if artist ~= "" then parts[#parts + 1] = artist end
if tonumber(candidate.duration) and tonumber(candidate.duration) > 0 then
parts[#parts + 1] = durationLabel(candidate.duration)
end
parts[#parts + 1] = noctalia.tr(candidate.synced == true and "panel.synced" or "panel.unsynced")
return table.concat(parts, " · ")
end
local function selectedIndex()
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
for index, candidate in ipairs(candidates) do
if tostring(candidate.id or "") == selectedCandidateId then return index - 1 end
end
return 0
end
local function render()
dirty = false
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
local loading = state.loading == true
local selectionError = tostring(state.error or "")
local body = {}
if not track then
body[#body + 1] = ui.column({ align = "center", justify = "center", gap = 8, flexGrow = 1 }, {
ui.glyph({ name = "music-off", size = 28, color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.no_track"), color = "on_surface_variant" }),
})
elseif #candidates == 0 then
body[#body + 1] = ui.column({ align = "center", justify = "center", gap = 8, flexGrow = 1 }, {
ui.glyph({ name = "list-search", size = 28, color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.no_candidates"), color = "on_surface_variant" }),
})
else
local options = {}
for _, candidate in ipairs(candidates) do options[#options + 1] = candidateLabel(candidate) end
body[#body + 1] = ui.label({ text = noctalia.tr("panel.result"), color = "on_surface_variant", fontSize = 11 })
body[#body + 1] = ui.select({
key = "lyrics-candidate-" .. selectedCandidateId,
options = options,
selectedIndex = selectedIndex(),
enabled = not loading,
onChange = "onCandidateChange",
})
end
if loading then
body[#body + 1] = ui.label({ text = noctalia.tr("panel.loading"), color = "primary", fontSize = 11 })
elseif selectionError ~= "" then
body[#body + 1] = ui.label({ text = noctalia.tr("panel.selection_failed"), color = "error", fontSize = 11 })
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "list-search", size = 22, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold" }),
ui.label({
text = track and ((track.artist and track.artist ~= "" and track.artist .. " - " or "") .. tostring(track.title or "")) or "",
color = "on_surface_variant",
fontSize = 11,
maxLines = 1,
}),
}),
ui.button({ glyph = "close", variant = "ghost", tooltip = noctalia.tr("panel.close"), onClick = "onCloseClicked" }),
}),
ui.column({ flexGrow = 1, gap = 8, align = "stretch" }, body),
}))
end
local function watch(key, transform)
noctalia.state.watch(key, function(value)
transform(value)
dirty = true
end)
end
watch("lyrics_candidate_state", function(value) candidateState = type(value) == "table" and value or {} end)
watch("track", function(value) track = value end)
function onOpen(_context)
noctalia.state.set("lyrics_selector_open", true)
dirty = true
render()
end
function onClose()
noctalia.state.set("lyrics_selector_open", false)
end
function update()
if dirty then render() end
end
function onCloseClicked()
panel.close()
end
function onCandidateChange(index, _label)
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
local candidate = candidates[(math.floor(tonumber(index) or 0)) + 1]
if not candidate or tostring(candidate.id or "") == selectedCandidateId or not track then return end
requestCounter = requestCounter + 1
noctalia.state.set("lyrics_candidate_request", {
request_id = tostring(os.clock()) .. "-" .. tostring(requestCounter),
candidate_id = tostring(candidate.id or ""),
track_key = trackKey(track),
})
end
render()
+92 -10
View File
@@ -9,6 +9,8 @@ noctalia.setUpdateInterval(updateIntervalMs)
local cache = {} local cache = {}
local coverCache = {} local coverCache = {}
local sourceCoverCache = {} local sourceCoverCache = {}
local candidateCache = {}
local selectedCandidateCache = {}
local lastTrackKey = "" local lastTrackKey = ""
local inFlight = nil local inFlight = nil
local fetchGeneration = 0 local fetchGeneration = 0
@@ -62,8 +64,21 @@ local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist")) local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track) local function trackKey(track)
local durationSeconds = math.floor((tonumber(track.duration) or 0) / 1000000)
return (track.playerInstance or "") .. "|" .. (track.trackId or "") .. "|" return (track.playerInstance or "") .. "|" .. (track.trackId or "") .. "|"
.. track.title .. "|" .. track.artist .. "|" .. track.album .. (track.title or "") .. "|" .. (track.artist or "") .. "|" .. (track.album or "")
.. "|" .. tostring(durationSeconds)
end
local function publishCandidates(tk, loading, selectionError, pendingCandidateId)
noctalia.state.set("lyrics_candidate_state", {
track_key = tk or "",
candidates = candidateCache[tk] or {},
selected_id = selectedCandidateCache[tk],
loading = loading == true,
error = selectionError,
pending_id = pendingCandidateId,
})
end end
local function patternMatches(value, pattern) local function patternMatches(value, pattern)
@@ -115,6 +130,7 @@ local function clearPlayerState()
noctalia.state.set("player_name", nil) noctalia.state.set("player_name", nil)
noctalia.state.set("track", nil) noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
publishCandidates("")
noctalia.state.set("cover", nil) noctalia.state.set("cover", nil)
noctalia.state.set("playing", false) noctalia.state.set("playing", false)
end end
@@ -306,15 +322,20 @@ local function evictCache()
for i = 1, #keys - 30 do for i = 1, #keys - 30 do
cache[keys[i]] = nil cache[keys[i]] = nil
sourceCoverCache[keys[i]] = nil sourceCoverCache[keys[i]] = nil
candidateCache[keys[i]] = nil
selectedCandidateCache[keys[i]] = nil
end end
end end
end end
local function fetchLyricsNetEase(track, embeddedLyrics) local function fetchLyricsNetEase(track, embeddedLyrics, requestedCandidateId)
local tk = trackKey(track) local tk = trackKey(track)
requestedCandidateId = tostring(requestedCandidateId or "")
local manualSelection = requestedCandidateId ~= ""
fetchGeneration = fetchGeneration + 1 fetchGeneration = fetchGeneration + 1
local flight = tk .. "|" .. tostring(fetchGeneration) local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight inFlight = flight
publishCandidates(tk, manualSelection, nil, manualSelection and requestedCandidateId or nil)
local function tryFetch(query, fallback) local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5" local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
@@ -529,6 +550,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end) end)
end end
if not manualSelection then
if lyricsSource == "external" then if lyricsSource == "external" then
inFlight = nil inFlight = nil
return return
@@ -540,13 +562,15 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
fetchCustom(nil) fetchCustom(nil)
return return
end end
end
local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json" local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
local sources = normalizedSources() local sources = manualSelection and { "lrclib" } or normalizedSources()
local request = { local request = {
track = track, track = track,
options = { options = {
translation_language = noctalia.getConfig("translation_language") or "zh-Hans", translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
lyrics_candidate_id = manualSelection and requestedCandidateId or nil,
}, },
} }
@@ -555,8 +579,15 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local source = sources[index] local source = sources[index]
if not source then if not source then
inFlight = nil inFlight = nil
if manualSelection then
publishCandidates(tk, false, "selection_failed")
else
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil) noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
end
return return
end end
if source == "mpris" then if source == "mpris" then
@@ -596,10 +627,18 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local parsed = noctalia.json.decode(result.stdout or "") 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 if type(parsed) == "table" and parsed.type == "lyrics" and type(parsed.lines) == "table" and #parsed.lines > 0 then
cache[tk] = parsed.lines cache[tk] = parsed.lines
if (parsed.source or source) == "lrclib" then
candidateCache[tk] = type(parsed.candidates) == "table" and parsed.candidates or {}
selectedCandidateCache[tk] = tostring(parsed.selected_candidate_id or "")
else
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
end
evictCache() evictCache()
inFlight = nil inFlight = nil
noctalia.state.set("lyrics", parsed.lines) noctalia.state.set("lyrics", parsed.lines)
noctalia.state.set("lyrics_source_used", parsed.source or source) noctalia.state.set("lyrics_source_used", parsed.source or source)
publishCandidates(tk)
if type(parsed.cover) == "string" and parsed.cover ~= "" then if type(parsed.cover) == "string" and parsed.cover ~= "" then
sourceCoverCache[tk] = parsed.cover sourceCoverCache[tk] = parsed.cover
maybeApplyCover(track, parsed.cover) maybeApplyCover(track, parsed.cover)
@@ -793,8 +832,15 @@ local function poll()
noctalia.state.set("player_name", selected.name) noctalia.state.set("player_name", selected.name)
if selected.title == "" and selected.artist == "" then if selected.title == "" and selected.artist == "" then
fetchGeneration = fetchGeneration + 1
inFlight = nil
currentTrack = nil
currentEmbeddedLyrics = ""
lastTrackKey = ""
noctalia.state.set("track", nil) noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
publishCandidates("")
noctalia.state.set("playing", selected.status == "playing") noctalia.state.set("playing", selected.status == "playing")
return return
end end
@@ -833,11 +879,16 @@ local function poll()
if displayMode == "track" then if displayMode == "track" then
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil) noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
elseif cached then elseif cached then
noctalia.state.set("lyrics", cached) noctalia.state.set("lyrics", cached)
noctalia.state.set("lyrics_source_used", "cache") noctalia.state.set("lyrics_source_used", "cache")
publishCandidates(tk)
else else
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
publishCandidates(tk)
fetchLyricsNetEase(t, selected.embeddedLyrics) fetchLyricsNetEase(t, selected.embeddedLyrics)
end end
@@ -921,25 +972,48 @@ function onConfigChanged()
inFlight = nil inFlight = nil
cache = {} cache = {}
sourceCoverCache = {} sourceCoverCache = {}
candidateCache = {}
selectedCandidateCache = {}
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
publishCandidates(trackKey(currentTrack))
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end end
end end
local lastCandidateRequest = ""
noctalia.state.watch("lyrics_candidate_request", function(request)
if type(request) ~= "table" or not currentTrack then return end
local requestId = tostring(request.request_id or "")
if requestId == "" or requestId == lastCandidateRequest then return end
lastCandidateRequest = requestId
local candidateId = tostring(request.candidate_id or "")
if candidateId == "" then return end
local tk = trackKey(currentTrack)
if tostring(request.track_key or "") ~= tk then return end
local known = false
for _, candidate in ipairs(candidateCache[tk] or {}) do
if tostring(candidate.id or "") == candidateId then
known = true
break
end
end
if not known or candidateId == tostring(selectedCandidateCache[tk] or "") then return end
inFlight = nil
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics, candidateId)
end)
local function applyPushedLyrics(payload) local function applyPushedLyrics(payload)
local decoded = noctalia.json.decode(payload or "") local decoded = noctalia.json.decode(payload or "")
if type(decoded) == "table" then if type(decoded) == "table" then
if decoded.track then if decoded.track then
currentTrack = decoded.track currentTrack = decoded.track
lastTrackKey = trackKey({ lastTrackKey = trackKey(decoded.track)
playerInstance = decoded.track.playerInstance or "",
trackId = decoded.track.trackId or "",
title = decoded.track.title or "",
artist = decoded.track.artist or "",
album = decoded.track.album or "",
})
noctalia.state.set("track", decoded.track) noctalia.state.set("track", decoded.track)
end end
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
if decoded.lyrics then if decoded.lyrics then
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics) local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
@@ -962,10 +1036,18 @@ end
function onIpc(event, payload) function onIpc(event, payload)
if event == "push-lrc" then if event == "push-lrc" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or "")) noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
elseif event == "push-json" or event == "push-state" then elseif event == "push-json" or event == "push-state" then
applyPushedLyrics(payload) applyPushedLyrics(payload)
elseif event == "clear" then elseif event == "clear" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
end end
end end
+5
View File
@@ -0,0 +1,5 @@
--!nonstrict
function onActivate()
noctalia.togglePanel("h465855hgg/lyrics:selector")
end
+16 -2
View File
@@ -1,11 +1,11 @@
id = "h465855hgg/lyrics" id = "h465855hgg/lyrics"
name = "Lyrics" name = "Lyrics"
version = "1.4.3" version = "1.4.4"
plugin_api = 3 plugin_api = 3
author = "h465855hgg" author = "h465855hgg"
license = "MIT" license = "MIT"
dependencies = ["playerctl", "python3", "cp", "chmod"] dependencies = ["playerctl", "python3", "cp", "chmod"]
tags = ["bar", "service", "music", "media", "animation"] tags = ["bar", "panel", "service", "music", "media", "animation"]
icon = "music" icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources." description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
@@ -451,11 +451,25 @@ min = 0
max = 64 max = 64
advanced = true advanced = true
# Keyboard shortcut for opening the lyrics selector panel.
[[shortcut]]
id = "open_selector"
entry = "lyrics_shortcut.luau"
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state. # Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]] [[service]]
id = "service" id = "service"
entry = "lyrics_service.luau" entry = "lyrics_service.luau"
# Attached selector for choosing among LRCLIB matches for the current track.
[[panel]]
id = "selector"
entry = "lyrics_selector.luau"
width = 520
height = 210
placement = "attached"
open_near_click = true
# Bar widget: thin presentation that mirrors the published lyrics state. # Bar widget: thin presentation that mirrors the published lyrics state.
[[widget]] [[widget]]
id = "lyrics" id = "lyrics"
+112
View File
@@ -7,6 +7,118 @@ import urllib.error
import lyric_sources import lyric_sources
class LrclibAdapterTest(unittest.TestCase):
def setUp(self):
self.track = {
"title": "Can't Stop",
"artist": "Red Hot Chili Peppers",
"album": "By the Way",
"duration": 269_000_000,
}
self.results = [
{
"id": 10,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 269,
"plainLyrics": "Plain line",
"syncedLyrics": "",
},
{
"id": 20,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 269,
"plainLyrics": "Synced line",
"syncedLyrics": "[00:01.00]Synced line",
},
]
@mock.patch("lyric_sources.itunes_cover", return_value="")
@mock.patch("lyric_sources.request_json")
def test_prefers_synced_candidate_and_returns_metadata(self, request_json, _itunes_cover):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(self.track, {}, {})
self.assertEqual(result["selected_candidate_id"], "20")
self.assertEqual(result["lines"][0]["time"], 1000)
self.assertEqual([item["id"] for item in result["candidates"]], ["20", "10"])
self.assertNotIn("plainLyrics", result["candidates"][0])
self.assertTrue(result["candidates"][0]["synced"])
@mock.patch("lyric_sources.itunes_cover", return_value="")
@mock.patch("lyric_sources.request_json")
def test_honors_requested_candidate(self, request_json, _itunes_cover):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(
self.track, {}, {"lyrics_candidate_id": "10"}
)
self.assertEqual(result["selected_candidate_id"], "10")
self.assertEqual(result["lines"][0]["time"], -1)
self.assertEqual(result["lines"][0]["text"], "Plain line")
@mock.patch("lyric_sources.request_json")
def test_rejects_stale_requested_candidate(self, request_json):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(
self.track, {}, {"lyrics_candidate_id": "missing"}
)
self.assertEqual(result["type"], "none")
self.assertEqual(result["diag"], ["lrclib: requested match unavailable"])
def test_rejects_wrong_artist_even_when_synced(self):
results = [
{
"id": 30,
"trackName": "Can't Stop",
"artistName": "Unrelated Artist",
"duration": 269,
"syncedLyrics": "[00:01.00]Wrong",
},
self.results[0],
]
ranked = lyric_sources.lrclib_candidates(results, self.track)
self.assertEqual([item["id"] for item in ranked], [10])
def test_prefers_duration_bucket_before_sync_status(self):
results = [
{
"id": 30,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 400,
"syncedLyrics": "[00:01.00]Wrong version",
},
self.results[0],
]
ranked = lyric_sources.lrclib_candidates(results, self.track)
self.assertEqual([item["id"] for item in ranked], [10, 30])
def test_filters_missing_and_duplicate_candidate_ids(self):
duplicate = dict(self.results[1])
duplicate["plainLyrics"] = "Duplicate"
missing = dict(self.results[0])
missing.pop("id")
ranked = lyric_sources.lrclib_candidates(
[missing, self.results[1], duplicate], self.track
)
self.assertEqual([item["id"] for item in ranked], [20])
class SPlayerLinesTest(unittest.TestCase): class SPlayerLinesTest(unittest.TestCase):
def test_preserves_timing_layers_and_markers(self): def test_preserves_timing_layers_and_markers(self):
lines = lyric_sources.splayer_transmitted_lines({ lines = lyric_sources.splayer_transmitted_lines({
+15
View File
@@ -2,6 +2,21 @@
"instrumental": "Instrumental", "instrumental": "Instrumental",
"intro": "Intro", "intro": "Intro",
"no_lyrics": "♪ No lyrics available", "no_lyrics": "♪ No lyrics available",
"panel": {
"close": "Close",
"loading": "Loading lyrics…",
"no_candidates": "No alternate LRCLIB results",
"no_track": "No track is playing",
"result": "Lyrics result",
"selection_failed": "Could not load the selected lyrics",
"synced": "Synced",
"title": "Choose lyrics",
"unsynced": "Unsynced"
},
"select_lyrics": "Choose lyrics",
"shortcut": {
"label": "Open lyrics selector"
},
"settings": { "settings": {
"active_color": { "active_color": {
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.", "description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
+15
View File
@@ -2,6 +2,21 @@
"instrumental": "间奏", "instrumental": "间奏",
"intro": "前奏", "intro": "前奏",
"no_lyrics": "暂无可用歌词", "no_lyrics": "暂无可用歌词",
"panel": {
"close": "关闭",
"loading": "正在加载歌词……",
"no_candidates": "没有其他 LRCLIB 结果",
"no_track": "当前没有正在播放的歌曲",
"result": "歌词结果",
"selection_failed": "无法加载所选歌词",
"synced": "同步歌词",
"title": "选择歌词",
"unsynced": "非同步歌词"
},
"select_lyrics": "选择歌词",
"shortcut": {
"label": "打开歌词选择器"
},
"settings": { "settings": {
"active_color": { "active_color": {
"description": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。", "description": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。",