diff --git a/lyrics/README.md b/lyrics/README.md index 86ce6e7..faf4729 100644 --- a/lyrics/README.md +++ b/lyrics/README.md @@ -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 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 to pause or resume the active player. Paused content is dimmed and all lyric, transition, and marquee animation stops until playback resumes. @@ -55,22 +60,26 @@ Plugin settings: | 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. | | `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. | | `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. | | `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. | | `gradient` | `bool` | `true` | Enables progressive per-character highlighting. | | `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. | | `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. | | `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. | | `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. | ## 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 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 files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and downloaded cover images are written inside the plugin runtime directory. Remote diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau index c352031..6458607 100644 --- a/lyrics/lyrics.luau +++ b/lyrics/lyrics.luau @@ -8,20 +8,23 @@ local showArtist = noctalia.getConfig("show_artist") local hideWhenPaused = noctalia.getConfig("hide_when_paused") local showCover = noctalia.getConfig("show_cover") 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" -- These are plugin settings, not per-widget settings. local scrollMode = noctalia.getConfig("scroll_mode") or "auto" local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30 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 gradientOn = noctalia.getConfig("gradient") if gradientOn == nil then gradientOn = true end local animation = noctalia.getConfig("animation") or "karaoke" local cueText = noctalia.getConfig("cue_text") or "•••••" 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 lyrics = nil @@ -29,6 +32,7 @@ local cover = nil local playing = false local showMode = "auto" local rendered = false +local playerInstance = "" local baselinePosition = 0 local baselineClock = os.clock() @@ -48,6 +52,10 @@ local INTERLUDE_GAP_MS = 7000 local INTRO_MIN_MS = 6000 local INTERLUDE_MIN_MS = 8000 +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + local function clamp(value, low, high) return math.min(high, math.max(low, value)) end @@ -91,7 +99,7 @@ local function getLineInfo() return { key = "intro", index = 0, - line = { text = cueText }, + line = { text = cueText, cue = true }, progress = clamp(position / firstTime, 0, 1), synced = true, position = position, @@ -130,7 +138,7 @@ local function getLineInfo() return { key = "interlude-" .. tostring(index), index = index, - line = { text = cueText }, + line = { text = cueText, cue = true }, progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1), synced = true, position = position, @@ -221,6 +229,7 @@ local function buildLineRow(line, opts) opts = opts or {} local text = type(line) == "table" and line.text or line 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 "") if #chars == 0 then chars = { " " } end @@ -273,6 +282,7 @@ local function buildLineRow(line, opts) color = color, opacity = alpha, maxLines = 1, + fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil, }) end @@ -344,6 +354,7 @@ local function render() cascade = shownLine == info.line and animation == "cascade", wave = shownLine == info.line and animation == "wave", opacity = opacity, + cue = shownLine == info.line and info.cue == true, }) end @@ -391,7 +402,8 @@ function onClick() end 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 local wasPlaying = playing if wasPlaying then baselinePosition = getProgressMs() * 1000 end @@ -412,6 +424,7 @@ function update() lyrics = noctalia.state.get("lyrics") cover = noctalia.state.get("cover") playing = noctalia.state.get("playing") == true + playerInstance = noctalia.state.get("player_instance") or "" local info = getLineInfo() local nextKey = info and info.key or "fallback" diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau index 119add0..4953c47 100644 --- a/lyrics/lyrics_service.luau +++ b/lyrics/lyrics_service.luau @@ -10,16 +10,85 @@ local coverCache = {} local lastTrackKey = "" local inFlight = 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 customUrl = noctalia.getConfig("custom_url") or "" local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" local currentTrack = nil 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 safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_") - return noctalia.pluginDir() .. "/cover_" .. safe .. ".jpg" + return cacheDir .. "/cover_" .. safe .. ".jpg" end local function shellQuote(value) @@ -75,16 +144,15 @@ local function evictCache() end local function fetchLyricsNetEase(track, embeddedLyrics) - if inFlight then return end - inFlight = track - - local tk = track.title .. "|" .. track.artist .. "|" .. track.album + local tk = trackKey(track) + if inFlight == tk then return end + inFlight = tk local function tryFetch(query, fallback) 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) - if not inFlight then return end + if inFlight ~= tk then 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 @@ -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" 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 local lyrics = nil @@ -150,7 +218,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) if klyricStr ~= "" then local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"' 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 local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "") if ok and parsed and parsed.type == "krc" and parsed.lines then @@ -201,7 +269,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) end 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) local dir = noctalia.pluginDir() or "/tmp" @@ -228,7 +296,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics) local function runPy(script, cb) local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"' 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 local ok, parsed = pcall(noctalia.json.decode, r.stdout or "") cb(ok and parsed or nil) @@ -317,13 +385,14 @@ local function fetchLyricsNetEase(track, embeddedLyrics) end local function fetchCover(track, artUrl) - if coverInFlight then return end - coverInFlight = track.title .. "|" .. track.artist .. "|" .. track.album + local tk = trackKey(track) + if coverInFlight == tk then return end + coverInFlight = tk local dest = coverPathFor(track) local apply = function(path) - local tk = track.title .. "|" .. track.artist .. "|" .. track.album + if coverInFlight ~= tk then return end coverInFlight = nil if tk ~= lastTrackKey then return end coverCache[tk] = path @@ -354,50 +423,69 @@ local function fetchCover(track, artUrl) end 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) if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then - noctalia.state.set("track", nil) - noctalia.state.set("lyrics", nil) - noctalia.state.set("cover", nil) - noctalia.state.set("playing", false) + clearPlayerState() return end - local parts = {} - local separator = string.char(31) - for field in (r.stdout .. separator):gmatch("(.-)" .. separator) do - parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "") + local players = {} + local fieldSeparator = string.char(31) + local recordSeparator = string.char(30) + 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 - local status = parts[1] or "stopped" - local title = parts[2] or "" - local artist = parts[3] or "" - 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") + local selected = selectPlayer(players) + if not selected then + clearPlayerState() return end - local playing = status == "playing" - local tk = title .. "|" .. artist .. "|" .. album + currentPlayerInstance = selected.instance + 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 = { - title = title, - artist = artist, - album = album, - status = status, - position = tonumber(parts[5]) or 0, - duration = tonumber(parts[6]) or 0, + title = selected.title, + artist = selected.artist, + album = selected.album, + status = selected.status, + position = selected.position, + duration = selected.duration, + playerInstance = selected.instance, } + local tk = trackKey(t) currentTrack = t - currentEmbeddedLyrics = embeddedLyrics + currentEmbeddedLyrics = selected.embeddedLyrics noctalia.state.set("position", t.position) @@ -411,7 +499,7 @@ local function poll() noctalia.state.set("lyrics", cached) else noctalia.state.set("lyrics", nil) - fetchLyricsNetEase(t, embeddedLyrics) + fetchLyricsNetEase(t, selected.embeddedLyrics) end local cachedCover = coverCache[tk] @@ -419,7 +507,7 @@ local function poll() noctalia.state.set("cover", cachedCover) else noctalia.state.set("cover", nil) - fetchCover(t, artUrl) + fetchCover(t, selected.artUrl) end else noctalia.state.set("track", t) @@ -443,11 +531,22 @@ function onConfigChanged() local nextSource = noctalia.getConfig("lyrics_source") or "auto" local nextUrl = noctalia.getConfig("custom_url") or "" 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 playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n") + or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n") lyricsSource = nextSource customUrl = nextUrl 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 cache = {} noctalia.state.set("lyrics", nil) @@ -460,7 +559,12 @@ local function applyPushedLyrics(payload) if type(decoded) == "table" then if decoded.track then 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) end if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml index 6f7837b..ec8f2bc 100644 --- a/lyrics/plugin.toml +++ b/lyrics/plugin.toml @@ -1,6 +1,6 @@ id = "h465855hgg/lyrics" name = "Lyrics" -version = "1.1.0" +version = "1.3.0" plugin_api = 3 author = "h465855hgg" license = "MIT" @@ -9,6 +9,22 @@ tags = ["bar", "service", "music", "media", "animation"] icon = "music" 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]] key = "lyrics_source" type = "select" @@ -47,6 +63,25 @@ label_key = "settings.cue_text.label" description_key = "settings.cue_text.description" 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]] key = "scroll_mode" type = "select" @@ -102,7 +137,7 @@ options = [ key = "max_chars" type = "int" label_key = "settings.max_chars.label" -default = 24 +default = 15 min = 8 max = 80 description_key = "settings.max_chars.description" @@ -156,7 +191,7 @@ entry = "lyrics.luau" type = "color" label_key = "settings.active_color.label" description_key = "settings.active_color.description" - default = "primary" + default = "on_surface" [[widget.setting]] key = "inactive_color" diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json index ac28055..ed3f66c 100644 --- a/lyrics/translations/en.json +++ b/lyrics/translations/en.json @@ -4,6 +4,14 @@ "intro": "Intro", "instrumental": "Instrumental", "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": { "label": "Glyph" }, @@ -19,7 +27,7 @@ }, "active_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": { "label": "Inactive lyric color", @@ -49,6 +57,18 @@ "label": "Intro/interlude characters", "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": { "label": "Scroll mode", "description": "How lyrics scroll in the bar.",