From 4c113f7370cc94ad1cbdd49eca81f5a485ed00d1 Mon Sep 17 00:00:00 2001 From: rrzt <3382198490@qq.com> Date: Sun, 26 Jul 2026 08:04:21 +0800 Subject: [PATCH] feat(lyrics): add selectable LRCLIB results (#102) --- lyrics/README.md | 17 +++- lyrics/lyric_sources.py | 94 +++++++++++++++++++- lyrics/lyrics.luau | 35 +++++++- lyrics/lyrics_selector.luau | 146 +++++++++++++++++++++++++++++++ lyrics/lyrics_service.luau | 126 +++++++++++++++++++++----- lyrics/lyrics_shortcut.luau | 5 ++ lyrics/plugin.toml | 18 +++- lyrics/test_lyric_sources.py | 112 ++++++++++++++++++++++++ lyrics/translations/en.json | 15 ++++ lyrics/translations/zh-Hans.json | 15 ++++ 10 files changed, 552 insertions(+), 31 deletions(-) create mode 100644 lyrics/lyrics_selector.luau create mode 100644 lyrics/lyrics_shortcut.luau diff --git a/lyrics/README.md b/lyrics/README.md index f42685a..b3a8eb2 100644 --- a/lyrics/README.md +++ b/lyrics/README.md @@ -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 diff --git a/lyrics/lyric_sources.py b/lyrics/lyric_sources.py index cc98443..474df82 100644 --- a/lyrics/lyric_sources.py +++ b/lyrics/lyric_sources.py @@ -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): diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau index f59e57e..37e6ae0 100644 --- a/lyrics/lyrics.luau +++ b/lyrics/lyrics.luau @@ -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" diff --git a/lyrics/lyrics_selector.luau b/lyrics/lyrics_selector.luau new file mode 100644 index 0000000..ccb087a --- /dev/null +++ b/lyrics/lyrics_selector.luau @@ -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() diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau index 30e2e07..2afec5d 100644 --- a/lyrics/lyrics_service.luau +++ b/lyrics/lyrics_service.luau @@ -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 diff --git a/lyrics/lyrics_shortcut.luau b/lyrics/lyrics_shortcut.luau new file mode 100644 index 0000000..0abd573 --- /dev/null +++ b/lyrics/lyrics_shortcut.luau @@ -0,0 +1,5 @@ +--!nonstrict + +function onActivate() + noctalia.togglePanel("h465855hgg/lyrics:selector") +end diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml index 15b3687..b6ed713 100644 --- a/lyrics/plugin.toml +++ b/lyrics/plugin.toml @@ -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" diff --git a/lyrics/test_lyric_sources.py b/lyrics/test_lyric_sources.py index 65dc497..970ab6d 100644 --- a/lyrics/test_lyric_sources.py +++ b/lyrics/test_lyric_sources.py @@ -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({ diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json index 3d814d5..8eaa071 100644 --- a/lyrics/translations/en.json +++ b/lyrics/translations/en.json @@ -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.", diff --git a/lyrics/translations/zh-Hans.json b/lyrics/translations/zh-Hans.json index 09147f8..a9b4e49 100644 --- a/lyrics/translations/zh-Hans.json +++ b/lyrics/translations/zh-Hans.json @@ -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": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。",