feat(lyrics): add selectable LRCLIB results (#102)
This commit is contained in:
+14
-3
@@ -1,4 +1,4 @@
|
||||
# Noctalia Lyrics 1.4.3
|
||||
# Noctalia Lyrics 1.4.4
|
||||
|
||||
Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
|
||||
translation and romanization layers, configurable sources, karaoke highlighting,
|
||||
@@ -9,7 +9,7 @@ album artwork, and layout controls.
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `h465855hgg/lyrics` |
|
||||
| Entries | Bar widget: `lyrics`; service: `service` |
|
||||
| Entries | Bar widget: `lyrics`; panel: `selector`; shortcut: `open_selector`; service: `service` |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -26,6 +26,14 @@ Install these commands on `PATH`:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -83,7 +91,8 @@ romanization, and character-timing fields:
|
||||
`auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported
|
||||
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
|
||||
romanization when returned.
|
||||
- `splayer`: SPlayer's complete current lyric data, including line and word
|
||||
@@ -167,6 +176,8 @@ polling, marquee metrics, player filters, and fine padding.
|
||||
none.
|
||||
14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and
|
||||
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
|
||||
|
||||
|
||||
+91
-3
@@ -461,6 +461,85 @@ def best_match(items, track, title_key, artist_key, album_key=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):
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
@@ -529,15 +608,24 @@ def adapter_lrclib(track, credentials, options):
|
||||
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", ""))
|
||||
matches = lrclib_candidates(data, track)
|
||||
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:
|
||||
return empty(source, "lrclib: no match")
|
||||
lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or ""
|
||||
return success(
|
||||
response = success(
|
||||
source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"],
|
||||
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):
|
||||
|
||||
+34
-1
@@ -60,6 +60,18 @@ local showMode = displayMode == "track" and "track" or "auto"
|
||||
local rendered = false
|
||||
local playerInstance = ""
|
||||
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 baselineClock = os.clock()
|
||||
@@ -454,7 +466,6 @@ local function render()
|
||||
color = playing and activeColor or inactiveColor,
|
||||
})
|
||||
end
|
||||
|
||||
local lineNodes = {}
|
||||
local renderLineGap = lineGap
|
||||
local renderTextHeight = 0
|
||||
@@ -568,6 +579,14 @@ function onClick()
|
||||
if rendered then render() end
|
||||
end
|
||||
|
||||
function onHover(value)
|
||||
hovering = value == true
|
||||
if not hovering then
|
||||
hoverElapsed = 0
|
||||
selectorOpenRequested = false
|
||||
end
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
if playerInstance == "" then return end
|
||||
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
|
||||
@@ -601,6 +620,20 @@ function update()
|
||||
playing = noctalia.state.get("playing") == true
|
||||
playerInstance = noctalia.state.get("player_instance") 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 nextKey = info and info.key or "fallback"
|
||||
|
||||
@@ -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()
|
||||
+104
-22
@@ -9,6 +9,8 @@ noctalia.setUpdateInterval(updateIntervalMs)
|
||||
local cache = {}
|
||||
local coverCache = {}
|
||||
local sourceCoverCache = {}
|
||||
local candidateCache = {}
|
||||
local selectedCandidateCache = {}
|
||||
local lastTrackKey = ""
|
||||
local inFlight = nil
|
||||
local fetchGeneration = 0
|
||||
@@ -62,8 +64,21 @@ local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")
|
||||
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
|
||||
|
||||
local function trackKey(track)
|
||||
local durationSeconds = math.floor((tonumber(track.duration) or 0) / 1000000)
|
||||
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
|
||||
|
||||
local function patternMatches(value, pattern)
|
||||
@@ -115,6 +130,7 @@ local function clearPlayerState()
|
||||
noctalia.state.set("player_name", nil)
|
||||
noctalia.state.set("track", nil)
|
||||
noctalia.state.set("lyrics", nil)
|
||||
publishCandidates("")
|
||||
noctalia.state.set("cover", nil)
|
||||
noctalia.state.set("playing", false)
|
||||
end
|
||||
@@ -306,15 +322,20 @@ local function evictCache()
|
||||
for i = 1, #keys - 30 do
|
||||
cache[keys[i]] = nil
|
||||
sourceCoverCache[keys[i]] = nil
|
||||
candidateCache[keys[i]] = nil
|
||||
selectedCandidateCache[keys[i]] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
local function fetchLyricsNetEase(track, embeddedLyrics, requestedCandidateId)
|
||||
local tk = trackKey(track)
|
||||
requestedCandidateId = tostring(requestedCandidateId or "")
|
||||
local manualSelection = requestedCandidateId ~= ""
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
local flight = tk .. "|" .. tostring(fetchGeneration)
|
||||
inFlight = flight
|
||||
publishCandidates(tk, manualSelection, nil, manualSelection and requestedCandidateId or nil)
|
||||
|
||||
local function tryFetch(query, fallback)
|
||||
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
|
||||
@@ -529,24 +550,27 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
end)
|
||||
end
|
||||
|
||||
if lyricsSource == "external" then
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "mpris" then
|
||||
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "custom" then
|
||||
fetchCustom(nil)
|
||||
return
|
||||
if not manualSelection then
|
||||
if lyricsSource == "external" then
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "mpris" then
|
||||
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "custom" then
|
||||
fetchCustom(nil)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
|
||||
local sources = normalizedSources()
|
||||
local sources = manualSelection and { "lrclib" } or normalizedSources()
|
||||
local request = {
|
||||
track = track,
|
||||
options = {
|
||||
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]
|
||||
if not source then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
if manualSelection then
|
||||
publishCandidates(tk, false, "selection_failed")
|
||||
else
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
candidateCache[tk] = nil
|
||||
selectedCandidateCache[tk] = nil
|
||||
publishCandidates(tk)
|
||||
end
|
||||
return
|
||||
end
|
||||
if source == "mpris" then
|
||||
@@ -596,10 +627,18 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
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
|
||||
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()
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", parsed.lines)
|
||||
noctalia.state.set("lyrics_source_used", parsed.source or source)
|
||||
publishCandidates(tk)
|
||||
if type(parsed.cover) == "string" and parsed.cover ~= "" then
|
||||
sourceCoverCache[tk] = parsed.cover
|
||||
maybeApplyCover(track, parsed.cover)
|
||||
@@ -793,8 +832,15 @@ local function poll()
|
||||
noctalia.state.set("player_name", selected.name)
|
||||
|
||||
if selected.title == "" and selected.artist == "" then
|
||||
fetchGeneration = fetchGeneration + 1
|
||||
inFlight = nil
|
||||
currentTrack = nil
|
||||
currentEmbeddedLyrics = ""
|
||||
lastTrackKey = ""
|
||||
noctalia.state.set("track", nil)
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
publishCandidates("")
|
||||
noctalia.state.set("playing", selected.status == "playing")
|
||||
return
|
||||
end
|
||||
@@ -833,11 +879,16 @@ local function poll()
|
||||
if displayMode == "track" then
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("lyrics_source_used", nil)
|
||||
candidateCache[tk] = nil
|
||||
selectedCandidateCache[tk] = nil
|
||||
publishCandidates(tk)
|
||||
elseif cached then
|
||||
noctalia.state.set("lyrics", cached)
|
||||
noctalia.state.set("lyrics_source_used", "cache")
|
||||
publishCandidates(tk)
|
||||
else
|
||||
noctalia.state.set("lyrics", nil)
|
||||
publishCandidates(tk)
|
||||
fetchLyricsNetEase(t, selected.embeddedLyrics)
|
||||
end
|
||||
|
||||
@@ -921,25 +972,48 @@ function onConfigChanged()
|
||||
inFlight = nil
|
||||
cache = {}
|
||||
sourceCoverCache = {}
|
||||
candidateCache = {}
|
||||
selectedCandidateCache = {}
|
||||
noctalia.state.set("lyrics", nil)
|
||||
publishCandidates(trackKey(currentTrack))
|
||||
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) 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 decoded = noctalia.json.decode(payload or "")
|
||||
if type(decoded) == "table" then
|
||||
if decoded.track then
|
||||
currentTrack = decoded.track
|
||||
lastTrackKey = trackKey({
|
||||
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 "",
|
||||
})
|
||||
lastTrackKey = trackKey(decoded.track)
|
||||
noctalia.state.set("track", decoded.track)
|
||||
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.lyrics then
|
||||
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
|
||||
@@ -962,10 +1036,18 @@ end
|
||||
|
||||
function onIpc(event, payload)
|
||||
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 ""))
|
||||
elseif event == "push-json" or event == "push-state" then
|
||||
applyPushedLyrics(payload)
|
||||
elseif event == "clear" then
|
||||
local tk = currentTrack and trackKey(currentTrack) or ""
|
||||
candidateCache[tk] = nil
|
||||
selectedCandidateCache[tk] = nil
|
||||
publishCandidates(tk)
|
||||
noctalia.state.set("lyrics", nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
--!nonstrict
|
||||
|
||||
function onActivate()
|
||||
noctalia.togglePanel("h465855hgg/lyrics:selector")
|
||||
end
|
||||
+16
-2
@@ -1,11 +1,11 @@
|
||||
id = "h465855hgg/lyrics"
|
||||
name = "Lyrics"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
plugin_api = 3
|
||||
author = "h465855hgg"
|
||||
license = "MIT"
|
||||
dependencies = ["playerctl", "python3", "cp", "chmod"]
|
||||
tags = ["bar", "service", "music", "media", "animation"]
|
||||
tags = ["bar", "panel", "service", "music", "media", "animation"]
|
||||
icon = "music"
|
||||
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
|
||||
|
||||
@@ -451,11 +451,25 @@ min = 0
|
||||
max = 64
|
||||
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.
|
||||
[[service]]
|
||||
id = "service"
|
||||
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.
|
||||
[[widget]]
|
||||
id = "lyrics"
|
||||
|
||||
@@ -7,6 +7,118 @@ import urllib.error
|
||||
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):
|
||||
def test_preserves_timing_layers_and_markers(self):
|
||||
lines = lyric_sources.splayer_transmitted_lines({
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
"instrumental": "Instrumental",
|
||||
"intro": "Intro",
|
||||
"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": {
|
||||
"active_color": {
|
||||
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
"instrumental": "间奏",
|
||||
"intro": "前奏",
|
||||
"no_lyrics": "暂无可用歌词",
|
||||
"panel": {
|
||||
"close": "关闭",
|
||||
"loading": "正在加载歌词……",
|
||||
"no_candidates": "没有其他 LRCLIB 结果",
|
||||
"no_track": "当前没有正在播放的歌曲",
|
||||
"result": "歌词结果",
|
||||
"selection_failed": "无法加载所选歌词",
|
||||
"synced": "同步歌词",
|
||||
"title": "选择歌词",
|
||||
"unsynced": "非同步歌词"
|
||||
},
|
||||
"select_lyrics": "选择歌词",
|
||||
"shortcut": {
|
||||
"label": "打开歌词选择器"
|
||||
},
|
||||
"settings": {
|
||||
"active_color": {
|
||||
"description": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。",
|
||||
|
||||
Reference in New Issue
Block a user