feat(lyrics): improve player and interlude controls (#42)

* feat(lyrics): support multiple MPRIS players

* feat(lyrics): add interlude font options
This commit is contained in:
rrzt
2026-07-17 19:55:31 -04:00
committed by GitHub
parent 54ac3ed0d6
commit d80bdf0190
5 changed files with 240 additions and 59 deletions
+12 -3
View File
@@ -33,6 +33,11 @@ settings. The background `service` detects the active MPRIS player, resolves
lyrics, downloads or caches album artwork, and publishes playback state to the lyrics, downloads or caches album artwork, and publishes playback state to the
widget. widget.
When several players are available, the service prefers a playing player, then
a paused player, and keeps the current player when priorities are equal. The
optional allowlist and blocklist match player names or instances and support
`*` wildcards.
Left-click the widget to switch between lyrics and track information. Right-click Left-click the widget to switch between lyrics and track information. Right-click
to pause or resume the active player. Paused content is dimmed and all lyric, to pause or resume the active player. Paused content is dimmed and all lyric,
transition, and marquee animation stops until playback resumes. transition, and marquee animation stops until playback resumes.
@@ -55,22 +60,26 @@ Plugin settings:
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. |
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. |
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. | | `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. | | `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. | | `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. | | `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. |
| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. |
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. | | `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. | | `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. | | `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. | | `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. | | `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
| `max_chars` | `int` | `24` | Number of visible Unicode characters before marquee scrolling starts. | | `max_chars` | `int` | `15` | Number of visible Unicode characters before marquee scrolling starts. |
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. | | `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. | | `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. | | `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. | | `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. | | `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
| `active_color` | `color` | `primary` | Colors the current and already-sung lyric characters. | | `active_color` | `color` | `on_surface` | Colors the current and already-sung lyric characters. |
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. | | `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
## IPC ## IPC
@@ -102,7 +111,7 @@ Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
HTTP mode contacts only the configured endpoint. The plugin never reads browser HTTP mode contacts only the configured endpoint. The plugin never reads browser
cookies or player credentials. cookies or player credentials.
The service runs `playerctl` to read and control MPRIS playback, `python3` for the The service runs `playerctl` to select, read, and control MPRIS playback, `python3` for the
LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover
files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and
downloaded cover images are written inside the plugin runtime directory. Remote downloaded cover images are written inside the plugin runtime directory. Remote
+18 -5
View File
@@ -8,20 +8,23 @@ local showArtist = noctalia.getConfig("show_artist")
local hideWhenPaused = noctalia.getConfig("hide_when_paused") local hideWhenPaused = noctalia.getConfig("hide_when_paused")
local showCover = noctalia.getConfig("show_cover") local showCover = noctalia.getConfig("show_cover")
if showCover == nil then showCover = true end if showCover == nil then showCover = true end
local activeColor = noctalia.getConfig("active_color") or "primary" local activeColor = noctalia.getConfig("active_color") or "on_surface"
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant" local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
-- These are plugin settings, not per-widget settings. -- These are plugin settings, not per-widget settings.
local scrollMode = noctalia.getConfig("scroll_mode") or "auto" local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30 local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30
local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1 local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1
local maxChars = tonumber(noctalia.getConfig("max_chars")) or 24 local maxChars = tonumber(noctalia.getConfig("max_chars")) or 15
local charWidth = tonumber(noctalia.getConfig("char_width")) or 9 local charWidth = tonumber(noctalia.getConfig("char_width")) or 9
local gradientOn = noctalia.getConfig("gradient") local gradientOn = noctalia.getConfig("gradient")
if gradientOn == nil then gradientOn = true end if gradientOn == nil then gradientOn = true end
local animation = noctalia.getConfig("animation") or "karaoke" local animation = noctalia.getConfig("animation") or "karaoke"
local cueText = noctalia.getConfig("cue_text") or "•••••" local cueText = noctalia.getConfig("cue_text") or "•••••"
if cueText == "" then cueText = "•••••" end if cueText == "" then cueText = "•••••" end
local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
if cueFontFamily == "" then cueFontFamily = "sans-serif" end
local track = nil local track = nil
local lyrics = nil local lyrics = nil
@@ -29,6 +32,7 @@ local cover = nil
local playing = false local playing = false
local showMode = "auto" local showMode = "auto"
local rendered = false local rendered = false
local playerInstance = ""
local baselinePosition = 0 local baselinePosition = 0
local baselineClock = os.clock() local baselineClock = os.clock()
@@ -48,6 +52,10 @@ local INTERLUDE_GAP_MS = 7000
local INTRO_MIN_MS = 6000 local INTRO_MIN_MS = 6000
local INTERLUDE_MIN_MS = 8000 local INTERLUDE_MIN_MS = 8000
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function clamp(value, low, high) local function clamp(value, low, high)
return math.min(high, math.max(low, value)) return math.min(high, math.max(low, value))
end end
@@ -91,7 +99,7 @@ local function getLineInfo()
return { return {
key = "intro", key = "intro",
index = 0, index = 0,
line = { text = cueText }, line = { text = cueText, cue = true },
progress = clamp(position / firstTime, 0, 1), progress = clamp(position / firstTime, 0, 1),
synced = true, synced = true,
position = position, position = position,
@@ -130,7 +138,7 @@ local function getLineInfo()
return { return {
key = "interlude-" .. tostring(index), key = "interlude-" .. tostring(index),
index = index, index = index,
line = { text = cueText }, line = { text = cueText, cue = true },
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1), progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
synced = true, synced = true,
position = position, position = position,
@@ -221,6 +229,7 @@ local function buildLineRow(line, opts)
opts = opts or {} opts = opts or {}
local text = type(line) == "table" and line.text or line local text = type(line) == "table" and line.text or line
local times = type(line) == "table" and line.chars or nil local times = type(line) == "table" and line.chars or nil
local isCue = opts.cue or (type(line) == "table" and line.cue == true)
local chars = toChars(text or "") local chars = toChars(text or "")
if #chars == 0 then chars = { " " } end if #chars == 0 then chars = { " " } end
@@ -273,6 +282,7 @@ local function buildLineRow(line, opts)
color = color, color = color,
opacity = alpha, opacity = alpha,
maxLines = 1, maxLines = 1,
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil,
}) })
end end
@@ -344,6 +354,7 @@ local function render()
cascade = shownLine == info.line and animation == "cascade", cascade = shownLine == info.line and animation == "cascade",
wave = shownLine == info.line and animation == "wave", wave = shownLine == info.line and animation == "wave",
opacity = opacity, opacity = opacity,
cue = shownLine == info.line and info.cue == true,
}) })
end end
@@ -391,7 +402,8 @@ function onClick()
end end
function onRightClick() function onRightClick()
noctalia.runAsync("playerctl play-pause", function(result) if playerInstance == "" then return end
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
if result.exitCode == 0 then if result.exitCode == 0 then
local wasPlaying = playing local wasPlaying = playing
if wasPlaying then baselinePosition = getProgressMs() * 1000 end if wasPlaying then baselinePosition = getProgressMs() * 1000 end
@@ -412,6 +424,7 @@ function update()
lyrics = noctalia.state.get("lyrics") lyrics = noctalia.state.get("lyrics")
cover = noctalia.state.get("cover") cover = noctalia.state.get("cover")
playing = noctalia.state.get("playing") == true playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or ""
local info = getLineInfo() local info = getLineInfo()
local nextKey = info and info.key or "fallback" local nextKey = info and info.key or "fallback"
+151 -47
View File
@@ -10,16 +10,85 @@ local coverCache = {}
local lastTrackKey = "" local lastTrackKey = ""
local inFlight = nil local inFlight = nil
local coverInFlight = nil local coverInFlight = nil
local krcTmp = (noctalia.pluginDir() or "/tmp") .. "/.krc_cache.tmp" local pluginDir = noctalia.pluginDir() or "/tmp"
local cacheDir = pluginDir .. "/.cache"
noctalia.mkdirAll(cacheDir)
local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto" local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local customUrl = noctalia.getConfig("custom_url") or "" local customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local currentTrack = nil local currentTrack = nil
local currentEmbeddedLyrics = "" local currentEmbeddedLyrics = ""
local currentPlayerInstance = ""
local function normalizePatterns(value)
if type(value) ~= "table" then return {} end
local patterns = {}
for _, pattern in ipairs(value) do
pattern = tostring(pattern):lower():gsub("^%s+", ""):gsub("%s+$", "")
if pattern ~= "" then patterns[#patterns + 1] = pattern end
end
table.sort(patterns)
return patterns
end
local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track)
return (track.playerInstance or "") .. "|" .. track.title .. "|" .. track.artist .. "|" .. track.album
end
local function patternMatches(value, pattern)
local luaPattern = "^" .. pattern:gsub("([%%%^%$%(%)%.%[%]%+%-%?])", "%%%1"):gsub("%*", ".*") .. "$"
return value:lower():match(luaPattern) ~= nil
end
local function matchesAny(player, patterns)
for _, pattern in ipairs(patterns) do
if patternMatches(player.name, pattern) or patternMatches(player.instance, pattern) then return true end
end
return false
end
local function playerAllowed(player)
if #playerAllowlist > 0 and not matchesAny(player, playerAllowlist) then return false end
return not matchesAny(player, playerBlocklist)
end
local function selectPlayer(players)
local best = nil
local bestRank = -1
for _, player in ipairs(players) do
if playerAllowed(player) then
local rank = player.status == "playing" and 2 or (player.status == "paused" and 1 or 0)
if rank > bestRank or (rank == bestRank and player.instance == currentPlayerInstance) then
best = player
bestRank = rank
end
end
end
return best
end
local function clearPlayerState()
currentPlayerInstance = ""
currentTrack = nil
currentEmbeddedLyrics = ""
lastTrackKey = ""
inFlight = nil
coverInFlight = nil
noctalia.state.set("player_instance", nil)
noctalia.state.set("player_name", nil)
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
end
local function coverPathFor(track) local function coverPathFor(track)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_") local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
return noctalia.pluginDir() .. "/cover_" .. safe .. ".jpg" return cacheDir .. "/cover_" .. safe .. ".jpg"
end end
local function shellQuote(value) local function shellQuote(value)
@@ -75,16 +144,15 @@ local function evictCache()
end end
local function fetchLyricsNetEase(track, embeddedLyrics) local function fetchLyricsNetEase(track, embeddedLyrics)
if inFlight then return end local tk = trackKey(track)
inFlight = track if inFlight == tk then return end
inFlight = tk
local tk = track.title .. "|" .. track.artist .. "|" .. track.album
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"
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1) noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
if not inFlight then return end if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end if tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
@@ -128,7 +196,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1" local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2) noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
if not inFlight then return end if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil local lyrics = nil
@@ -150,7 +218,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
if klyricStr ~= "" then if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"' local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
noctalia.runAsync(py, function(r3) noctalia.runAsync(py, function(r3)
if not inFlight then return end if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "") local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then if ok and parsed and parsed.type == "krc" and parsed.lines then
@@ -201,7 +269,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end end
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "") local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
local qTmp = (noctalia.pluginDir() or "/tmp") .. "/.query_cache.tmp" local qTmp = cacheDir .. "/query.tmp"
noctalia.writeFile(qTmp, query) noctalia.writeFile(qTmp, query)
local dir = noctalia.pluginDir() or "/tmp" local dir = noctalia.pluginDir() or "/tmp"
@@ -228,7 +296,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local function runPy(script, cb) local function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"' local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
noctalia.runAsync(py, function(r) noctalia.runAsync(py, function(r)
if not inFlight then return end if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "") local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil) cb(ok and parsed or nil)
@@ -317,13 +385,14 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end end
local function fetchCover(track, artUrl) local function fetchCover(track, artUrl)
if coverInFlight then return end local tk = trackKey(track)
coverInFlight = track.title .. "|" .. track.artist .. "|" .. track.album if coverInFlight == tk then return end
coverInFlight = tk
local dest = coverPathFor(track) local dest = coverPathFor(track)
local apply = function(path) local apply = function(path)
local tk = track.title .. "|" .. track.artist .. "|" .. track.album if coverInFlight ~= tk then return end
coverInFlight = nil coverInFlight = nil
if tk ~= lastTrackKey then return end if tk ~= lastTrackKey then return end
coverCache[tk] = path coverCache[tk] = path
@@ -354,50 +423,69 @@ local function fetchCover(track, artUrl)
end end
local function poll() local function poll()
local cmd = [[playerctl metadata --format $'{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}' 2>/dev/null]] local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
noctalia.runAsync(cmd, function(r) noctalia.runAsync(cmd, function(r)
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
noctalia.state.set("track", nil) clearPlayerState()
noctalia.state.set("lyrics", nil)
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
return return
end end
local parts = {} local players = {}
local separator = string.char(31) local fieldSeparator = string.char(31)
for field in (r.stdout .. separator):gmatch("(.-)" .. separator) do local recordSeparator = string.char(30)
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "") for record in (r.stdout .. recordSeparator):gmatch("(.-)" .. recordSeparator) do
if record ~= "" then
local parts = {}
for field in (record .. fieldSeparator):gmatch("(.-)" .. fieldSeparator) do
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
end
local player = {
instance = parts[1] or "",
name = parts[2] or "",
status = parts[3] or "stopped",
title = parts[4] or "",
artist = parts[5] or "",
album = parts[6] or "",
position = tonumber(parts[7]) or 0,
duration = tonumber(parts[8]) or 0,
artUrl = parts[9] or "",
embeddedLyrics = parts[10] or "",
}
if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end
end
end end
local status = parts[1] or "stopped" local selected = selectPlayer(players)
local title = parts[2] or "" if not selected then
local artist = parts[3] or "" clearPlayerState()
local album = parts[4] or ""
local artUrl = parts[7] or ""
local embeddedLyrics = parts[8] or ""
if title == "" and artist == "" then
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("playing", status == "playing")
return return
end end
local playing = status == "playing" currentPlayerInstance = selected.instance
local tk = title .. "|" .. artist .. "|" .. album noctalia.state.set("player_instance", selected.instance)
noctalia.state.set("player_name", selected.name)
if selected.title == "" and selected.artist == "" then
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("playing", selected.status == "playing")
return
end
local playing = selected.status == "playing"
local t = { local t = {
title = title, title = selected.title,
artist = artist, artist = selected.artist,
album = album, album = selected.album,
status = status, status = selected.status,
position = tonumber(parts[5]) or 0, position = selected.position,
duration = tonumber(parts[6]) or 0, duration = selected.duration,
playerInstance = selected.instance,
} }
local tk = trackKey(t)
currentTrack = t currentTrack = t
currentEmbeddedLyrics = embeddedLyrics currentEmbeddedLyrics = selected.embeddedLyrics
noctalia.state.set("position", t.position) noctalia.state.set("position", t.position)
@@ -411,7 +499,7 @@ local function poll()
noctalia.state.set("lyrics", cached) noctalia.state.set("lyrics", cached)
else else
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, embeddedLyrics) fetchLyricsNetEase(t, selected.embeddedLyrics)
end end
local cachedCover = coverCache[tk] local cachedCover = coverCache[tk]
@@ -419,7 +507,7 @@ local function poll()
noctalia.state.set("cover", cachedCover) noctalia.state.set("cover", cachedCover)
else else
noctalia.state.set("cover", nil) noctalia.state.set("cover", nil)
fetchCover(t, artUrl) fetchCover(t, selected.artUrl)
end end
else else
noctalia.state.set("track", t) noctalia.state.set("track", t)
@@ -443,11 +531,22 @@ function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto" local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or "" local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics" local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
lyricsSource = nextSource lyricsSource = nextSource
customUrl = nextUrl customUrl = nextUrl
customJsonField = nextField customJsonField = nextField
if sourceChanged and currentTrack then playerAllowlist = nextAllowlist
playerBlocklist = nextBlocklist
if playersChanged then
currentPlayerInstance = ""
inFlight = nil
poll()
end
if sourceChanged and currentTrack and not playersChanged then
inFlight = nil inFlight = nil
cache = {} cache = {}
noctalia.state.set("lyrics", nil) noctalia.state.set("lyrics", nil)
@@ -460,7 +559,12 @@ local function applyPushedLyrics(payload)
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 = (decoded.track.title or "") .. "|" .. (decoded.track.artist or "") .. "|" .. (decoded.track.album or "") lastTrackKey = trackKey({
playerInstance = decoded.track.playerInstance 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
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
+38 -3
View File
@@ -1,6 +1,6 @@
id = "h465855hgg/lyrics" id = "h465855hgg/lyrics"
name = "Lyrics" name = "Lyrics"
version = "1.1.0" version = "1.3.0"
plugin_api = 3 plugin_api = 3
author = "h465855hgg" author = "h465855hgg"
license = "MIT" license = "MIT"
@@ -9,6 +9,22 @@ tags = ["bar", "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."
[[setting]]
key = "player_allowlist"
type = "string_list"
label_key = "settings.player_allowlist.label"
description_key = "settings.player_allowlist.description"
default = []
advanced = true
[[setting]]
key = "player_blocklist"
type = "string_list"
label_key = "settings.player_blocklist.label"
description_key = "settings.player_blocklist.description"
default = []
advanced = true
[[setting]] [[setting]]
key = "lyrics_source" key = "lyrics_source"
type = "select" type = "select"
@@ -47,6 +63,25 @@ label_key = "settings.cue_text.label"
description_key = "settings.cue_text.description" description_key = "settings.cue_text.description"
default = "•••••" default = "•••••"
[[setting]]
key = "cue_font_mode"
type = "select"
label_key = "settings.cue_font_mode.label"
description_key = "settings.cue_font_mode.description"
default = "follow"
options = [
{ value = "follow", label_key = "settings.cue_font_mode.options.follow" },
{ value = "custom", label_key = "settings.cue_font_mode.options.custom" },
]
[[setting]]
key = "cue_font_family"
type = "string"
label_key = "settings.cue_font_family.label"
description_key = "settings.cue_font_family.description"
default = "sans-serif"
visible_when = { key = "cue_font_mode", values = ["custom"] }
[[setting]] [[setting]]
key = "scroll_mode" key = "scroll_mode"
type = "select" type = "select"
@@ -102,7 +137,7 @@ options = [
key = "max_chars" key = "max_chars"
type = "int" type = "int"
label_key = "settings.max_chars.label" label_key = "settings.max_chars.label"
default = 24 default = 15
min = 8 min = 8
max = 80 max = 80
description_key = "settings.max_chars.description" description_key = "settings.max_chars.description"
@@ -156,7 +191,7 @@ entry = "lyrics.luau"
type = "color" type = "color"
label_key = "settings.active_color.label" label_key = "settings.active_color.label"
description_key = "settings.active_color.description" description_key = "settings.active_color.description"
default = "primary" default = "on_surface"
[[widget.setting]] [[widget.setting]]
key = "inactive_color" key = "inactive_color"
+21 -1
View File
@@ -4,6 +4,14 @@
"intro": "Intro", "intro": "Intro",
"instrumental": "Instrumental", "instrumental": "Instrumental",
"settings": { "settings": {
"player_allowlist": {
"label": "Allowed players",
"description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players."
},
"player_blocklist": {
"label": "Blocked players",
"description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist."
},
"glyph": { "glyph": {
"label": "Glyph" "label": "Glyph"
}, },
@@ -19,7 +27,7 @@
}, },
"active_color": { "active_color": {
"label": "Active lyric color", "label": "Active lyric color",
"description": "Color used for the current and already-sung lyric characters." "description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color."
}, },
"inactive_color": { "inactive_color": {
"label": "Inactive lyric color", "label": "Inactive lyric color",
@@ -49,6 +57,18 @@
"label": "Intro/interlude characters", "label": "Intro/interlude characters",
"description": "Text or characters highlighted during intros and interludes." "description": "Text or characters highlighted during intros and interludes."
}, },
"cue_font_mode": {
"label": "Intro/interlude font mode",
"description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.",
"options": {
"follow": "Follow interface font",
"custom": "Custom font"
}
},
"cue_font_family": {
"label": "Intro/interlude font",
"description": "Installed font family used only for intro and interlude characters in custom-font mode."
},
"scroll_mode": { "scroll_mode": {
"label": "Scroll mode", "label": "Scroll mode",
"description": "How lyrics scroll in the bar.", "description": "How lyrics scroll in the bar.",