Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f4e3220
Add spotify-lyrics plugin
Jul 12, 2026
d35264f
Merge branch 'noctalia-dev:main' into main
goatnath Jul 20, 2026
5278a33
fix(spotify-lyrics): resolve race conditions and add plugin_api
Jul 20, 2026
afd281e
fix(spotify-lyrics): update plugin_api to 3
Jul 20, 2026
afc2aff
fix(spotify-lyrics): resolve github actions validation errors
Jul 20, 2026
ca6b823
fix(spotify-lyrics): resize thumbnail to 960x540 to fix validation error
Jul 20, 2026
552775a
fix(spotify-lyrics): bump version to 1.2.1
Jul 21, 2026
202cb25
Merge branch 'noctalia-dev:main' into main
goatnath Aug 2, 2026
85b6c42
fix(spotify-lyrics): update namespace and replace misleading thumbnail
Aug 2, 2026
2b754fa
fix(spotify-lyrics): declare runtime dependencies in plugin.toml and …
Aug 3, 2026
ef91e6f
feat(lyrics): implement dynamic panel width sizing
Aug 3, 2026
8aa4ec5
Revert "feat(lyrics): implement dynamic panel width sizing"
Aug 3, 2026
9299951
feat(spotify-lyrics): implement dynamic panel width sizing
Aug 3, 2026
41fbe58
fix(spotify-lyrics): correct target width pre-calculation for upcomin…
Aug 3, 2026
d653000
fix(spotify-lyrics): prevent vertical spill by enforcing maxLines=1
Aug 3, 2026
8031fcb
fix(spotify-lyrics): implement dynamic height resizing to encapsulate…
Aug 3, 2026
d5a26bb
fix(spotify-lyrics): remove horizontal cap to prevent vertical spill
Aug 5, 2026
761fa99
fix(spotify-lyrics): restore minHeight and implement perfectly safe w…
Aug 5, 2026
4fbbf53
fix(spotify-lyrics): lock panel width and use vertical dynamic resizi…
Aug 5, 2026
9745026
fix(spotify-lyrics): implement dynamic font scaling and remove panel …
Aug 5, 2026
06178ee
fix(spotify-lyrics): restore robust dynamic height logic and discard …
Aug 5, 2026
4928206
refactor(spotify-lyrics): rewrite height estimation and clean up code…
Aug 5, 2026
576655e
fix(spotify-lyrics): set panel height=280 in plugin.toml — the actual…
Aug 5, 2026
479335b
feat(spotify-lyrics): add dynamic font scaling for long lyrics
Aug 5, 2026
ec20ac2
fix(spotify-lyrics): fix plugin IDs and tilde path expansion
Aug 5, 2026
aa950a7
Fix UI bugs, implement reactive updates, and add album art
Aug 5, 2026
cb6c84c
Fix plugin manifest validation errors
Aug 5, 2026
d29e6fc
fix(spotify-lyrics): address PR review comments
Aug 6, 2026
d0e8d6e
fix(spotify-lyrics): add headless service, direct
Aug 28, 2026
3c0c5e6
Merge branch 'main' into fix/spotify-lyrics-service-and-state
goatnath Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions spotify-lyrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A seamless, time-synced scrolling lyrics panel for the Noctalia desktop shell. I
| Field | Value |
| --- | --- |
| ID | `goatnath/spotify-lyrics` |
| Entries | Bar widget: `lyrics`; panel: `lyrics-panel`; desktop widget: `lyrics-desktop` |
| Entries | Service: `service`; bar widget: `lyrics`; panel: `lyrics-panel`; desktop widget: `lyrics-desktop` |

## Requirements

Expand Down Expand Up @@ -58,7 +58,7 @@ systemctl --user enable --now noctalia-lyrics.service
noctalia msg plugins enable goatnath/spotify-lyrics
```

3. Add the `lyrics` widget to your bar's layout in your `~/.local/state/noctalia/settings.toml` (next to the `media` widget).
3. (Optional) Add the `lyrics` widget to your bar's layout in your `~/.local/state/noctalia/settings.toml` (next to the `media` widget).

```toml
start = [ "launcher", "workspaces", "media", "lyrics" ]
Expand Down
51 changes: 17 additions & 34 deletions spotify-lyrics/bar.luau
Original file line number Diff line number Diff line change
@@ -1,49 +1,33 @@
--!nonstrict
-- Minimal bar trigger: shows a small lyrics glyph next to the media widget.
-- Auto-hides when no music is playing. Click to toggle the lyrics panel.
--
-- Also acts as the data bridge: reads the daemon's JSON file and publishes
-- all lyrics fields into noctalia.state so the panel can reactively consume
-- them without polling the filesystem itself.

