Files
community-plugins/spotify-lyrics/panel.luau
T
e439ccd102 spotify-lyrics (#214)
* Add spotify-lyrics plugin

* fix(spotify-lyrics): resolve race conditions and add plugin_api

* fix(spotify-lyrics): update plugin_api to 3

* fix(spotify-lyrics): resolve github actions validation errors

* fix(spotify-lyrics): resize thumbnail to 960x540 to fix validation error

* fix(spotify-lyrics): bump version to 1.2.1

* fix(spotify-lyrics): update namespace and replace misleading thumbnail

* fix(spotify-lyrics): declare runtime dependencies in plugin.toml and update README requirements

* feat(lyrics): implement dynamic panel width sizing

* Revert "feat(lyrics): implement dynamic panel width sizing"

This reverts commit ef91e6f9f688df7deeb41ba9e5c7606a4904b47a.

* feat(spotify-lyrics): implement dynamic panel width sizing

* fix(spotify-lyrics): correct target width pre-calculation for upcoming lines

* fix(spotify-lyrics): prevent vertical spill by enforcing maxLines=1

* fix(spotify-lyrics): implement dynamic height resizing to encapsulate wrapped text

* fix(spotify-lyrics): remove horizontal cap to prevent vertical spill

* fix(spotify-lyrics): restore minHeight and implement perfectly safe wrapping height prediction

* fix(spotify-lyrics): lock panel width and use vertical dynamic resizing exclusively

* fix(spotify-lyrics): implement dynamic font scaling and remove panel dimension animations

* fix(spotify-lyrics): restore robust dynamic height logic and discard font scaling

* refactor(spotify-lyrics): rewrite height estimation and clean up codebase

Root cause: the charUnits per-character width estimation consistently
underestimated real rendered widths because the 0.80 multipliers in
getLineWidth and getLinesCount cancelled each other out, making the
effective calculation ignore the safety margin entirely.

Fix: replaced the complex charUnits/toChars/getLineWidth machinery with
a simple #text / chars-per-line heuristic using a conservative 0.60x
character width factor. This reliably overestimates line count, ensuring
the panel always allocates enough height for wrapped text.

Quality of life improvements:
- Split monolithic render() into renderEmpty/renderPaused/renderPlaying
- Reduced update interval from 33ms (30 FPS) to 100ms (10 FPS)
- Removed file-read timer (reads every frame at lower FPS instead)
- Added clear section headers and inline documentation
- Removed all dead code (charUnits, toChars, getLineWidth, etc.)

* fix(spotify-lyrics): set panel height=280 in plugin.toml — the actual fix

The root cause of the lyrics spilling was never in the Lua code.
Noctalia panels are sized exclusively by plugin.toml, not by minHeight
on the column layout. Since we had removed width/height from plugin.toml
to make sizing 'dynamic', noctalia used a tiny default that couldn't
contain wrapped lyrics. minHeight on ui.column had zero effect on the
actual panel window size.

Set height=280 to comfortably fit 3 lyrics lines even when they wrap.

* feat(spotify-lyrics): add dynamic font scaling for long lyrics

Long lyrics (>40 chars) now get progressively smaller fonts:
- Every 15 chars beyond 40 reduces font by 2px
- Minimum font: 10px (panel) / 11px (widget)

This prevents vertical overflow regardless of container size by
ensuring long lines take up less vertical space when they wrap.

* fix(spotify-lyrics): fix plugin IDs and tilde path expansion

- Updated bar.luau to toggle correct panel ID
- Replaced ~ in noctalia.readFile with absolute path since Lua doesn't auto-expand it
- Updated plugin.toml height to 280 and id to noctalia/spotify-lyrics

* Fix UI bugs, implement reactive updates, and add album art

* Fix plugin manifest validation errors

* fix(spotify-lyrics): address PR review comments

- Change plugin id from noctalia/ to goatnath/ namespace
- Declare runtime dependencies: playerctl, python3, syncedlyrics
- Replace hardcoded /home/goatnath path with noctalia.expandPath()
- Add [[desktop_widget]] manifest entry for widget.luau
- Rewrite README to follow README_TEMPLATE.md structure
- Update all references to use corrected plugin id

---------

Co-authored-by: goatnath <aadinathkeshav1978@gmail.com>
2026-08-06 10:05:22 -04:00

224 lines
7.3 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!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