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:
+12
-3
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user