local STATE_PATH = noctalia.expandPath("~/.cache/noctalia/lyrics/current.json")

local function readState()
local content = noctalia.readFile(STATE_PATH)
if not content then return nil end
local state, _ = noctalia.json.decode(content)
return state
if not content or content == "" then return nil end
local ok, state = pcall(function() return noctalia.json.decode(content) end)
if ok and type(state) == "table" then return state end
return nil
end

local tickCount = 0

function update()
noctalia.setUpdateInterval(100)
tickCount = tickCount + 1

local state = readState()

-- Publish every field the panel/widget needs into noctalia.state.
-- Each .set() call notifies any panel that called .get() on the same key,
-- which is what makes the panel re-render reactively.
if state then
noctalia.state.set("lyricsStatus", state.status or "")
noctalia.state.set("lyricsTitle", state.title or "")
noctalia.state.set("lyricsArtist", state.artist or "")
noctalia.state.set("lyricsPrevPrev", state.prev_prev or "")
noctalia.state.set("lyricsPrev", state.prev or "")
noctalia.state.set("lyricsCurrent", state.current or "")
noctalia.state.set("lyricsNext", state.next or "")
noctalia.state.set("lyricsNextNext", state.next_next or "")
noctalia.state.set("lyricsArtPath", state.art_path or "")
else
noctalia.state.set("lyricsStatus", "")
end

-- Bump tick last so the panel can also use it as a generic change signal
noctalia.state.set("lyricsTick", tickCount)
local status = noctalia.state.get("lyricsStatus")
local current = noctalia.state.get("lyricsCurrent")

if not state or state.status == "Stopped" or state.status == "" then
-- Fallback to direct file read if service has not published yet
if not status or status == "" then
local fileState = readState()
if fileState then
status = fileState.status or ""
current = fileState.current or ""
end
end

if not status or status == "Stopped" or status == "" then
barWidget.setVisible(false)
return
end
Expand All @@ -52,8 +36,7 @@ function update()
barWidget.setGlyph("music")
barWidget.setText("")
barWidget.setGlyphColor("primary")

barWidget.setTooltip(state.current or "...")
barWidget.setTooltip(current or "...")
end

function onClick()
Expand Down
79 changes: 56 additions & 23 deletions spotify-lyrics/panel.luau
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
--!nonstrict
-- Spotify Lyrics Panel – 3-line synced lyrics view with album art.
--
-- Reads state from noctalia.state (published by bar.luau) and renders
-- album art + prev / current / next lyric lines reactively via
-- noctalia.state.watch().
--
-- NOTE: Panels do NOT get update() called on a timer. Only bar widgets,
-- desktop widgets, and services receive update(). Panels must use
-- noctalia.state.watch() or onFrameTick for live updates.
-- Reads state from noctalia.state (published by service.luau) or directly
-- from disk fallback, and renders album art + prev / current / next lyric lines.

local STATE_PATH = noctalia.expandPath("~/.cache/noctalia/lyrics/current.json")

--------------------------------------------------------------------------------
-- Layout constants
Expand Down Expand Up @@ -44,6 +41,51 @@ local function clampText(text, fontSize, maxLines)
return text
end

--------------------------------------------------------------------------------
-- State Reader
--------------------------------------------------------------------------------

local function readStateFromFile()
local content = noctalia.readFile(STATE_PATH)
if not content or content == "" then return nil end
local ok, state = pcall(function() return noctalia.json.decode(content) end)
if ok and type(state) == "table" then return state end
return nil
end

local function getState()
local status = noctalia.state.get("lyricsStatus")
if status and status ~= "" then
return {
status = status,
title = noctalia.state.get("lyricsTitle") or "",
artist = noctalia.state.get("lyricsArtist") or "",
prev = noctalia.state.get("lyricsPrev") or "",
current = noctalia.state.get("lyricsCurrent") or "",
next = noctalia.state.get("lyricsNext") or "",
art_path = noctalia.state.get("lyricsArtPath") or "",
}
end

