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,
|
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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 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,24 +550,27 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
if lyricsSource == "external" then
|
if not manualSelection then
|
||||||
inFlight = nil
|
if lyricsSource == "external" then
|
||||||
return
|
inFlight = nil
|
||||||
elseif lyricsSource == "mpris" then
|
return
|
||||||
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
|
elseif lyricsSource == "mpris" then
|
||||||
inFlight = nil
|
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
|
||||||
return
|
inFlight = nil
|
||||||
elseif lyricsSource == "custom" then
|
return
|
||||||
fetchCustom(nil)
|
elseif lyricsSource == "custom" then
|
||||||
return
|
fetchCustom(nil)
|
||||||
|
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
|
||||||
noctalia.state.set("lyrics", nil)
|
if manualSelection then
|
||||||
noctalia.state.set("lyrics_source_used", nil)
|
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
|
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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
function onActivate()
|
||||||
|
noctalia.togglePanel("h465855hgg/lyrics:selector")
|
||||||
|
end
|
||||||
+16
-2
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。",
|
||||||
|
|||||||
Reference in New Issue
Block a user