Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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
66 changes: 66 additions & 0 deletions spotify-lyrics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Noctalia Synced Lyrics Plugin

A seamless, time-synced scrolling lyrics panel for the Noctalia desktop shell. It integrates directly into your Noctalia bar and displays a beautifully formatted, auto-scrolling lyrics card when you click the `♫` icon.

## Why it's great
* **Zero Configuration:** No Spotify `sp_dc` cookies, API keys, or web scraping required! It pulls lyrics from public databases like LRCLIB and NetEase automatically.
* **Blazing Fast:** Uses a lightweight Python background daemon that caches lyrics to your disk so subsequent plays load in 0ms.
* **Native Shell Integration:** Doesn't feel like a clunky third-party app. It uses Noctalia's native declarative UI framework for buttery smooth, theme-aware rendering.

## Plugin
- **Id:** `noctalia/spotify-lyrics`
- **Widgets:**
- `lyrics`: The bar icon that toggles the lyrics panel.
- **Panels:**
- `lyrics-panel`: The scrolling lyrics panel.
- To toggle it manually via IPC, run: `noctalia msg panel-toggle noctalia/spotify-lyrics:lyrics-panel`

## Requirements

This plugin requires `playerctl`, `python3`, and `syncedlyrics`.
```bash
# Arch Linux
sudo pacman -S playerctl python
pip install syncedlyrics
```

## Usage

### 1. Set up the Background Daemon
The daemon listens to your media player (Spotify, MPD, etc.) and fetches the lyrics.

1. Copy the `spotify_lyrics_daemon.py` file to your preferred location (e.g., `~/.local/bin/`).
2. Set it up to run in the background. The recommended way is using a systemd user service:

```ini
# ~/.config/systemd/user/noctalia-lyrics.service
[Unit]
Description=Noctalia Lyrics Daemon
After=graphical-session.target

[Service]
ExecStart=/usr/bin/python3 /path/to/spotify_lyrics_daemon.py
Restart=always

[Install]
WantedBy=default.target
```
Start and enable the daemon:
```bash
systemctl --user daemon-reload
systemctl --user enable --now noctalia-lyrics.service
```