local fileState = readStateFromFile()
if fileState then
return {
status = fileState.status or "",
title = fileState.title or "",
artist = fileState.artist or "",
prev = fileState.prev or "",
current = fileState.current or "",
next = fileState.next or "",
art_path = fileState.art_path or "",
}
end

return {
status = "", title = "", artist = "",
prev = "", current = "", next = "", art_path = ""
}
end

--------------------------------------------------------------------------------
-- Rendering
--------------------------------------------------------------------------------
Expand All @@ -53,7 +95,6 @@ local function renderEmpty()
flexGrow = 1, gap = 8, align = "stretch", justify = "center",
padding = PANEL_PADDING,
minWidth = PANEL_WIDTH, height = PANEL_HEIGHT,
overflow = "hidden",
}, {
ui.row({ justify = "center" }, {
ui.glyph({ name = "music", size = 28, color = "on_surface/0.2" }),
Expand Down Expand Up @@ -98,7 +139,6 @@ local function renderPaused(title, artist, current, artPath)
flexGrow = 1, gap = 8, align = "stretch", justify = "center",
padding = PANEL_PADDING,
minWidth = PANEL_WIDTH, height = PANEL_HEIGHT,
overflow = "hidden",
}, {
ui.row({ gap = 12, align = "center", justify = "center" }, headerChildren),
ui.label({
Expand Down Expand Up @@ -184,29 +224,22 @@ local function renderPlaying(title, artist, prev, current, nextLine, artPath)
flexGrow = 1, gap = 8, align = "stretch", justify = "center",
padding = PANEL_PADDING,
minWidth = PANEL_WIDTH, height = PANEL_HEIGHT,
overflow = "hidden",
}, rows))
end

--------------------------------------------------------------------------------
-- Full re-render from current noctalia.state snapshot
-- Full re-render
--------------------------------------------------------------------------------

local function renderFromState()
local status = noctalia.state.get("lyricsStatus") or ""
local title = noctalia.state.get("lyricsTitle") or ""
local artist = noctalia.state.get("lyricsArtist") or ""
local prev = noctalia.state.get("lyricsPrev") or ""
local current = noctalia.state.get("lyricsCurrent") or ""
local nextLine = noctalia.state.get("lyricsNext") or ""
local artPath = noctalia.state.get("lyricsArtPath") or ""

if status == "" or status == "Stopped" then
local state = getState()

if state.status == "" or state.status == "Stopped" then
renderEmpty()
elseif status == "Paused" then
renderPaused(title, artist, current, artPath)
elseif state.status == "Paused" then
renderPaused(state.title, state.artist, state.current, state.art_path)
else
renderPlaying(title, artist, prev, current, nextLine, artPath)
renderPlaying(state.title, state.artist, state.prev, state.current, state.next, state.art_path)
end
end

Expand Down
9 changes: 7 additions & 2 deletions spotify-lyrics/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
id = "goatnath/spotify-lyrics"
name = "Spotify Lyrics"
version = "1.2.0"
version = "1.2.1"
plugin_api = 3
author = "goatnath"
license = "MIT"
dependencies = ["playerctl", "python3", "syncedlyrics"]
tags = ["music"]
tags = ["music", "bar", "panel", "desktop", "service"]
icon = "music"
description = "Time-synced lyrics panel linked to the media widget."

# Headless background service: reads daemon state and publishes to noctalia.state
[[service]]
id = "service"
entry = "service.luau"

# Minimal bar trigger: tiny glyph icon next to the media widget.
# Auto-hides when nothing is playing. Click to open the lyrics panel.
[[widget]]
Expand Down
38 changes: 38 additions & 0 deletions spotify-lyrics/service.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
--!nonstrict
-- Spotify Lyrics Headless Service
-- Reads state written by the Python daemon and publishes to noctalia.state
-- so bar widgets, panels, and desktop widgets update reactively across any compositor.

local STATE_PATH = noctalia.expandPath("~/.cache/noctalia/lyrics/current.json")
local tickCount = 0

local function readState()
local content = noctalia.readFile(STATE_PATH)
if not content or content == "" then return nil end
local ok, state = pcall(function() return noctalia.json.decode(content) end)
if ok and type(state) == "table" then return state end
return nil
end

function update()
noctalia.setUpdateInterval(100)
tickCount = tickCount + 1

local state = readState()

if state then
noctalia.state.set("lyricsStatus", state.status or "")
noctalia.state.set("lyricsTitle", state.title or "")
noctalia.state.set("lyricsArtist", state.artist or "")
noctalia.state.set("lyricsPrevPrev", state.prev_prev or "")
noctalia.state.set("lyricsPrev", state.prev or "")
noctalia.state.set("lyricsCurrent", state.current or "")
noctalia.state.set("lyricsNext", state.next or "")
noctalia.state.set("lyricsNextNext", state.next_next or "")
noctalia.state.set("lyricsArtPath", state.art_path or "")
else
noctalia.state.set("lyricsStatus", "")
end

noctalia.state.set("lyricsTick", tickCount)
end
33 changes: 22 additions & 11 deletions spotify-lyrics/spotify_lyrics_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def load_lrc_file(self, lrc_file):
return []

def get_album_art_path(self, art_url):
"""Download album art from URL and return local cached file path."""
"""Download album art from URL or resolve local file path, returning local path."""
if not art_url or art_url == "":
return ""

Expand All @@ -113,6 +113,13 @@ def get_album_art_path(self, art_url):
if os.path.exists(path):
return path

# Check if local file:// URL
if art_url.startswith("file://"):
local_file = urllib.request.url2pathname(art_url[7:])
if os.path.exists(local_file):
self.art_cache[art_url] = local_file
return local_file

# Derive a stable filename from the URL hash
url_hash = hashlib.md5(art_url.encode()).hexdigest()
ext = ".jpg" # Spotify art is always JPEG
Expand Down Expand Up @@ -158,22 +165,26 @@ def get_player_status(self):
if not players:
return None

# Prioritize Spotify
player_name = "spotify" if "spotify" in players else players[0]
# Prioritize Spotify (handling instance names like spotify.instance1 or Flatpak)
spotify_player = next((p for p in players if "spotify" in p.lower()), None)
player_name = spotify_player if spotify_player else players[0]

# Query all metadata in ONE execution using custom delimiters to eliminate subprocess latency
# Query all metadata in ONE execution using unit separator (\x1f) delimiter
output = subprocess.check_output([
"playerctl", "-p", player_name, "metadata",
"--format", "{{status}}|||{{position}}|||{{title}}|||{{artist}}|||{{mpris:artUrl}}"
"--format", "{{status}}\x1f{{position}}\x1f{{title}}\x1f{{artist}}\x1f{{mpris:artUrl}}"
], stderr=subprocess.DEVNULL).decode("utf-8").strip()

parts = output.split("|||")
parts = output.split("\x1f")
if len(parts) >= 4:
status, pos_us, title, artist = parts[0], parts[1], parts[2], parts[3]
status, pos_raw, title, artist = parts[0], parts[1], parts[2], parts[3]
art_url = parts[4] if len(parts) >= 5 else ""

# Position is in microseconds (us), convert to milliseconds (ms)
position_ms = int(int(pos_us) / 1000)
# Position is in microseconds (us), convert safely to milliseconds (ms)
try:
position_ms = int(float(pos_raw) / 1000) if pos_raw else 0
except (ValueError, TypeError):
position_ms = 0

return {
"status": status,
Expand Down Expand Up @@ -239,15 +250,15 @@ def run(self):
"art_path": art_path
}

# Save state
# Save state atomically
tmp_file = CURRENT_STATE_FILE.with_suffix('.tmp')
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(state, f)
tmp_file.replace(CURRENT_STATE_FILE)

# Update more frequently if playing to maintain tight sync
if player["status"] == "Playing":
time.sleep(0.3) # Reduce polling frequency to prevent massive OS subprocess leak
time.sleep(0.3)
else:
time.sleep(1.0)

Expand Down
2 changes: 1 addition & 1 deletion spotify-lyrics/translations/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"description": "Time-synced lyrics panel linked to the media widget.",
"name": "Spotify Lyrics",
"description": "Time-synced lyrics panel linked to the media widget.",
"no_music": "No music playing"
}
Loading