diff --git a/spotify-lyrics/README.md b/spotify-lyrics/README.md new file mode 100644 index 00000000..9b098d30 --- /dev/null +++ b/spotify-lyrics/README.md @@ -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. diff --git a/spotify-lyrics/bar.luau b/spotify-lyrics/bar.luau new file mode 100644 index 00000000..57eb8851 --- /dev/null +++ b/spotify-lyrics/bar.luau @@ -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 diff --git a/spotify-lyrics/panel.luau b/spotify-lyrics/panel.luau new file mode 100644 index 00000000..b88951a7 --- /dev/null +++ b/spotify-lyrics/panel.luau @@ -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 diff --git a/spotify-lyrics/plugin.toml b/spotify-lyrics/plugin.toml new file mode 100644 index 00000000..250b075c --- /dev/null +++ b/spotify-lyrics/plugin.toml @@ -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 diff --git a/spotify-lyrics/spotify_lyrics_daemon.py b/spotify-lyrics/spotify_lyrics_daemon.py new file mode 100644 index 00000000..7df54fd6 --- /dev/null +++ b/spotify-lyrics/spotify_lyrics_daemon.py @@ -0,0 +1,256 @@ +import os +import time +import json +import hashlib +import subprocess +import threading +import urllib.request +import syncedlyrics +from pathlib import Path + +# Config +CACHE_DIR = Path.home() / ".cache" / "noctalia" / "lyrics" +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CURRENT_STATE_FILE = CACHE_DIR / "current.json" + +ART_CACHE_DIR = CACHE_DIR / "art" +ART_CACHE_DIR.mkdir(parents=True, exist_ok=True) + +class SpotifyLyricsDaemon: + def __init__(self): + self.lyrics_cache = {} # song_key -> list of lines + self.fetching_keys = set() # Tracks keys currently fetching in the background + self.art_cache = {} # art_url -> local file path + self.art_fetching = set() # URLs currently being downloaded + + def clean_filename(self, name): + return "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip() + + def get_parsed_lyrics(self, title, artist): + song_key = f"{artist} - {title}" + if song_key in self.lyrics_cache: + return self.lyrics_cache[song_key] + + # Check local disk cache first + safe_name = self.clean_filename(song_key) + lrc_file = CACHE_DIR / f"{safe_name}.lrc" + + if lrc_file.exists(): + parsed = self.load_lrc_file(lrc_file) + self.lyrics_cache[song_key] = parsed + return parsed + + # Fetch from syncedlyrics asynchronously to prevent daemon thread lag + if song_key not in self.fetching_keys: + self.fetching_keys.add(song_key) + threading.Thread( + target=self._async_fetch_lyrics, + args=(song_key, lrc_file), + daemon=True + ).start() + + return [] + + def _async_fetch_lyrics(self, song_key, lrc_file): + try: + print(f"[Daemon] Fetching lyrics in background for: {song_key}...") + lrc_text = syncedlyrics.search(song_key, providers=["NetEase", "Lrclib"]) + if lrc_text: + with open(lrc_file, "w", encoding="utf-8") as f: + f.write(lrc_text) + + parsed = self.parse_lrc_text(lrc_text) + self.lyrics_cache[song_key] = parsed + print(f"[Daemon] Fetch completed for: {song_key}") + else: + print(f"[Daemon] No lyrics found online for: {song_key}") + except Exception as e: + print(f"[Daemon] Error fetching lyrics for {song_key}: {e}") + finally: + self.fetching_keys.discard(song_key) + + def parse_lrc_text(self, lrc_text): + parsed = [] + for line in lrc_text.splitlines(): + # Format: [mm:ss.xx] Text + if line.startswith("[") and "]" in line: + parts = line.split("]", 1) + time_part = parts[0].replace("[", "").strip() + text = parts[1].strip() + + try: + # mm:ss.xx or mm:ss + if "." in time_part: + min_sec, hund = time_part.split(".") + hund_val = int(hund) * 10 if len(hund) == 2 else int(hund) + else: + min_sec = time_part + hund_val = 0 + + minutes, seconds = min_sec.split(":") + time_ms = ((int(minutes) * 60) + int(seconds)) * 1000 + hund_val + parsed.append({"time_ms": time_ms, "text": text}) + except Exception: + pass + return parsed + + def load_lrc_file(self, lrc_file): + try: + with open(lrc_file, "r", encoding="utf-8") as f: + return self.parse_lrc_text(f.read()) + except Exception as e: + print(f"[Daemon] Error reading LRC file: {e}") + return [] + + def get_album_art_path(self, art_url): + """Download album art from URL and return local cached file path.""" + if not art_url or art_url == "": + return "" + + # Check in-memory cache + if art_url in self.art_cache: + path = self.art_cache[art_url] + if os.path.exists(path): + return path + + # Derive a stable filename from the URL hash + url_hash = hashlib.md5(art_url.encode()).hexdigest() + ext = ".jpg" # Spotify art is always JPEG + local_path = str(ART_CACHE_DIR / f"{url_hash}{ext}") + + # If already downloaded on disk, cache and return + if os.path.exists(local_path): + self.art_cache[art_url] = local_path + return local_path + + # Download in background to avoid blocking the main loop + if art_url not in self.art_fetching: + self.art_fetching.add(art_url) + threading.Thread( + target=self._download_art, + args=(art_url, local_path), + daemon=True + ).start() + + return "" # Not yet available + + def _download_art(self, url, local_path): + try: + tmp_path = local_path + ".tmp" + urllib.request.urlretrieve(url, tmp_path) + os.replace(tmp_path, local_path) + self.art_cache[url] = local_path + print(f"[Daemon] Downloaded album art: {url[:60]}...") + except Exception as e: + print(f"[Daemon] Error downloading album art: {e}") + # Clean up partial download + try: + os.remove(local_path + ".tmp") + except OSError: + pass + finally: + self.art_fetching.discard(url) + + def get_player_status(self): + try: + # Query active players + players = subprocess.check_output(["playerctl", "-l"], stderr=subprocess.DEVNULL).decode("utf-8").strip().splitlines() + if not players: + return None + + # Prioritize Spotify + player_name = "spotify" if "spotify" in players else players[0] + + # Query all metadata in ONE execution using custom delimiters to eliminate subprocess latency + output = subprocess.check_output([ + "playerctl", "-p", player_name, "metadata", + "--format", "{{status}}|||{{position}}|||{{title}}|||{{artist}}|||{{mpris:artUrl}}" + ], stderr=subprocess.DEVNULL).decode("utf-8").strip() + + parts = output.split("|||") + if len(parts) >= 4: + status, pos_us, 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) + + return { + "status": status, + "position_ms": position_ms, + "title": title, + "artist": artist, + "art_url": art_url + } + except Exception: + pass + return None + + def run(self): + print("[Daemon] Starting Universal lyrics cache daemon...") + + while True: + player = self.get_player_status() + + if not player or not player["title"]: + # Write empty/inactive state + empty_state = {"status": "Stopped"} + tmp_file = CURRENT_STATE_FILE.with_suffix('.tmp') + with open(tmp_file, "w", encoding="utf-8") as f: + json.dump(empty_state, f) + tmp_file.replace(CURRENT_STATE_FILE) + time.sleep(1.0) + continue + + title = player["title"] + artist = player["artist"] + + lyrics_lines = self.get_parsed_lyrics(title, artist) + + # Find active line + active_idx = -1 + pos_ms = player["position_ms"] + + for i, line in enumerate(lyrics_lines): + if pos_ms >= line["time_ms"]: + active_idx = i + else: + break + + # Get surrounding lines + prev_prev = lyrics_lines[active_idx - 2]["text"] if active_idx >= 2 else "" + prev = lyrics_lines[active_idx - 1]["text"] if active_idx >= 1 else "" + current = lyrics_lines[active_idx]["text"] if active_idx >= 0 else "..." + next_line = lyrics_lines[active_idx + 1]["text"] if active_idx >= 0 and active_idx + 1 < len(lyrics_lines) else "" + next_next = lyrics_lines[active_idx + 2]["text"] if active_idx >= 0 and active_idx + 2 < len(lyrics_lines) else "" + + # Resolve album art to a local file path + art_path = self.get_album_art_path(player.get("art_url", "")) + + state = { + "status": player["status"], + "title": title, + "artist": artist, + "prev_prev": prev_prev, + "prev": prev, + "current": current, + "next": next_line, + "next_next": next_next, + "art_path": art_path + } + + # Save state + 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 + else: + time.sleep(1.0) + +if __name__ == "__main__": + daemon = SpotifyLyricsDaemon() + daemon.run() diff --git a/spotify-lyrics/thumbnail.webp b/spotify-lyrics/thumbnail.webp new file mode 100644 index 00000000..cf318c7d Binary files /dev/null and b/spotify-lyrics/thumbnail.webp differ diff --git a/spotify-lyrics/translations/en.json b/spotify-lyrics/translations/en.json new file mode 100644 index 00000000..8f23557a --- /dev/null +++ b/spotify-lyrics/translations/en.json @@ -0,0 +1,5 @@ +{ + "name": "Spotify Lyrics", + "description": "Time-synced lyrics panel linked to the media widget.", + "no_music": "No music playing" +} diff --git a/spotify-lyrics/widget.luau b/spotify-lyrics/widget.luau new file mode 100644 index 00000000..f8fa771c --- /dev/null +++ b/spotify-lyrics/widget.luau @@ -0,0 +1,253 @@ +--!nonstrict +-- Spotify Lyrics Desktop Widget – floating overlay version. +-- +-- Same data source as panel.luau but rendered via desktopWidget.render() +-- with larger font sizes for desktop readability. +-- Long lyrics are dynamically scaled to smaller fonts to prevent overflow. + +-------------------------------------------------------------------------------- +-- Layout constants +-------------------------------------------------------------------------------- +local WIDGET_WIDTH = 400 +local WIDGET_PADDING = 10 +local INNER_WIDTH = WIDGET_WIDTH - WIDGET_PADDING * 2 -- 380px usable +local MIN_HEIGHT = 160 +local MAX_HEIGHT = 300 -- absolute ceiling to prevent spill + +-------------------------------------------------------------------------------- +-- Runtime state +-------------------------------------------------------------------------------- +local currentHeight = MIN_HEIGHT +local lastState = nil +local lastClock = os.clock() + +-------------------------------------------------------------------------------- +-- Dynamic font sizing (see panel.luau for rationale) +-------------------------------------------------------------------------------- + +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(11, defaultSize - reduction) +end + +-------------------------------------------------------------------------------- +-- Text truncation – hard-clamp text to prevent overflow +-------------------------------------------------------------------------------- + +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 + +-------------------------------------------------------------------------------- +-- Height estimation +-------------------------------------------------------------------------------- + +local function estimateLines(text, fontSize) + if not text or text == "" then return 0 end + local charsPerLine = math.max(1, math.floor(INNER_WIDTH / (fontSize * 0.60))) + return math.max(1, math.ceil(#text / charsPerLine)) +end + +local function estimateContentHeight(state) + local h = WIDGET_PADDING * 2 + + local title = state.title or "" + local artist = state.artist or "" + if title ~= "" and artist ~= "" then + h = h + 12 + 6 + estimateLines(title .. " — " .. artist, 11) * 16 + h = h + 10 + 1 + 10 + end + + local prevSize = fitFontSize(state.prev, 18) + if (state.prev or "") ~= "" then + h = h + math.min(2, estimateLines(state.prev, prevSize)) * math.ceil(prevSize * 1.4) + else + h = h + 18 + end + h = h + 10 + + local currentSize = fitFontSize(state.current, 26) + h = h + math.min(3, estimateLines(state.current or "...", currentSize)) * math.ceil(currentSize * 1.4) + h = h + 10 + + local nextSize = fitFontSize(state.next, 18) + if (state.next or "") ~= "" then + h = h + math.min(2, estimateLines(state.next, nextSize)) * math.ceil(nextSize * 1.4) + else + h = h + 18 + end + + h = h + 24 + return math.max(MIN_HEIGHT, math.min(MAX_HEIGHT, h)) +end + +-------------------------------------------------------------------------------- +-- State reader – pulls lyrics data from noctalia.state (populated by bar.luau) +-------------------------------------------------------------------------------- + +local function readState() + local status = noctalia.state.get("lyricsStatus") + if not status or status == "" then return nil end + + return { + status = status, + title = noctalia.state.get("lyricsTitle") or "", + artist = noctalia.state.get("lyricsArtist") or "", + prev_prev = noctalia.state.get("lyricsPrevPrev") or "", + prev = noctalia.state.get("lyricsPrev") or "", + current = noctalia.state.get("lyricsCurrent") or "", + next = noctalia.state.get("lyricsNext") or "", + next_next = noctalia.state.get("lyricsNextNext") or "", + } +end + +-------------------------------------------------------------------------------- +-- Rendering +-------------------------------------------------------------------------------- + +local function renderEmpty() + desktopWidget.render(ui.column({ + gap = 8, align = "center", justify = "center", + minWidth = WIDGET_WIDTH, minHeight = currentHeight, + maxHeight = MAX_HEIGHT, overflow = "hidden", + }, { + ui.glyph({ name = "music", size = 28, color = "on_surface/0.3" }), + ui.label({ + text = "No music playing", + fontSize = 16, fontWeight = "medium", color = "on_surface/0.3", + }), + })) +end + +local function renderPaused(state) + desktopWidget.render(ui.column({ + gap = 8, align = "center", justify = "center", + minWidth = WIDGET_WIDTH, minHeight = currentHeight, + maxHeight = MAX_HEIGHT, overflow = "hidden", + }, { + ui.glyph({ name = "player-pause", size = 22, color = "on_surface/0.4" }), + ui.label({ + text = state.current or "Paused", + fontSize = 22, fontWeight = "bold", + color = "on_surface/0.5", wrap = true, textAlign = "center", + maxLines = 2, + }), + })) +end + +local function renderPlaying(state) + local rows = {} + + -- Track header + local title = state.title or "" + local artist = state.artist or "" + if title ~= "" and artist ~= "" then + table.insert(rows, ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "music", size = 12, color = "primary/0.7" }), + ui.label({ + text = title .. " — " .. artist, + fontSize = 11, fontWeight = "medium", + color = "primary/0.6", wrap = true, + maxLines = 1, + }), + })) + table.insert(rows, ui.box({ height = 1, fill = "on_surface/0.08" })) + end + + -- Previous lyric (dynamically scaled, clamped to 2 lines) + local prevText = state.prev or "" + if prevText ~= "" then + local prevSize = fitFontSize(prevText, 18) + table.insert(rows, ui.label({ + text = clampText(prevText, prevSize, 2), + fontSize = prevSize, fontWeight = "normal", + color = "on_surface/0.3", textAlign = "center", wrap = true, + maxLines = 2, + })) + else + table.insert(rows, ui.box({ height = 18 })) + end + + -- Current lyric (dynamically scaled, clamped to 3 lines) + local currentText = state.current or "..." + if currentText == "" then currentText = "..." end + local currentSize = fitFontSize(currentText, 26) + table.insert(rows, ui.label({ + text = clampText(currentText, currentSize, 3), + fontSize = currentSize, fontWeight = "bold", + color = "on_surface", textAlign = "center", wrap = true, + maxLines = 3, + })) + + -- Next lyric (dynamically scaled, clamped to 2 lines) + local nextText = state.next or "" + if nextText ~= "" then + local nextSize = fitFontSize(nextText, 18) + table.insert(rows, ui.label({ + text = clampText(nextText, nextSize, 2), + fontSize = nextSize, fontWeight = "normal", + color = "on_surface/0.3", textAlign = "center", wrap = true, + maxLines = 2, + })) + else + table.insert(rows, ui.box({ height = 18 })) + end + + desktopWidget.render(ui.column({ + gap = 10, align = "center", justify = "center", + minWidth = WIDGET_WIDTH, minHeight = currentHeight, + maxHeight = MAX_HEIGHT, overflow = "hidden", + }, rows)) +end + +local function render(state) + if not state or state.status == "Stopped" or state.status == "" then + renderEmpty() + elseif state.status == "Paused" then + renderPaused(state) + else + renderPlaying(state) + end +end + +-------------------------------------------------------------------------------- +-- Update loop +-------------------------------------------------------------------------------- + +function update() + noctalia.setUpdateInterval(100) + + -- Subscribe to the tick so noctalia re-runs this on state changes + noctalia.state.get("lyricsTick") + + local now = os.clock() + local delta = lastClock > 0 and now - lastClock or 0 + lastClock = now + + lastState = readState() or lastState + + local targetHeight = MIN_HEIGHT + if lastState and lastState.status == "Playing" then + targetHeight = estimateContentHeight(lastState) + end + + if targetHeight > currentHeight then + currentHeight = targetHeight + else + currentHeight = currentHeight + (targetHeight - currentHeight) * math.min(1, delta * 4) + end + + -- Clamp final height to never exceed MAX_HEIGHT + currentHeight = math.min(currentHeight, MAX_HEIGHT) + + render(lastState) +end