### 3. Enable the Noctalia Plugin
1. Install this plugin from the plugin manager or download the folder to `~/.local/share/noctalia/plugins/spotify-lyrics/`.
2. Enable the plugin via CLI:
```bash
noctalia msg plugins enable noctalia/spotify-lyrics
```
3. 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" ]
```

That's it! Play a song on Spotify and a `♫` icon will appear in your bar. Click it to view the synced lyrics.
59 changes: 59 additions & 0 deletions spotify-lyrics/bar.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
--!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 function readState()
local content = noctalia.readFile("/home/goatnath/.cache/noctalia/lyrics/current.json")
if not content then return nil end
local state, _ = noctalia.json.decode(content)
return state
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)

if not state or state.status == "Stopped" or state.status == "" then
barWidget.setVisible(false)
return
end

barWidget.setVisible(true)
barWidget.setGlyph("music")
barWidget.setText("")
barWidget.setGlyphColor("primary")

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

function onClick()
noctalia.togglePanel("noctalia/spotify-lyrics:lyrics-panel")
end
223 changes: 223 additions & 0 deletions spotify-lyrics/panel.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
--!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.

--------------------------------------------------------------------------------
-- Layout constants
--------------------------------------------------------------------------------
local PANEL_WIDTH = 460
local PANEL_PADDING = 16
local INNER_WIDTH = PANEL_WIDTH - PANEL_PADDING * 2 -- 428px usable
local PANEL_HEIGHT = 280 -- matches plugin.toml height exactly
local ART_SIZE = 80 -- album art thumbnail size

--------------------------------------------------------------------------------
-- Dynamic font sizing
--------------------------------------------------------------------------------

local function fitFontSize(text, defaultSize)
if not text or text == "" then return defaultSize end
local len = #text
if len <= 40 then return defaultSize end
local reduction = math.floor((len - 40) / 15) * 2
return math.max(10, defaultSize - reduction)
end

--------------------------------------------------------------------------------
-- Text truncation
--------------------------------------------------------------------------------

local function clampText(text, fontSize, maxLines)
if not text or text == "" then return text end
local charsPerLine = math.max(1, math.floor(INNER_WIDTH / (fontSize * 0.60)))
local maxChars = charsPerLine * maxLines
if #text > maxChars then
return string.sub(text, 1, maxChars - 1) .. "…"
end
return text
end

--------------------------------------------------------------------------------
-- Rendering
--------------------------------------------------------------------------------

local function renderEmpty()
panel.render(ui.column({
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" }),
}),
ui.label({
text = "No music playing",
fontSize = 14, fontWeight = "medium",
color = "on_surface/0.25", textAlign = "center",
}),
}))
end

local function renderPaused(title, artist, current, artPath)
local headerChildren = {}

-- Album art (if available)
if artPath ~= "" then
table.insert(headerChildren, ui.image({
path = artPath,
width = 60, height = 60,
cornerRadius = 8,
}))
else
table.insert(headerChildren, ui.glyph({ name = "player-pause", size = 22, color = "on_surface/0.3" }))
end

-- Title + artist beside the art
table.insert(headerChildren, ui.column({ gap = 2, flexGrow = 1, flexShrink = 1 }, {
ui.label({
text = title or "Paused",
fontSize = 13, fontWeight = "bold",
color = "on_surface/0.5", wrap = true, maxLines = 1,
}),
ui.label({
text = artist or "",
fontSize = 11, fontWeight = "medium",
color = "on_surface/0.35", wrap = true, maxLines = 1,
}),
}))

panel.render(ui.column({
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({
text = current or "Paused",
fontSize = 18, fontWeight = "bold",
color = "on_surface/0.6", textAlign = "center", wrap = true,
}),
}))
end

local function renderPlaying(title, artist, prev, current, nextLine, artPath)
local rows = {}

-- ── Track header: album art + song info ──
if title ~= "" and artist ~= "" then
local headerChildren = {}

-- Album art
if artPath ~= "" then
table.insert(headerChildren, ui.image({
path = artPath,
width = ART_SIZE, height = ART_SIZE,
cornerRadius = 8,
}))
end

-- Title + artist stacked vertically, beside the art
table.insert(headerChildren, ui.column({ gap = 2, flexGrow = 1, flexShrink = 1 }, {
ui.label({
text = title,
fontSize = 13, fontWeight = "bold",
color = "on_surface/0.9", wrap = true, maxLines = 2,
}),
ui.label({
text = artist,
fontSize = 11, fontWeight = "medium",
color = "primary/0.7", wrap = true, maxLines = 1,
}),
}))

table.insert(rows, ui.row({ gap = 12, align = "center" }, headerChildren))
table.insert(rows, ui.box({ height = 1, fill = "on_surface/0.06" }))
end

-- ── Previous lyric ──
if prev ~= "" then
local prevSize = fitFontSize(prev, 14)
table.insert(rows, ui.label({
text = clampText(prev, prevSize, 2),
fontSize = prevSize, fontWeight = "medium",
color = "on_surface/0.4", textAlign = "center", wrap = true,
maxLines = 2,
}))
else
table.insert(rows, ui.box({ height = 16 }))
end

-- ── Current lyric ──
local currentText = current
if currentText == "" then currentText = "..." end
local currentSize = fitFontSize(currentText, 18)
table.insert(rows, ui.label({
text = clampText(currentText, currentSize, 3),
fontSize = currentSize, fontWeight = "bold",
color = "on_surface/0.95", textAlign = "center", wrap = true,
maxLines = 3,
}))

-- ── Next lyric ──
if nextLine ~= "" then
local nextSize = fitFontSize(nextLine, 14)
table.insert(rows, ui.label({
text = clampText(nextLine, nextSize, 2),
fontSize = nextSize, fontWeight = "medium",
color = "on_surface/0.4", textAlign = "center", wrap = true,
maxLines = 2,
}))
else
table.insert(rows, ui.box({ height = 16 }))
end

panel.render(ui.column({
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
--------------------------------------------------------------------------------

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
renderEmpty()
elseif status == "Paused" then
renderPaused(title, artist, current, artPath)
else
renderPlaying(title, artist, prev, current, nextLine, artPath)
end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function onOpen()
renderFromState()

noctalia.state.watch("lyricsTick", function(_tick)
renderFromState()
end)
end
23 changes: 23 additions & 0 deletions spotify-lyrics/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
id = "noctalia/spotify-lyrics"
name = "Spotify Lyrics"
version = "1.2.0"
plugin_api = 3
author = "goatnath"
license = "MIT"
dependencies = []
tags = ["music"]
icon = "music"
description = "Time-synced lyrics panel linked to the media widget."

# Minimal bar trigger: tiny glyph icon next to the media widget.
# Auto-hides when nothing is playing. Click to open the lyrics panel.
[[widget]]
id = "lyrics"
entry = "bar.luau"

# Lyrics panel: 3-line synced view anchored near the media area.
[[panel]]
id = "lyrics-panel"
entry = "panel.luau"
width = 460
height = 280
Loading