diff --git a/media-lyrics/CHANGELOG.md b/media-lyrics/CHANGELOG.md new file mode 100644 index 000000000..16c9e44be --- /dev/null +++ b/media-lyrics/CHANGELOG.md @@ -0,0 +1,206 @@ +# Changelog + +All notable changes to **Media Lyrics** are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.8.13] — 2026-09-02 + +### Fixed + +- **Review-hardening round 2** (community review prep): + - i18n coverage extended: widget title fallback, panel "Unknown artist", + and the footer provider labels ("Local"/"Cache") now route through + `noctalia.tr()` (keys: `common.*`, `provider.*`). + - Network failures are no longer reported as "no lyrics found": a + transport error (curl exit ≠ 0 and ≠ 22) surfaces + `service.lyrics-unreachable` through the lyrics-error state, which the + panel now reaches for the first time. + - No nil children in the UI tree: the unsynced-list chevron was emitted + as `cond and node or nil` inside a children array (the host logs + "ui tree node is not a table"); children are now appended conditionally. + - UTF-8-safe cuts: `wrapLyric` hard-slices and the bar-widget title + truncation never split a multi-byte sequence (Cyrillic titles/lines). + - Karaoke class (font size/weight) is precomputed once into arrays shared + by the window budget and the render loop — they cannot drift apart. + - README/docs: "14 visible lines" corrected to 10/14/16 per preset; the + "Bilingual UI (en/ru)" claim softened to "translatable via Noctalia + i18n" (no ru file ships by community rule); stale comments in + plugin.toml/service.luau/panel.luau updated (150 ms poll, no "ring", + no "zero external tools"). + +## [0.8.12] — 2026-09-02 + +### Fixed + +- **README Plugin section documents every panel entry** — the Plugin table + now lists `panel`, `panel-compact` and `panel-large` (the size presets + added in 0.8.7); the community `validate` CI requires each panel entry id + to appear in the README. + +## [0.8.11] — 2026-09-02 + +### Fixed + +- **Long lyric lines no longer spill past the panel edge** (reported live on + the compact preset): a `ui.label` wider than the panel is clipped at the + surface edge, cutting 47+ char lines mid-word. The host does not auto-wrap, + so long lines are now soft-wrapped into balanced sub-lines via `\n` + (verified that `ui.label` renders `\n`). The wrap budget uses a glyph + advance factor of ~0.62 em measured live at scale 1.5 (bold 0.66) and + per-preset content widths (compact 404 / medium 484 / large 604). +- **Karaoke window now fits a vertical sub-line budget** — the visible + window shrinks when wrapped lines would push the active line below the + panel edge (previously the fixed 10/14/16-row window could clip the + bottom rows once lines wrapped). + +## [0.8.10] — 2026-09-02 + +### Fixed + +- **Community review compliance** (noctalia-dev/community-plugins #592): + - `curl` now declared in `dependencies` and the README Requirements — + the service spawns it for LRCLIB fetches (`-sSf -m 8 -4`, argv-only, no + shell). `sleep` (coreutils) documented in the README Notes. + - Plugin description no longer claims "no external dependencies" — it + states the real runtime needs (`busctl` + `curl`). + - Panel chrome is fully routed through `noctalia.tr()` with keys in + `translations/en.json` ("Loading lyrics…", "Lyrics error: ", "No lyrics + found", "No media player", "(no title)", "NOW PLAYING", "Reload lyrics", + "synced"/"unsynced") — other locales can now be provided via Noctalia + Translate instead of rendering hardcoded English. + - Thumbnail regenerated with the official thumbnail generator + (assets.noctalia.dev, 960×540 WebP). + +## [0.8.9] — 2026-09-01 + +### Fixed + +- **Compact panel: layout fix actually wired up** — `panelLayout()` was + defined but never used in `buildInfoRow`; the header still rendered with + the fixed 520px metrics and the transport block stayed clipped at 440px. + `buildInfoRow` now applies the preset layout (cover size, gap, text width, + transport button sizes, no-player row height). + +## [0.8.8] — 2026-09-01 + +### Fixed + +- **Compact panel: transport block clipped** — the header (cover + title + + transport + time) overflowed 440px. `panelLayout()` now scales cover size, + text column width and transport button sizes per preset (compact 36/168/14, + medium 50/286/18, large 56/370/20); the disc placeholder glyph scales too. +- **Per-preset placement/position settings removed** — only the medium + `panel` declares `placement`/`position`; compact and large inherit the + default so the settings UI shows one placement/position block, not three. + +## [0.8.7] — 2026-09-01 + +### Added + +- **Panel size presets** — `panel_size` setting (compact 440 / medium 520 / + large 640) selects which panel preset the bar widget and control-center + tile open. Three `[[panel]]` entries share one `panel.luau`; the visible + lyric lines scale with the preset (10 / 14 / 16). Medium keeps the + historical `panel` id for IPC compatibility. + +## [0.8.5] — 2026-09-01 + +### Added + +- **Clickable lyric lines** — click a synced line to seek the player to that + timestamp. Lines without a timestamp (plain lyrics) are not clickable. + Implemented as a `ui.row` click target wrapping the label — `ui.label` + takes no `onClick` and `ui.button` ignores `color`/`fontWeight` (would + break the karaoke gradient). +- **Manual lyric scroll + line selection** — Up/Down step the lyric cursor + (highlighted with a chevron marker), Return/Space seek to the cursor line. + Works for synced AND plain lyrics (plain: highlight only, no seek). + Before the first timestamp the window starts at line 1 (was frozen). + `keyboard_focus = "exclusive"` so the panel receives keys; the host's chord + validator accepts only basic names (PageUp/PageDown/Home/End are rejected + and would drop the plugin from the store). +- `onScroll` declared on the panel (the host documents it as bar-widget-only; + if a future build delivers wheel events, scrolling steps the cursor). + +### Fixed + +- **Seek used the wrong D-Bus method** — `SeekActive` is RELATIVE (MPRIS + Seek): seeking to a line jumped by the timestamp instead of to it. Now uses + `SetPositionActive` (ABSOLUTE, verified live: 2:00 lands at 2:00). +- **`busctlCall` dropped the D-Bus signature** — typed arguments were passed + without their type (`SetPositionActive 90000000` instead of + `SetPositionActive x 90000000`), so busctl failed with «Too few parameters + for signature» and seek/shuffle/loop silently did nothing. Now the type is + passed for every argument (`b`, `s`, `x`). +- **Bar widget mixed render() and setGlyph/setText** — the host warns that + setGlyph/setText have no visible effect once a render() tree is active; + the empty state now renders a disc glyph tree too. + +## [0.8.3] — 2026-09-01 + +### Changed + +- `dependencies = ["busctl"]` declared in the manifest (community-plugins + review rule: every shelled-out command must be declared). +- `description` fixed — «ring progress» was removed in v0.8.0 (replaced by + the header progress bar); catalog copy now reads «progress bar» (111/120). + +### Removed + +- `translations/ru.json` — community rule is en.json only; other locales are + handled via Noctalia Translate. +- ROADMAP link from the plugin README (roadmap lives outside the plugin + directory in the community-plugins layout). + +## [0.8.1] — 2026-09-01 + +### Changed + +- Bar widget honors the instance's `Color` / `Icon Color` settings: explicit + color roles removed from the `barWidget.render()` tree (they silently + ignored the user's per-widget color configuration; the host colors the + built-in glyph/text row, which the empty state already used). +- `[widget.actions] middle = "none"` declared in the manifest — the host + default (`settings-open-widget`) swallowed `onMiddleClick`, so middle-click + play/pause never fired out of the box. + +### Removed + +- Custom URL from the planned additional lyric sources (contradicts the + install-and-use philosophy). + +## [0.8.0] — 2026-09-01 + +### Added + +- Marquee (scrolling) titles for long track/artist names: 2 s static hold, + then slow scroll; per-slice button keys prevent glyph overlap. +- Per-font-size marquee capacity and speed (`vwUnits(fs)`, `MARQUEE_SPEED/fs`). +- `singleLine` sanitizer for MPRIS metadata containing embedded newlines. +- Progress bar between header and lyrics (replaces the old separator). +- Localization: English + Russian UI strings. +- Screenshots, thumbnail, docs (`ROADMAP.md`), MIT license — publication-ready. + +### Changed + +- Visible lyric lines: 11 → 14 (carousel window). +- Lyric timing: marquee clock driven by `watch("media")` publishes + (the host never calls panel `update()`); service publish cadence 500 → 150 ms + for smooth animation. +- Title/artist pinned flush-left via ghost buttons with `contentAlign="start"` + (host ignores `textAlign` on labels). +- Lyric source fallback: LRCLIB `/api/get` → `/api/search` → local `.lrc` → cache. + +### Fixed + +- Lyric lines wrapping and letter overlap (newline sanitizer, integer + button heights, per-slice keys). +- Marquee not starting (host tick probe: `update()` never called on panels). +- Titles/artists drifting to center or clipping on long names. +- Progress ring under the cover removed; progress bar layout stable. + +### Removed + +- Shuffle/repeat randomness, progress seek buckets, cover progress ring + (replaced by the header progress bar). diff --git a/media-lyrics/LICENSE b/media-lyrics/LICENSE new file mode 100644 index 000000000..7218c1e07 --- /dev/null +++ b/media-lyrics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Zyablov (TraNZeM) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/media-lyrics/README.md b/media-lyrics/README.md new file mode 100644 index 000000000..a699d5617 --- /dev/null +++ b/media-lyrics/README.md @@ -0,0 +1,113 @@ +# Media Lyrics + +A full-featured media player panel with **time-synced lyrics** for the Noctalia desktop shell. Karaoke-style lyric carousel (10/14/16 visible lines per size preset), album cover, transport controls, and a progress bar — all in one floating panel. **Pure Luau implementation**: no playerctl, no python daemons, no GTK overlays — runtime needs `busctl` (MPRIS) and `curl` (LRCLIB HTTPS). + +| Light theme | Dark theme | +| --- | --- | +| ![Media Lyrics panel (light)](screenshots/panel-light.png) | ![Media Lyrics panel (dark)](screenshots/panel-dark.png) | + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `tranzem/media-lyrics` | +| Entries | Bar widget: `now-playing`; panels: `panel` (medium 520×520), `panel-compact` (440×440), `panel-large` (640×640); service: `service`; shortcut: `toggle` | + +## Requirements + +- Noctalia v5 (plugin API 24+) +- `busctl` (systemd, present on every Arch install) +- `curl` — used for the LRCLIB HTTPS fetch (spawned as `curl -sSf -m 8 -4 `, argv-only, no shell; LRCLIB resolves IPv4 faster than the built-in HTTP client on some setups) +- Outbound HTTPS access to `https://lrclib.net` for synced lyrics + +No player-specific software. Any MPRIS-capable player works: Spotify, MPD, Cider, web players, VLC, and anything else that exposes MPRIS over D-Bus. `sleep` (coreutils) is used for a short refresh delay after transport commands. + +## Usage + +Enable the plugin, then open the panel: + +```sh +noctalia msg plugins enable tranzem/media-lyrics +noctalia msg panel-toggle tranzem/media-lyrics:panel +``` + +The panel opens at the size preset selected by the `panel_size` setting +(compact 440 / medium 520 / large 640). The `now-playing` bar widget and the +`toggle` control-center tile both open the selected preset; you can also open +a specific preset directly: + +```sh +noctalia msg panel-toggle tranzem/media-lyrics:panel-compact +noctalia msg panel-toggle tranzem/media-lyrics:panel-large +``` + +Add the `now-playing` widget to your bar to get a compact indicator that opens the panel on click. A `toggle` shortcut (control-center tile) is also available. Bind it to a hotkey in Noctalia's shortcut settings, or from your compositor: + +```toml +"Ctrl+Alt+M" = "spawn:noctalia msg panel-toggle tranzem/media-lyrics:panel" +``` + +The panel shows the active MPRIS player automatically; when nothing is playing it renders an empty state. + +## Features + +- **Karaoke lyric carousel** — 10/14/16 lines visible at once (compact/medium/large presets); the active line is bright, neighbours fade by distance (Clavis-style). Works with synced (LRC) and plain lyrics. +- **Clickable lyric lines** — click a synced line to seek the player to that timestamp. +- **Manual lyric scroll** — Up/Down step a line (the host's chord validator accepts only basic key names; PageUp/PageDown/Home/End are rejected). +- **LRCLIB integration** — exact `/api/get` lookup first, `/api/search` fallback, LRC parsed in pure Luau. +- **Local `.lrc` files** — drop `Artist - Title.lrc` into the local lyrics folder; they take priority over the network. +- **Marquee titles** — long track/artist names hold for 2 s, then scroll slowly instead of wrapping or clipping. Overlap-free (per-slice node recreation). +- **Album cover + progress bar** — interpolated progress between polls, transport controls (prev / play-pause / next), shuffle and repeat state. +- **Settings** — lyric timing offset in ms, on-disk cache, local lyrics folder. Translatable UI: strings go through Noctalia's i18n (`noctalia.tr`, English ships in the plugin; other locales via Noctalia Translate). + +## Advantages over alternative lyric plugins + +- **Lean runtime.** No playerctl, python daemons, pip packages, or GTK overlays to install and maintain — just `busctl` and `curl`, present on virtually every Linux system. Enable → works. +- **Player-agnostic.** Reads MPRIS directly via Noctalia's D-Bus aggregator — works with any player, not tied to a specific app. +- **A real panel, not a 1–3 line bar widget.** Full-screen-height carousel with 10–16 visible lines (per size preset) keeps whole verses in view. +- **Overflow handled properly.** Long titles get a marquee, single-line sanitizer strips embedded newlines, integer button heights prevent glyph overlap. +- **Offline-friendly.** LRCLIB responses are cached; local `.lrc` files work without network at all. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `panel_size` | `select` | `medium` | Panel size preset: `compact` (440×440, 10 lyric lines), `medium` (520×520, 14 lines), `large` (640×640, 16 lines). The bar widget and the control-center tile open this preset. | +| `offset_ms` | `int` | `0` | Shift lyric timing: positive shows lines earlier, negative later. | +| `use_cache` | `bool` | `true` | Cache fetched lyrics in the plugin data directory for offline reuse. | +| `local_lyrics_dir` | `folder` | `~/.local/share/media-lyrics` | Folder with local `.lrc` files named `Artist - Title.lrc`; searched before LRCLIB. | + +## IPC + +```sh +noctalia msg panel-toggle tranzem/media-lyrics:panel +``` + +## Local development + +Add the parent directory as a local Noctalia source: + +```sh +noctalia msg plugins source add media-lyrics-dev path /path/to/media-lyrics-parent +noctalia msg plugins enable tranzem/media-lyrics +noctalia msg config-reload +``` + +## To Do + +Upcoming work, roughly in priority order: + +- [ ] Album cover inside a capsule shape +- [ ] Additional lyric sources (NetEase, Musixmatch, embedded MPRIS metadata, …) +- [x] Clickable lyric lines — click a line to seek the track to that moment (DONE in 0.8.5: click + Return/Space) +- [ ] Seek on progress-bar click +- [ ] Compact mode with a pinnable widget +- [x] Preconfigured widget actions — default gestures declared in the manifest (middle click → play/pause, scroll → track switching) work out of the box (DONE in 0.8.1: `[widget.actions] middle = "none"`) +- [x] Widget size setting — panel size presets (DONE in 0.8.7: `panel_size` select — compact 440 / medium 520 / large 640; the bar widget itself keeps its hard-coded look) + +## Notes + +- The service polls MPRIS via `busctl` (150 ms cadence) and publishes a snapshot to `noctalia.state`; the panel animates from those publishes. +- Lyrics are fetched from the public LRCLIB API with `curl`; nothing is uploaded. Cache and local lyrics live under the plugin data directory and `local_lyrics_dir`. +- Spawned processes (all argv-form, no shell): `busctl` (MPRIS poll), `curl` (LRCLIB fetch, IPv4, 8 s timeout), `sleep` (coreutils, 0.35 s refresh delay after transport commands). +- Adapted from the Clavis shell media player text layer (karaoke render + LRCLIB provider), ported to pure Luau for Noctalia v5. diff --git a/media-lyrics/panel.luau b/media-lyrics/panel.luau new file mode 100644 index 000000000..fbcab2ac7 --- /dev/null +++ b/media-lyrics/panel.luau @@ -0,0 +1,778 @@ +--!nonstrict +-- media-lyrics panel: cover art with progress line, track info + transport, +-- karaoke lyric layer. Redesign v0.2 ("quiet premium"): one hero (the cover), +-- minimal chrome, type-driven hierarchy. Layout (2 bands): +-- A. chrome header: NOW PLAYING ......... [reload] [close] (11px ghost) +-- B. info row: [cover 50 + progress 2.5] [title / artist - album] | transport+time +-- hairline separator +-- C. lyrics: karaoke carousel (synced) or clipped list (plain), flexGrow +-- D. footer: provider - mode label (11px, low-contrast) +-- +-- Progress: verified live on this host build — +-- * no arc primitive exists, and VERTICAL ui.progress bars do not render +-- at all, so the v0.1 8-segment perimeter ring and the per-edge bars were +-- dropped for a single native horizontal ui.progress (2.5px) under the art; +-- * ui.box cannot hold children (host drops them with warnings); +-- * labels centered by default: text with maxWidth inside a stretched +-- column loses left alignment -> infoCol hugs content, springer pushes +-- transport to the right edge. +-- +-- The panel is a thin client: state comes from noctalia.state("media"), +-- commands go back over noctalia.state("panel_cmd"). + +-- ============================= design tokens ============================= +-- One table, one place. Spacing on a 4px grid; colors are palette role tokens +-- with alpha so everything tracks the active Noctalia theme. + +local T = { + -- layout + pad = 4, -- root padding + gapBand = 8, -- between bands A/B/hairline/C + gapInline = 12, -- cover <-> text + gapMicro = 6, -- inside groups (transport step, meta stack) + + -- cover + ring + cover = 50, -- inner cover size (logical px) + ring = 2.5, -- ring thickness + coverRadius = 10, -- inner cover corner radius + + -- typography + fsOverline = 11, -- chrome header / footer + fsTitle = 19, + fsMeta = 13, + fsTime = 12, + fsActiveLyric = 17, + fsNearLyric = 15, + fsFarLyric = 14, + + -- colors (state colors live here too) + colOverline = "on_surface_variant", + colTitle = "on_surface", + colMeta = "on_surface_variant", + colTime = "on_surface_variant", + ringTrack = "outline/0.5", + ringFill = "primary", + lyricActive = "primary", + lyricNear = "on_surface", + lyricFar = "on_surface_variant", + footer = "on_surface_variant/0.7", + hairline = "outline/0.35", + emptyState = "on_surface_variant", +} + +local snap = nil -- latest snapshot from the service +local cursorLine = 0 -- keyboard/scroll cursor over lyric lines (0 = follow playback) +local lastTrackKey = "" -- track identity for cursor reset on track change +local lastSnapMs = 0 +tickMarquee = nil -- set by buildInfoRow; advances marquee clocks + +local function nowMs() + local ok, v = pcall(function() return noctalia.nowMs() end) + if ok and type(v) == "number" then return v end + return os.time() * 1000 +end + +local function interpPos(s) + -- interpolate position between service polls for smooth lyrics + if not s then return 0 end + if not s.playing then return s.posSec or 0 end + local elapsed = (lastSnapMs > 0) and ((nowMs() - lastSnapMs) / 1000) or 0 + local p = (s.posSec or 0) + elapsed + if s.lengthSec and s.lengthSec > 0 and p > s.lengthSec then p = s.lengthSec end + return p +end + +local function fmtTime(sec) + sec = tonumber(sec) or 0 + if sec < 0 then sec = 0 end + local s = math.floor(sec) + return math.floor(s / 60) .. ":" .. string.format("%02d", s % 60) +end + +local glyphFor = { + prev = "player-skip-back-filled", + next = "player-skip-forward-filled", + play = "player-play-filled", + pause = "player-pause-filled", +} + +local function sendCmd(action, extra) + local cmd = { action = action } + if extra then + for k, v in pairs(extra) do cmd[k] = v end + end + noctalia.state.set("panel_cmd", cmd) +end + +-- marquee phase state lives at module scope: buildInfoRow runs on EVERY +-- render, and a per-render table would reset elapsed to 0 each tick. +local marqueeState = {} -- [key] = { text, elapsed } + +-- ============================= lyrics rendering ============================= + +-- Visible lyric lines depend on the panel preset (panel_size setting): +-- compact 440px fits 10, medium 520px fits 14 (historical default), large +-- 640px fits 16. +local function visibleLines() + local sz = noctalia.getConfig("panel_size") + if sz == "compact" then return 10 end + if sz == "large" then return 16 end + return 14 +end + +-- Header layout per panel preset: cover size, gaps, text column width and +-- transport button sizes. Compact is ~432 usable px, medium ~512, large +-- ~632 (panel width minus root padding); the numbers keep the whole header +-- (cover + text + transport + time) inside those bounds. +local function panelLayout() + local sz = noctalia.getConfig("panel_size") + if sz == "compact" then + return { cover = 36, gapInline = 8, textW = 168, ctl = 14, main = 18 } + elseif sz == "large" then + return { cover = 56, gapInline = 14, textW = 370, ctl = 20, main = 24 } + end + return { cover = 50, gapInline = 12, textW = 286, ctl = 18, main = 22 } +end + +local ACTIVE_ROW = 4 -- 1-based slot of the active line inside the window + +-- Long lyric lines must not spill past the panel edge: a ui.label wider +-- than the panel is clipped at the surface edge (verified live — compact +-- 440 px panel cut ~47+ char lines mid-word). ui.label DOES render "\n" +-- (verified live), so long lines are soft-wrapped into sub-lines. Glyph +-- budget: advance factor 0.62 em measured live on this host at scale 1.5 +-- (mixed-case text, incl. spaces); bold/medium run wider and get a bump. +-- Width per preset measured live: panel width minus ~36 px of host insets +-- + root padding (compact 404 / medium 484 / large 604). +local LYR_WIDTH = { compact = 404, medium = 484, large = 604 } +-- Advance factors measured live on this host at scale 1.5 (mixed-case text, +-- incl. spaces): regular ~0.613 em, medium ~0.62, bold ~+6% over regular. +local LYR_K = { normal = 0.62, medium = 0.62, bold = 0.66 } + +-- Split a lyric line into display sub-lines that fit the glyph budget. +-- Word-aware and BALANCED: when the line needs exactly two sub-lines the +-- break sits near the middle (not a 40+4 sliver); longer lines fill whole +-- budget chunks. A single word longer than the whole budget is hard-sliced. +-- Returns the original string when it already fits. `reserved` shrinks the +-- budget (cursor rows carry a leading chevron). +local function wrapLyric(text, fontSize, fontWeight, reserved) + if text == nil or text == "" then return text end + local sz = noctalia.getConfig("panel_size") or "medium" + local maxW = (LYR_WIDTH[sz] or LYR_WIDTH.medium) - 10 - (reserved or 0) + local k = LYR_K[fontWeight] or LYR_K.normal + local budget = math.max(10, math.floor(maxW / (k * fontSize))) + local n = #text + if n <= budget then return text end + local words = {} + for w in (text .. " "):gmatch("(.-) ") do + words[#words + 1] = w + end + -- Balanced two-line break: both halves must fit the budget. + if n <= budget * 2 and #words >= 2 then + local best, bestScore = 1, math.huge + for i = 1, #words - 1 do + local a, b = "", "" + for j = 1, i do a = a .. (j > 1 and " " or "") .. words[j] end + for j = i + 1, #words do b = b .. (j > i + 1 and " " or "") .. words[j] end + if #a <= budget and #b <= budget then + local score = math.abs(#a - #b) + if score < bestScore then best, bestScore = i, score end + end + end + if bestScore ~= math.huge then + local a, b = "", "" + for j = 1, best do a = a .. (j > 1 and " " or "") .. words[j] end + for j = best + 1, #words do b = b .. (j > best + 1 and " " or "") .. words[j] end + return a .. "\n" .. b + end + end + -- Three-or-more: fill whole budget chunks, word-aware. + local out = {} + local cur = "" + for i = 1, #words do + local w = words[i] + if #w > budget then + -- hard-slice, but never split a UTF-8 sequence: step back over + -- continuation bytes (0x80..0xBF) to the last char boundary + local rest = w + while #rest > budget do + if cur ~= "" then out[#out + 1] = cur; cur = "" end + local cut = budget + local bt = rest:byte(cut) + while bt and bt >= 0x80 and bt < 0xC0 and cut > 0 do + cut = cut - 1 + bt = rest:byte(cut) + end + if cut < 1 then cut = 1 end + out[#out + 1] = rest:sub(1, cut) + rest = rest:sub(cut + 1) + end + cur = rest + elseif cur == "" then + cur = w + elseif #cur + 1 + #w <= budget then + cur = cur .. " " .. w + else + out[#out + 1] = cur + cur = w + end + end + if cur ~= "" then out[#out + 1] = cur end + return table.concat(out, "\n") +end + +local function buildLyricsBody(s) + local lines = s.lyrics or {} + local n = #lines + + if s.lyricsStatus == "loading" then + return { ui.label({ text = noctalia.tr("panel.loading-lyrics"), color = T.colMeta, + fontSize = T.fsFarLyric, fontWeight = "medium", textAlign = "center" }) } + end + if s.lyricsStatus == "error" then + return { ui.label({ text = noctalia.tr("panel.lyrics-error") .. (s.lyricsError or ""), + color = "error", fontSize = T.fsFarLyric, textAlign = "center" }) } + end + if s.lyricsStatus ~= "ready" or n == 0 then + return { ui.label({ text = noctalia.tr("panel.no-lyrics"), color = T.emptyState, + fontSize = T.fsFarLyric, fontWeight = "medium", textAlign = "center" }) } + end + + if not s.synced then + -- unsynced: static list inside a clipping scroll (a bare column does not + -- clip its children), dimmed, centered, generous line spacing. The + -- keyboard cursor still highlights a line (no seek — no timestamps). + local rows = {} + for i, l in ipairs(lines) do + local isCursor = (i == cursorLine) + -- Build the children list conditionally — a literal + -- `cond and node or nil` inside the children array emits a nil + -- child, which the host logs as "ui tree node is not a table". + local rowChildren = {} + if isCursor then + rowChildren[#rowChildren + 1] = ui.glyph({ + key = "cur" .. i, + name = "chevron-right", + size = 11, + color = T.lyricActive, + }) + end + rowChildren[#rowChildren + 1] = ui.label({ + key = "lbl" .. i, text = wrapLyric(l.text, T.fsFarLyric, "normal", isCursor and 16 or 0), + fontSize = T.fsFarLyric, + color = isCursor and T.lyricActive or T.lyricNear, + opacity = isCursor and 1 or 0.8, textAlign = "center", + }) + rows[#rows + 1] = ui.row({ + key = "lyr" .. i, + align = "center", justify = "center", + }, rowChildren) + rows[#rows + 1] = ui.spacer({ key = "lgap" .. i, height = T.gapMicro }) + end + return { ui.scroll({ flexGrow = 1, align = "stretch" }, rows) } + end + + -- synchronized: carousel around the playback line (karaoke fade ladder) + local pos = interpPos(s) + local active = -1 + for i = 1, n do + if (s.lyrics[i].time >= 0) and (s.lyrics[i].time <= pos) then active = i end + if (s.lyrics[i].time >= 0) and (s.lyrics[i].time > pos) then break end + end + + -- Lyric class per line: font size / weight from the karaoke distance + -- ladder, precomputed ONCE so the window budget and the render loop share + -- the same values (they must never drift apart). + local fsArr, wArr = {}, {} + for i = 1, n do + local isA = (i == active) + local isC = (i == cursorLine) + local d = math.abs(i - active) + fsArr[i] = isA and T.fsActiveLyric or (d <= 2 and T.fsNearLyric or T.fsFarLyric) + wArr[i] = (isA or isC) and "bold" or (d <= 2 and "medium" or "normal") + end + -- Wrapped lines consume more than one visual row. The window must fit a + -- VERTICAL BUDGET of visibleLines() rows, so count the wrapped sub-lines + -- a row needs and shrink the window until its total fits (wrapping long + -- lines must never push the active line below the panel edge). + local function sublines(i) + return 1 + select(2, wrapLyric(s.lyrics[i].text, fsArr[i], wArr[i], i == cursorLine and 16 or 0):gsub("\n", "")) + end + local budget = visibleLines() + local refActive = active >= 1 and active or 1 + local target = cursorLine >= 1 and cursorLine or refActive + local showCount = math.min(budget, n) + local centerY = active >= 1 and ACTIVE_ROW or math.ceil(showCount / 2) + local lo = 1 + local hi = math.max(1, n - showCount + 1) + local base = target - centerY + base = math.max(lo, math.min(hi, base)) + while showCount > 1 do + local used = 0 + for k = base, math.min(n, base + showCount - 1) do + used = used + sublines(k) + end + if used <= budget then break end + showCount = showCount - 1 + -- keep the target line inside the shrunken window + base = math.max(1, math.min(n - showCount + 1, target - math.ceil(showCount / 2))) + base = math.max(1, math.min(base, target)) + base = math.max(1, base) + if base + showCount - 1 < target then base = target - showCount + 1 end + if base < 1 then base = 1 end + end + local rows = {} + for k = 1, showCount do + local idx = base + k - 1 + if idx >= 1 and idx <= n then + local isActive = (idx == active) + local isCursor = (idx == cursorLine) + local dist = math.abs(idx - active) + local fontSize = fsArr[idx] + local fontWeight = wArr[idx] + local color = (isActive or isCursor) and T.lyricActive + or (dist <= 2 and T.lyricNear or T.lyricFar) + local opacity = (isActive or isCursor) and 1 + or (dist <= 2 and 0.9 or (dist <= 5 and 0.55 or 0.3)) + local lineTime = s.lyrics[idx].time + -- Clickable line: ui.label does not accept onClick and ui.button + -- ignores color/fontWeight (would break the karaoke gradient), so wrap + -- the label in a ui.row that owns the click target. Seek only when the + -- line has a timestamp. The cursor row gets a leading chevron marker. + local children = {} + if isCursor then + children[#children + 1] = ui.glyph({ + key = "cur" .. idx, + name = "chevron-right", + size = math.max(10, fontSize - 3), + color = T.lyricActive, + }) + end + children[#children + 1] = ui.label({ + key = "lbl" .. idx, + text = wrapLyric(s.lyrics[idx].text, fontSize, fontWeight, isCursor and 16 or 0), + fontSize = fontSize, + fontWeight = fontWeight, + color = color, + opacity = opacity, + textAlign = "center", + }) + rows[k] = ui.row({ + key = "lyr" .. idx, + align = "center", justify = "center", + onClick = (lineTime >= 0) and function() + sendCmd("seekTo", { sec = lineTime }) + end or nil, + }, children) + else + rows[k] = ui.spacer({ key = "gap" .. k, height = 24 }) + end + end + return rows +end + +-- ============================= cover: art + progress ============================= + +-- Cover art with a thin progress line under it. VERIFIED LIVE on this host: +-- * ui.box cannot hold children (host drops them with warnings); +-- * ui.image renders at DECLARED size only when NOT wrapped in a padded +-- container (inside a padded plate column it came out oversize), so the +-- frame is the image's own border (ui.image border/borderWidth/radius); +-- * vertical ui.progress bars do not render at all -> single horizontal +-- native progress bar under the art (agreed auto-fallback). +local function buildCover(s, coverSize) + local COVER = coverSize or T.cover + if s.artUrl and s.artUrl ~= "" then + return ui.image({ + key = "cover", path = s.artUrl, width = COVER, height = COVER, + fit = "cover", radius = T.coverRadius, + border = T.ringTrack, borderWidth = 1, + onClick = function() sendCmd("toggle") end, + }) + else + return ui.box({ key = "coverbox", width = COVER, height = COVER, + fill = "surface_variant/0.6", border = "outline/0.5", borderWidth = 1, + radius = T.coverRadius }, { + ui.glyph({ name = "disc", size = math.max(28, COVER - 8), color = "on_surface_variant" }), + }) + end +end + +local function buildProgressBar(s) + local total = s.lengthSec or 0 + local cur = interpPos(s) + local frac = (total > 0) and math.min(1, cur / total) or 0 + return ui.progress({ + key = "pbar", progress = frac, + fill = T.ringFill, track = T.ringTrack, + width = "stretch", height = T.ring, radius = T.ring / 2 + }) +end + +-- ============================= header (cover + info + transport) ============================= + +local function buildInfoRow(s) + local L = panelLayout() + if not s.hasPlayer then + return { ui.row({ key = "noplayer", height = L.cover + 2 * T.ring, align = "center" }, { + ui.label({ text = noctalia.tr("panel.no-media-player"), color = T.emptyState, fontSize = T.fsMeta }), + }) } + end + + local cover = buildCover(s, L.cover) + + local function ctl(glyph, action, enabled, size) + return ui.button({ + glyph = glyph, glyphSize = size or L.ctl, variant = "ghost", + enabled = enabled ~= false, onClick = function() sendCmd(action) end, + }) + end + + local mainGlyph = s.playing and glyphFor.pause or glyphFor.play + local cur = interpPos(s) + local total = s.lengthSec or 0 + + -- right block: transport row (the hero pill sits on the cover's axis) + + -- quiet time label underneath, right-aligned + local transportCol = ui.column({ key = "transportCol", gap = 2, align = "center", justify = "center" }, { + ui.row({ gap = T.gapMicro, align = "center", justify = "end" }, { + ctl(glyphFor.prev, "prev", s.canGoPrev ~= false, L.ctl), + ui.button({ + glyph = mainGlyph, glyphSize = L.main, variant = "primary", + enabled = s.hasPlayer == true, onClick = function() sendCmd("toggle") end, + controlSize = "sm", + }), + ctl(glyphFor.next, "next", s.canGoNext ~= false, L.ctl), + }), + ui.label({ key = "time", text = fmtTime(cur) .. " / " .. fmtTime(total), + fontSize = T.fsTime, color = T.colTime }), + }) + + -- middle block: title + artist - album (no wrap, marquee for overflow). + -- PATTERN FROM COMMUNITY/TODO + lyrics.luau: text pinned left inside a + -- fixed-width BUTTON (contentAlign = "start"); a line that does not fit is + -- NOT ellipsized - it stays static for MARQUEE_HOLD seconds, then scrolls + -- char-by-char (charUnits widths), holds at the end, and scrolls back. + -- Height never jumps: button height is fixed per line. + local TEXT_W = L.textW + local MARQUEE_HOLD = 2 -- seconds of static display before scrolling + local MARQUEE_SPEED = 24 -- pixels per second + -- MPRIS metadata can contain embedded newlines ("Golden Empire feat.\n + -- Zitaa - Original Mix"): a button then WRAPS its text to 2 lines inside + -- the fixed-height box and the overflow draws over the next line (the + -- "наложение букв" the user saw). Sanitize to a single line. + local function singleLine(text) + text = tostring(text or ""):gsub("[\r\n\t]+", " ") + text = text:gsub(" +", " ") + return text + end + local artistLine = singleLine(s.artist ~= "" and s.artist or noctalia.tr("common.unknown-artist")) + if s.album ~= "" then artistLine = artistLine .. " - " .. s.album end + + -- UTF-8 aware char split + per-char width units (community/lyrics scheme) + local function toChars(text) + local chars = {} + local i = 1 + while i <= #text do + local byte = text:byte(i) + local len = 1 + if byte >= 0xF0 then len = 4 elseif byte >= 0xE0 then len = 3 + elseif byte >= 0xC0 then len = 2 end + chars[#chars + 1] = text:sub(i, i + len - 1) + i = i + len + end + return chars + end + local function charUnits(char) + -- uniform conservative estimate: this theme's font renders ~0.72em/char + -- on average (caps-heavy titles measured wider than 0.58em predicted, + -- which let lines slip past capacity and WRAP). 0.72 units/char keeps + -- slices safely inside the button width. + local byte = char:byte(1) or 0 + if byte < 0x80 then return 0.72 end + return 1 + end + local function textUnits(text) + local u = 0 + for _, ch in ipairs(toChars(text)) do u = u + charUnits(ch) end + return u + end + -- viewport capacity in width units, PER FONT SIZE: units are em-fractions + -- (charUnits = em fraction), so capacity = usable px / fontSize. A single + -- fs13-based capacity made fs19 title slices ~1.46x too wide -> the button + -- wrapped the slice again (observed live: "…nd In That Moment I Knew Th…" + -- on two lines). Button inner padding ~13px/side is also included. + local function vwUnits(fs) + return (TEXT_W - 26) / fs + end + local function shouldMarquee(text, fs) + return textUnits(text) > vwUnits(fs) + end + + -- scroll timing per line: static hold -> slow travel -> hold -> back + -- (marqueeState is module-scope — see top of file) + + local function marqueeSlice(chars, offset, fs) + -- visible window of vwUnits(fs) starting at char offset + local out, used, i = {}, 0, 1 + local skip = math.floor(offset) + local frac = offset - skip + local index = skip + 1 + while index <= #chars and used < vwUnits(fs) do + local w = charUnits(chars[index]) + if used + w > vwUnits(fs) then break end + out[#out + 1] = chars[index] + used = used + w + index = index + 1 + end + return table.concat(out) + end + + local function textLine(key, text, fs, weight, colr) + local state = marqueeState[key] + if state == nil or state.text ~= text then + state = { text = text, elapsed = 0, phase = 0 } + marqueeState[key] = state + end + local shown = text + if shouldMarquee(text, fs) then + local chars = toChars(text) + local widths = {} + for i, ch in ipairs(chars) do widths[i] = charUnits(ch) end + local trailing = 0 + local units = 0 + for i = #chars, 1, -1 do + local nu = units + widths[i] + if nu > vwUnits(fs) then break end + units = nu + trailing = trailing + 1 + end + local distance = math.max(0, #chars - math.max(1, trailing)) + if distance > 0 then + -- scroll speed in units/second: 24px/s at fs13 ≈ 1.85 units/s, + -- scaled by fontSize so all lines scroll at the same visual speed + local cellsPerSec = math.max(0.5, MARQUEE_SPEED / fs) + local travel = distance / cellsPerSec + local cycle = MARQUEE_HOLD * 2 + travel * 2 + local phase = state.elapsed % cycle + local off + if phase < MARQUEE_HOLD then off = 0 + else + phase = phase - MARQUEE_HOLD + if phase < travel then off = distance * (phase / travel) + else + phase = phase - travel + if phase < MARQUEE_HOLD then off = distance + else + phase = phase - MARQUEE_HOLD + off = distance * (1 - phase / travel) + end + end + end + shown = marqueeSlice(chars, math.floor(off), fs) + end + end + -- key includes the slice text: the host RETAINS a button whose key is + -- unchanged and re-renders its text in place, which visibly overlaps the + -- old glyphs with the new ones during a text swap. Keying per slice + -- forces a clean node recreation on every step. + return ui.button({ + key = key .. "-" .. shown, text = shown, variant = "ghost", + contentAlign = "start", fontSize = fs, + width = TEXT_W, height = math.floor(fs * 1.35 + 0.5), + }) + end + + -- advance marquee timers between renders (called from update()) + tickMarquee = function(deltaSec) + for _, st in pairs(marqueeState) do st.elapsed = st.elapsed + deltaSec end + end + local infoCol = ui.column({ key = "infoCol", gap = 3, align = "start" }, { + textLine("ttl", singleLine(s.title ~= "" and s.title or noctalia.tr("common.no-title")), + T.fsTitle, "semibold", T.colTitle), + textLine("art", artistLine, T.fsMeta, "regular", T.colMeta), + }) + + -- single band, everything centered on the art's vertical axis + return { + ui.row({ key = "infoRow", align = "center", justify = "start", height = 62 }, { + ui.column({ key = "coverWrap", gap = 1, align = "center", + width = L.cover + 2 }, { + cover, + }), + ui.spacer({ key = "coverGap", width = L.gapInline }), + infoCol, + ui.spacer({ key = "spring1", flexGrow = 1 }), + transportCol, + }), + } +end + +-- ============================= render ============================= + +local function render() + local s = snap or { + hasPlayer = false, title = "", artist = "", album = "", artUrl = "", + playing = false, lengthSec = 0, posSec = 0, lyrics = {}, + lyricsStatus = "loading", lyricsProvider = "", synced = false, + } + local infoRow = buildInfoRow(s) + + -- footer: provider + mode in one quiet label (empty string hides itself) + local footerTxt = "" + if s.lyricsStatus == "ready" then + footerTxt = (s.lyricsProvider ~= "" and s.lyricsProvider or "") + .. (s.synced and (" - " .. noctalia.tr("panel.synced")) + or (" - " .. noctalia.tr("panel.unsynced"))) + end + + panel.render(ui.column({ flexGrow = 1, padding = T.pad, gap = T.gapBand }, { + -- A. chrome header: overline left, two ghost glyph buttons right + ui.row({ key = "chrome", align = "center", justify = "space_between" }, { + ui.label({ text = noctalia.tr("panel.now-playing"), fontSize = T.fsOverline, fontWeight = "semibold", + color = T.colOverline }), + ui.row({ gap = T.gapMicro, align = "center" }, { + ui.button({ glyph = "refresh", glyphSize = 14, variant = "ghost", controlSize = "sm", + width = 22, height = 22, + tooltip = noctalia.tr("panel.reload-lyrics"), + onClick = function() noctalia.state.set("panel_req", { action = "reloadLyrics" }) end }), + ui.button({ glyph = "close", glyphSize = 14, variant = "ghost", controlSize = "sm", + width = 22, height = 22, + onClick = function() panel.close() end }), + }), + }), + -- B. info band (cover | info | transport) + infoRow[1], + -- progress bar replacing the hairline separator + buildProgressBar(s), + -- C. lyrics (flexGrow) + ui.column({ key = "lyrics", flexGrow = 1, gap = T.gapMicro }, buildLyricsBody(s)), + -- D. footer: provider - mode + ui.label({ key = "footer", text = footerTxt, fontSize = T.fsOverline, + color = T.footer, visible = footerTxt ~= "" }), + })) +end + +-- ============================= state wiring ============================= + +-- VERIFIED BY PROBE (this host build): the host NEVER calls update() or +-- onFrameTick for [[panel]] entries — both counters stayed 0 with +-- setWantsSecondTicks(true) + setNeedsFrameTick(true). The ONLY periodic +-- signal a panel gets is the service's media publishes (POLL_MS = 150ms): +-- the service re-sets noctalia.state("media") on every poll, and watch() +-- fires per publish. So the marquee clock MUST tick inside the media watch, +-- using the wall-clock delta between publishes. +noctalia.state.watch("media", function(value) + local now = nowMs() + if lastSnapMs > 0 then + local deltaSec = (now - lastSnapMs) / 1000 + -- clamp: a long gap (panel closed / track change pause) must not + -- fast-forward the marquee + if deltaSec > 0 and deltaSec < 2.0 then + if tickMarquee then tickMarquee(deltaSec) end + end + end + -- track changed: drop the lyric cursor so the panel follows playback again + local key = (value.title or "") .. "|" .. (value.artist or "") .. "|" .. tostring(value.lengthSec or 0) + if key ~= lastTrackKey then + lastTrackKey = key + cursorLine = 0 + end + snap = value + lastSnapMs = nowMs() + render() +end) + +-- update() is never called by the host for panels in this build (verified +-- with a live counter probe). Kept as a harmless no-op so the entry stays +-- API-correct if a future build starts ticking panels. +function update() +end + +-- Lyric cursor helpers. cursorLine is 1-based over s.lyrics; 0 = follow the +-- active playback line. moveCursor starts from the active line (or line 1 +-- before the first timestamp — fixes the frozen window until the first +-- words), steps by delta and re-centers the window on the cursor. +local function activeIndex(s) + local pos = interpPos(s) + local active = -1 + local n = #(s.lyrics or {}) + for i = 1, n do + if (s.lyrics[i].time >= 0) and (s.lyrics[i].time <= pos) then active = i end + if (s.lyrics[i].time >= 0) and (s.lyrics[i].time > pos) then break end + end + return active +end + +local function moveCursor(delta) + local s = snap + if not s or not s.lyrics or #s.lyrics == 0 then return end + local n = #s.lyrics + local cur = cursorLine + if cur < 1 or cur > n then + cur = activeIndex(s) + if cur < 1 then cur = 1 end + end + cursorLine = math.max(1, math.min(n, cur + delta)) + render() +end + +-- Activate the line under the cursor: seek the player to its timestamp +-- (synced lyrics only — plain lines have no timestamps). +local function seekCursor() + local s = snap + if not s or not s.synced or not s.lyrics then + cursorLine = 0 + render() + return + end + local cur = cursorLine + if cur < 1 or cur > #s.lyrics then + cur = activeIndex(s) + if cur < 1 then + cursorLine = 0 + render() + return + end + end + local lineTime = s.lyrics[cur].time + if lineTime >= 0 then + sendCmd("seekTo", { sec = lineTime }) + end + cursorLine = 0 + render() +end + +function onOpen(_context) + cursorLine = 0 + -- second ticks keep position + lyrics moving between service polls + panel.setWantsSecondTicks(true) + noctalia.state.set("panel_req", { action = "refresh" }) + render() +end + +-- Manual lyric scroll. The host's chord validator accepts only a small set +-- of key names (Up/Down/Left/Right/Return/Space/Escape/Tab — used by +-- community plugins); PageUp/PageDown/Home/End are REJECTED by the catalog +-- scan and drop the plugin from the store, so only Up/Down/Return/Space are +-- declared. Up/Down step the cursor, Return/Space seek to the cursor line. +-- The window never leaves [1..n] (clamped in buildLyricsBody). +function onKey(chord, pressed) + if not pressed then return end + if chord == "Up" then + moveCursor(-1) + elseif chord == "Down" then + moveCursor(1) + elseif chord == "Return" or chord == "Space" then + seekCursor() + end +end + +-- Docs say onScroll is a bar-widget callback, but declare it anyway: if the +-- host delivers wheel events to panels, this makes wheel scrolling step the +-- lyric cursor. Harmless no-op otherwise. +function onScroll(axis, steps, startsGesture) + if axis ~= "vertical" or startsGesture then return end + local dir = (steps or 0) > 0 and 1 or -1 + moveCursor(dir) +end \ No newline at end of file diff --git a/media-lyrics/plugin.toml b/media-lyrics/plugin.toml new file mode 100644 index 000000000..408de0da2 --- /dev/null +++ b/media-lyrics/plugin.toml @@ -0,0 +1,104 @@ +# media-lyrics — Noctalia v5 plugin: media player panel with synced lyrics. +# +# Pure-Luau media panel: polls Noctalia's own D-Bus MPRIS aggregator +# (dev.noctalia.Mpris) from a [[service]], fetches synced lyrics from +# LRCLIB (exact /api/get first, then /api/search fallback), parses LRC in +# pure Luau, caches locally, and exposes everything to the [[panel]] over +# noctalia.state. Runtime needs busctl (MPRIS poll) and curl (LRCLIB HTTPS). +# +# Open the panel: noctalia msg panel-toggle tranzem/media-lyrics:panel + +id = "tranzem/media-lyrics" +name = "Media Lyrics" +version = "0.8.13" +plugin_api = 24 +author = "tranzem" +license = "MIT" +tags = ["media", "music", "panel", "service"] +icon = "music" +description = "Synced lyrics media panel: karaoke carousel, progress bar, marquee titles. Pure Luau; runtime needs busctl + curl." +dependencies = ["busctl", "curl"] + +[[setting]] +key = "offset_ms" +type = "int" +label_key = "settings.offset_ms.label" +description_key = "settings.offset_ms.description" +default = 0 + +[[setting]] +key = "panel_size" +type = "select" +label_key = "settings.panel_size.label" +description_key = "settings.panel_size.description" +default = "medium" +options = [ + { value = "compact", label_key = "settings.panel_size.options.compact" }, + { value = "medium", label_key = "settings.panel_size.options.medium" }, + { value = "large", label_key = "settings.panel_size.options.large" }, +] + +[[setting]] +key = "use_cache" +type = "bool" +label_key = "settings.use_cache.label" +description_key = "settings.use_cache.description" +default = true + +[[setting]] +key = "local_lyrics_dir" +type = "folder" +label_key = "settings.local_lyrics_dir.label" +description_key = "settings.local_lyrics_dir.description" +default = "~/.local/share/media-lyrics" + +# Bar widget: compact now-playing indicator; opens the panel on click. +[[widget]] +id = "now-playing" +entry = "widget.luau" + + # Free the middle button for play/pause — the host default opens the + # widget's settings instead. Declared so onMiddleClick works out of the box. + [widget.actions] + middle = "none" + +# Background service: polls D-Bus for player state, fetches + parses lyrics, +# and publishes a snapshot to noctalia.state for the panel. +[[service]] +id = "service" +entry = "service.luau" + +# Main panel: header (cover | title/artist | transport) + karaoke lyrics view. +# Three size presets share one entry; panel_size selects which one the widget +# and the control-center tile open. Medium (520) is the default and keeps the +# historical `panel` id for IPC compatibility. +[[panel]] +id = "panel" +entry = "panel.luau" +width = 520 +height = 520 +placement = "floating" +position = "center" +keyboard_focus = "exclusive" +capture_keys = ["Up", "Down", "Return", "Space"] + +[[panel]] +id = "panel-compact" +entry = "panel.luau" +width = 440 +height = 440 +keyboard_focus = "exclusive" +capture_keys = ["Up", "Down", "Return", "Space"] + +[[panel]] +id = "panel-large" +entry = "panel.luau" +width = 640 +height = 640 +keyboard_focus = "exclusive" +capture_keys = ["Up", "Down", "Return", "Space"] + +# Control-center tile: quick panel open. +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" diff --git a/media-lyrics/screenshots/panel-dark.png b/media-lyrics/screenshots/panel-dark.png new file mode 100644 index 000000000..7d1e2b129 Binary files /dev/null and b/media-lyrics/screenshots/panel-dark.png differ diff --git a/media-lyrics/screenshots/panel-light.png b/media-lyrics/screenshots/panel-light.png new file mode 100644 index 000000000..0cb4783aa Binary files /dev/null and b/media-lyrics/screenshots/panel-light.png differ diff --git a/media-lyrics/screenshots/settings-panel.png b/media-lyrics/screenshots/settings-panel.png new file mode 100644 index 000000000..cee53c657 Binary files /dev/null and b/media-lyrics/screenshots/settings-panel.png differ diff --git a/media-lyrics/screenshots/settings.png b/media-lyrics/screenshots/settings.png new file mode 100644 index 000000000..2d8cb1624 Binary files /dev/null and b/media-lyrics/screenshots/settings.png differ diff --git a/media-lyrics/service.luau b/media-lyrics/service.luau new file mode 100644 index 000000000..49ec1ff60 --- /dev/null +++ b/media-lyrics/service.luau @@ -0,0 +1,561 @@ +--!nonstrict +-- media-lyrics service: owns all media/lyrics logic, publishes snapshots. +-- +-- Port of Clavis's lyrics stack (shell/Services/LyricsTrackService.qml + +-- shell/core/plugin/lyrics/src/lyrics.cpp) to Noctalia v5 Luau: +-- * Clavis MediaManager.active -> Noctalia D-Bus aggregator +-- dev.noctalia.Mpris GetActivePlayer +-- (poll via busctl every 150 ms) +-- * Clavis.Lyrics providers -> LRCLIB /api/get then /api/search +-- * Lyrics::parseLrc / indexForTime -> pure Luau ports below +-- * cache + local dir -> pluginDataDir()/lyrics + configured dir +-- Publishes a snapshot table to noctalia.state("media") for the panel. + +-- 150ms: the panel has no timer of its own (host never calls panel update(), +-- verified by probe) — it animates the marquee from watch("media") ticks. +-- 150ms keeps the scroll smooth (~0.2 char per tick at 24px/s) without +-- hammering MPRIS. +local POLL_MS = 150 +local LRCLIB_UA = "media-lyrics/0.1 (Noctalia v5 plugin)" + +-- ============================= utility ============================= + +local function trim(s) + return noctalia.string.trim(s or "") +end + +local function nowMs() + local ok, v = pcall(function() return noctalia.nowMs() end) + if ok and type(v) == "number" then return v end + return os.time() * 1000 +end + +-- ============================= state ============================= + +local snapshot = { + hasPlayer = false, + playerName = "", + title = "", + artist = "", + album = "", + artUrl = "", + playing = false, + canSeek = false, + canGoNext = false, + canGoPrev = false, + shuffle = false, + loopStatus = "None", + lengthSec = 0, + posSec = 0, + posAtMs = 0, -- wall-clock ms when posSec was sampled (panel interpolates) + -- lyrics block + lyricsStatus = "idle", -- idle | loading | ready | empty | error + lyricsError = "", + lyricsProvider = "", + synced = false, + lyrics = {}, -- { { time = sec, text = "..." }, ... } (time < 0 = unsynced) +} + +-- ============================= LRC parsing (port of Lyrics::parseLrc) ============================= + +local function fractionalSeconds(frac) + if not frac or frac == "" then return 0.0 end + local v = tonumber(frac) or 0 + local n = #frac + if n == 1 then return v / 10.0 end + if n == 2 then return v / 100.0 end + return v / 1000.0 +end + +local function isMetadataLine(line) + local l = line:lower() + return l:match("^%[ar%s*:") ~= nil or l:match("^%[ti%s*:") ~= nil + or l:match("^%[al%s*:") ~= nil or l:match("^%[by%s*:") ~= nil + or l:match("^%[re%s*:") ~= nil or l:match("^%[ve%s*:") ~= nil + or l:match("^%[offset%s*:") ~= nil +end + +local function looksLikeMalformedTimestamp(line) + return line:match("^%[%d+%s*:[^%]]+%]") ~= nil +end + +-- Parse an LRC (or plain-text) lyric into a sorted list of { time = sec, text }. +-- time < 0 marks unsynced lines. Mirrors Lyrics::parseLrc from Clavis. +local function parseLrc(text, offsetMs) + local result = {} + if trim(text) == "" then return result end + + local fileOffsetMs = 0 + local off = text:match("%[offset%s*:%s*(-?%d+%.?%d*)%]") + if off then fileOffsetMs = tonumber(off) or 0 end + local extraOffset = (fileOffsetMs + (offsetMs or 0)) / 1000.0 + + for line in (text .. "\n"):gmatch("([^\r\n]*)\r?\n") do + local timestamps = {} + local rest = line + -- consume every leading [mm:ss(.ttt)] timestamp tag (also accepts : or - ms separators) + while true do + local min, sec, epos + local sep, frac + -- with fractional part + min, sec, sep, frac, epos = rest:match("^%s*%[(%d+):(%d%d?)([%.:%-])(%d+)%]()") + if not min then + -- plain [mm:ss] + min, sec, epos = rest:match("^%s*%[(%d+):(%d%d?)%]()") + sep, frac = nil, nil + end + if not min then break end + local ts = tonumber(min) * 60 + tonumber(sec) + fractionalSeconds(frac) + timestamps[#timestamps + 1] = ts + extraOffset + rest = rest:sub(epos) + end + + local lyricText = trim(rest) + if #timestamps > 0 then + if lyricText ~= "" then + for _, t in ipairs(timestamps) do + result[#result + 1] = { time = t, text = lyricText } + end + end + else + local trimmedLine = trim(line) + if not (lyricText == "" or isMetadataLine(trimmedLine) or looksLikeMalformedTimestamp(trimmedLine)) then + result[#result + 1] = { time = -1.0, text = lyricText } + end + end + end + + -- stable sort: synced lines first (by time), unsynced after, preserving order + local function keyed(t) + for i, v in ipairs(result) do + if v == t then return i end + end + return 0 + end + table.sort(result, function(a, b) + local at, bt = a.time >= 0, b.time >= 0 + if at ~= bt then return at end + if at and bt then + if a.time ~= b.time then return a.time < b.time end + return keyed(a) < keyed(b) -- stability + end + return false + end) + return result +end + +local function indexForTime(lyrics, positionSeconds) + if not snapshot.synced or positionSeconds == nil or positionSeconds < 0 then return -1 end + local lo, hi = 1, #lyrics + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if lyrics[mid].time <= positionSeconds then lo = mid + 1 else hi = mid end + end + return lo - 1 +end + +local function timeForIndex(lyrics, idx) + if idx < 1 or idx > #lyrics then return -1 end + local t = lyrics[idx].time + if t >= 0 then return t end + return -1 +end + +-- ============================= cache / local lyrics ============================= + +local function sanitizePart(s) + return trim((s or ""):gsub('[%c/\\]', "_")) +end + +local function cachePath(artist, title) + local dir = noctalia.pluginDataDir() + if not dir then return nil end + return dir .. "/lyrics/" .. sanitizePart(artist) .. "__" .. sanitizePart(title) .. ".lrc" +end + +local function loadCachedLyrics(artist, title) + if noctalia.getConfig("use_cache") ~= true then return nil end + local path = cachePath(artist, title) + if not path then return nil end + local raw = noctalia.readFile(path) + if raw and trim(raw) ~= "" then return raw end + return nil +end + +local function cacheLyrics(artist, title, synced, plain) + if noctalia.getConfig("use_cache") ~= true then return end + local path = cachePath(artist, title) + if not path then return end + local body = synced ~= "" and synced or plain + if trim(body) == "" then return end + noctalia.writeFile(path, body) +end + +local function tryLocalLyrics(artist, title) + local dirCfg = noctalia.getConfig("local_lyrics_dir") + if not dirCfg or trim(dirCfg) == "" then return nil end + local dir = noctalia.expandPath(dirCfg) + local base = sanitizePart(artist) + local candidates = {} + if base ~= "" then + candidates[#candidates + 1] = dir .. "/" .. base .. " - " .. sanitizePart(title) .. ".lrc" + end + candidates[#candidates + 1] = dir .. "/" .. sanitizePart(title) .. ".lrc" + for _, path in ipairs(candidates) do + local raw = noctalia.readFile(path) + if raw and trim(raw) ~= "" then return raw end + end + return nil +end + +-- ============================= lyrics pipeline ============================= + +local currentTrackKey = "" +local fetchGeneration = 0 + +local function publish() + noctalia.state.set("media", snapshot) +end + +local function acceptLyrics(provider, synced, plain, key) + local raw = synced ~= "" and synced or plain + if trim(raw) == "" then return false end + local offsetMs = tonumber(noctalia.getConfig("offset_ms")) or 0 + local lines = parseLrc(raw, offsetMs) + if #lines == 0 then return false end + + local hasSynced = false + for _, l in ipairs(lines) do + if l.time >= 0 then hasSynced = true break end + end + + snapshot.lyrics = lines + snapshot.lyricsStatus = "ready" + snapshot.lyricsProvider = provider + snapshot.synced = hasSynced + snapshot.lyricsError = "" + currentTrackKey = key + publish() + return true +end + +local function finishEmpty() + snapshot.lyrics = {} + snapshot.lyricsStatus = "empty" + snapshot.lyricsProvider = "" + snapshot.synced = false + snapshot.lyricsError = "" + if currentTrackKey ~= "" then currentTrackKey = currentTrackKey end + publish() +end + +local function finishError(msg) + snapshot.lyrics = {} + snapshot.lyricsStatus = "error" + snapshot.lyricsProvider = "" + snapshot.synced = false + snapshot.lyricsError = msg + publish() +end + +-- chain: local dir -> cache -> LRCLIB exact -> LRCLIB search +local function loadLyricsForTrack(artist, title, album, durationSec, key) + fetchGeneration = fetchGeneration + 1 + local gen = fetchGeneration + -- claim the track key up front: while the fetch is in flight, subsequent + -- refreshPlayer() ticks must treat this track as UNCHANGED (otherwise every + -- 500ms tick would invalidate the pending fetch and re-start it forever). + currentTrackKey = key + snapshot.lyricsStatus = "loading" + publish() + + if trim(title) == "" then + finishEmpty() + return + end + + -- 1) local directory (default ~/.local/share/media-lyrics) + local localRaw = tryLocalLyrics(artist, title) + if localRaw then + acceptLyrics(noctalia.tr("provider.local"), localRaw, "", key) + return + end + + -- 2) cache + local cached = loadCachedLyrics(title == "" and "" or artist, title) + if cached then + acceptLyrics(noctalia.tr("provider.cache"), cached, "", key) + return + end + + -- 3) LRCLIB exact /api/get (fast endpoint), 4) /api/search fallback + local getUrl = "https://lrclib.net/api/get?track_name=" .. noctalia.string.urlEncode(title) + .. "&artist_name=" .. noctalia.string.urlEncode(artist) + if album ~= "" then getUrl = getUrl .. "&album_name=" .. noctalia.string.urlEncode(album) end + if durationSec and durationSec > 0 then + getUrl = getUrl .. "&duration=" .. tostring(math.floor(durationSec + 0.5)) + end + + -- fetch via external curl: the built-in noctalia.http hangs against lrclib.net + -- on this setup (curl=28 timeout, 30s, 0 bytes) while the curl CLI resolves + -- IPv4 happily (~50ms). Force IPv4 and a short timeout. + local function httpGet(url, cb) + -- -f: 4xx/5xx -> non-zero exit (treated as fetch failure, not parse error). + -- exitCode is passed through so callers can tell "HTTP miss" (curl -f + -- exit 22) from a transport/network failure (DNS, timeout, conn reset). + noctalia.runAsync({ "curl", "-sSf", "-m", "8", "-4", + "-H", "User-Agent: " .. LRCLIB_UA, url }, function(result) + cb({ ok = result.exitCode == 0, status = result.exitCode == 0 and 200 or 0, + body = result.stdout or "", exit = result.exitCode }) + end) + end + + httpGet(getUrl, function(resp) + if gen ~= fetchGeneration then return end + local okData = nil + if resp.ok and resp.status == 200 and type(resp.body) == "string" then + local data = noctalia.json.decode(resp.body) + if type(data) == "table" and (trim(data.syncedLyrics or "") ~= "" or trim(data.plainLyrics or "") ~= "") then + okData = { synced = trim(data.syncedLyrics or ""), plain = trim(data.plainLyrics or "") } + end + end + + if okData then + if acceptLyrics("LRCLIB", okData.synced, okData.plain, key) then + cacheLyrics(artist, title, okData.synced, okData.plain) + end + return + end + + -- Transport failure (DNS / timeout / connection), not an HTTP miss: + -- curl -f exits 22 for 4xx/5xx; anything else means the request never + -- completed, so a search against the same host would fail the same way. + -- Surface a real error instead of the misleading "no lyrics found". + if not resp.ok and resp.exit ~= 22 then + if gen == fetchGeneration then + finishError(noctalia.tr("service.lyrics-unreachable")) + end + return + end + + -- search fallback + local searchUrl = "https://lrclib.net/api/search?track_name=" .. noctalia.string.urlEncode(title) + .. "&artist_name=" .. noctalia.string.urlEncode(artist) + httpGet(searchUrl, function(resp2) + if gen ~= fetchGeneration then return end + if resp2.ok and resp2.status == 200 and type(resp2.body) == "string" then + local list = noctalia.json.decode(resp2.body) + if type(list) == "table" and #list > 0 then + local best, bestScore = nil, -1 + for _, cand in ipairs(list) do + local score = 0 + if trim(cand.artistName or "") == artist then score = score + 2 end + if trim(cand.trackName or "") == title then score = score + 2 end + if trim(cand.syncedLyrics or "") ~= "" then score = score + 1 end + if score > bestScore then best, bestScore = cand, score end + end + if best then + local synced = trim(best.syncedLyrics or "") + local plain = trim(best.plainLyrics or "") + -- accept may fail on an instrumental hit (empty lyrics): end the + -- pipeline with "empty", otherwise the status sticks at "loading" + if acceptLyrics("LRCLIB", synced, plain, key) then + cacheLyrics(artist, title, synced, plain) + else + finishEmpty() + end + return + end + end + end + if not resp2.ok and resp2.exit ~= 22 then + if gen == fetchGeneration then + finishError(noctalia.tr("service.lyrics-unreachable")) + end + return + end + finishEmpty() + end) + end) +end + +local function invalidateLyrics() + fetchGeneration = fetchGeneration + 1 + snapshot.lyrics = {} + snapshot.lyricsStatus = "idle" + snapshot.lyricsProvider = "" + snapshot.synced = false + snapshot.lyricsError = "" +end + +-- ============================= MPRIS via busctl ============================= + +local GET_ACTIVE = { "busctl", "--user", "--json=short", "call", "dev.noctalia.Mpris", + "/dev/noctalia/Mpris", "dev.noctalia.Mpris", "GetActivePlayer" } + +local function parseBusctlVariant(v) + if type(v) ~= "table" or v.type == nil then return nil end + local t, d = v.type, v.data + if t == "s" then return d end + if t == "b" then return d == true end + if t == "x" or t == "d" or t == "i" or t == "u" then return tonumber(d) end + if t == "as" and type(d) == "table" then return table.concat(d, ", ") end + return d +end + +local function clearPlayer() + if snapshot.hasPlayer or snapshot.title ~= "" then + snapshot.hasPlayer = false + snapshot.title, snapshot.artist, snapshot.album, snapshot.artUrl = "", "", "", "" + snapshot.playing = false + invalidateLyrics() + publish() + end +end + +local refreshInFlight = false + +local function refreshPlayer() + if refreshInFlight then return end + refreshInFlight = true + noctalia.runAsync(GET_ACTIVE, function(result) + refreshInFlight = false + if result.exitCode ~= 0 or trim(result.stdout or "") == "" then + clearPlayer() + return + end + + -- busctl --json=short wraps the reply: { type = "ba{sv}", data = [ found, fields ] } + local payload = noctalia.json.decode(result.stdout) + local body = payload and payload.data + if type(body) ~= "table" or type(body[1]) ~= "boolean" then return end + if not body[1] or type(body[2]) ~= "table" then + clearPlayer() + return + end + + local f = body[2] + local title = parseBusctlVariant(f.title) or "" + local artist = parseBusctlVariant(f.artists) or "" + local album = parseBusctlVariant(f.album) or "" + -- art_url comes as file:// URI; ui.image expects a filesystem path + local artUrl = parseBusctlVariant(f.art_url) or "" + artUrl = artUrl:gsub("^file://", "") + local playing = (parseBusctlVariant(f.playback_status) == "Playing") + local lengthUs = tonumber(parseBusctlVariant(f.length_us)) or 0 + local posUs = tonumber(parseBusctlVariant(f.position_us)) or 0 + local ident = parseBusctlVariant(f.identity) or parseBusctlVariant(f.bus_name) or "" + local canSeek = parseBusctlVariant(f.can_seek) == true + local canNext = parseBusctlVariant(f.can_go_next) == true + local canPrev = parseBusctlVariant(f.can_go_previous) == true + local shuffle = parseBusctlVariant(f.shuffle) == true + local loopStatus = parseBusctlVariant(f.loop_status) or "None" + + local key = ident .. "|" .. title .. "|" .. artist .. "|" .. tostring(lengthUs) + local trackChanged = (not snapshot.hasPlayer) or (currentTrackKey ~= key) + + snapshot.hasPlayer = true + snapshot.playerName = ident + snapshot.title = title + snapshot.artist = artist + snapshot.album = album + snapshot.artUrl = artUrl + snapshot.playing = playing + snapshot.canSeek = canSeek + snapshot.canGoNext = canNext + snapshot.canGoPrev = canPrev + snapshot.shuffle = shuffle + snapshot.loopStatus = loopStatus + snapshot.lengthSec = lengthUs / 1e6 + snapshot.posSec = posUs / 1e6 + snapshot.posAtMs = nowMs() + + if trackChanged then + invalidateLyrics() + publish() + loadLyricsForTrack(artist, title, album, snapshot.lengthSec, key) + else + publish() + end + end) +end + +-- ============================= command channel ============================= + +local function busctlCall(method, argType, argValue) + local argv = { "busctl", "--user", "call", "dev.noctalia.Mpris", "/dev/noctalia/Mpris", + "dev.noctalia.Mpris", method } + -- busctl requires the D-Bus signature for typed arguments ("Too few + -- parameters for signature" otherwise); methods without args need none. + if argType then + argv[#argv + 1] = argType + argv[#argv + 1] = tostring(argValue) + end + noctalia.runAsync(argv) +end + +noctalia.state.watch("panel_cmd", function(value) + if type(value) ~= "table" or value.action == nil then return end + local a = value.action + if a == "toggle" then + busctlCall("PlayPauseActive") + elseif a == "next" then + busctlCall("NextActive") + elseif a == "prev" then + busctlCall("PreviousActive") + elseif a == "shuffle" then + busctlCall("SetShuffleActive", "b", tostring(not snapshot.shuffle)) + elseif a == "loop" then + local order = { None = "Playlist", Playlist = "Track", Track = "None" } + local nextLoop = order[snapshot.loopStatus] or "None" + busctlCall("SetLoopStatusActive", "s", nextLoop) + elseif a == "seekTo" then + local sec = tonumber(value.sec) + if sec then + -- SetPositionActive is ABSOLUTE (like MPRIS SetPosition); SeekActive + -- is RELATIVE (MPRIS Seek) and would jump by the timestamp, not to it. + busctlCall("SetPositionActive", "x", math.floor(sec * 1e6 + 0.5)) + end + end + -- refresh promptly after a command + local gen = fetchGeneration + noctalia.runAsync({ "sleep", "0.35" }, function() + if gen == fetchGeneration then refreshPlayer() end + end) +end) + +noctalia.state.watch("panel_req", function(value) + if type(value) ~= "table" then return end + if value.action == "refresh" then + refreshPlayer() + elseif value.action == "reloadLyrics" then + -- same track key format as refreshPlayer (lengthUs integer, not float) + local key = snapshot.playerName .. "|" .. snapshot.title .. "|" .. snapshot.artist + .. "|" .. tostring(math.floor(snapshot.lengthSec * 1e6 + 0.5)) + loadLyricsForTrack(snapshot.artist, snapshot.title, snapshot.album, snapshot.lengthSec, key) + end +end) + +-- ============================= lifecycle ============================= + +function onConfigChanged() + -- settings changed (e.g. offset_ms): rebuild the timeline for the current track + local artist, title = snapshot.artist, snapshot.title + invalidateLyrics() + refreshPlayer() + if title ~= "" then + local key = snapshot.playerName .. "|" .. title .. "|" .. artist .. "|" .. tostring(snapshot.lengthSec * 1e6) + loadLyricsForTrack(artist, title, snapshot.album, snapshot.lengthSec, key) + end +end + +function onEnable() + publish() + refreshPlayer() +end + +function update() + refreshPlayer() +end + +noctalia.setUpdateInterval(POLL_MS) \ No newline at end of file diff --git a/media-lyrics/shortcut.luau b/media-lyrics/shortcut.luau new file mode 100644 index 000000000..902ccf6ff --- /dev/null +++ b/media-lyrics/shortcut.luau @@ -0,0 +1,13 @@ +--!nonstrict +-- Control-center tile: toggles the media-lyrics panel (size preset from the +-- panel_size setting: compact / medium / large). +local function panelIdForSize() + local sz = noctalia.getConfig("panel_size") + if sz == "compact" then return "panel-compact" end + if sz == "large" then return "panel-large" end + return "panel" +end + +function onClick() + noctalia.togglePanel("tranzem/media-lyrics:" .. panelIdForSize()) +end diff --git a/media-lyrics/thumbnail.webp b/media-lyrics/thumbnail.webp new file mode 100644 index 000000000..c156c8f1e Binary files /dev/null and b/media-lyrics/thumbnail.webp differ diff --git a/media-lyrics/translations/en.json b/media-lyrics/translations/en.json new file mode 100644 index 000000000..ac77af058 --- /dev/null +++ b/media-lyrics/translations/en.json @@ -0,0 +1,46 @@ +{ + "common": { + "no-title": "(no title)", + "unknown-artist": "Unknown artist" + }, + "provider": { + "local": "Local", + "cache": "Cache" + }, + "service": { + "lyrics-unreachable": "LRCLIB unreachable (network error)" + }, + "panel": { + "loading-lyrics": "Loading lyrics…", + "lyrics-error": "Lyrics error: ", + "no-lyrics": "No lyrics found", + "no-media-player": "No media player", + "now-playing": "NOW PLAYING", + "reload-lyrics": "Reload lyrics", + "synced": "synced", + "unsynced": "unsynced" + }, + "settings": { + "offset_ms": { + "label": "Lyrics offset (ms)", + "description": "Shift lyric timing: positive values show lines earlier, negative later." + }, + "panel_size": { + "label": "Panel size", + "description": "Size preset of the lyrics panel: compact (440), medium (520), large (640). The bar widget and control-center tile open this preset.", + "options": { + "compact": "Compact (440)", + "medium": "Medium (520)", + "large": "Large (640)" + } + }, + "use_cache": { + "label": "Cache lyrics", + "description": "Store fetched lyrics in the plugin data directory for offline reuse." + }, + "local_lyrics_dir": { + "label": "Local lyrics folder", + "description": "Folder with local .lrc files named 'Artist - Title.lrc'; searched before LRCLIB." + } + } +} diff --git a/media-lyrics/widget.luau b/media-lyrics/widget.luau new file mode 100644 index 000000000..5f7697184 --- /dev/null +++ b/media-lyrics/widget.luau @@ -0,0 +1,110 @@ +--!nonstrict +-- Bar widget: compact now-playing indicator; click opens the lyrics panel. +-- +-- Reads the snapshot published by the service (noctalia.state "media") and +-- renders glyph + truncated title. Left click toggles the panel, middle click +-- toggles play/pause, scroll switches tracks. + +local glyphPlay = "player-play" +local glyphPause = "player-pause" + +-- Which panel preset to open: the plugin setting panel_size selects among +-- compact (440), medium (520, default) and large (640) presets declared in +-- plugin.toml. Medium keeps the historical `panel` id for IPC compatibility. +local function panelIdForSize() + local sz = noctalia.getConfig("panel_size") + if sz == "compact" then return "panel-compact" end + if sz == "large" then return "panel-large" end + return "panel" +end + +local TITLE_MAX = 26 + +-- UTF-8 helpers: a byte >= 0xC0 starts a character (2–4 bytes), a byte in +-- 0x80..0xBF is a continuation. charCount walks whole characters so titles +-- in any script truncate on char boundaries, never mid-sequence. +local function charCount(s) + local i, n = 1, 0 + while i <= #s do + local b = s:byte(i) + if b >= 0xF0 then i = i + 4 + elseif b >= 0xE0 then i = i + 3 + elseif b >= 0xC0 then i = i + 2 + else i = i + 1 end + n = n + 1 + end + return n +end + +local function charPrefix(s, maxChars) + local i, n = 1, 0 + while i <= #s and n < maxChars do + local b = s:byte(i) + if b >= 0xF0 then i = i + 4 + elseif b >= 0xE0 then i = i + 3 + elseif b >= 0xC0 then i = i + 2 + else i = i + 1 end + n = n + 1 + end + return s:sub(1, i - 1) +end + +local function truncate(s) + s = tostring(s or "") + if charCount(s) <= TITLE_MAX then return s end + return charPrefix(s, TITLE_MAX - 1) .. "…" +end + +local function media() + local ok, v = pcall(function() return noctalia.state.get("media") end) + if ok and type(v) == "table" then return v end + return nil +end + +local function renderFrom(m) + if not m or not m.hasPlayer then + -- Empty state is ALSO a render() tree: the host warns that + -- setGlyph/setText have no visible effect once a render() tree is + -- active (verified live), so a plain disc glyph is rendered instead. + -- No explicit color roles — the widget instance's Color/Icon Color + -- settings apply. + barWidget.render(ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "disc", size = 14 }), + })) + return + end + local g = m.playing and glyphPause or glyphPlay + local title = truncate(m.title ~= "" and m.title or noctalia.tr("common.no-title")) + -- No explicit color roles: the host applies the widget instance's Color / + -- Icon Color settings (Default = theme role). Explicit roles here would + -- silently ignore the user's per-widget color configuration. + barWidget.render(ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = g, size = 14 }), + ui.label({ text = title, fontSize = 12 }), + })) +end + +function update() + renderFrom(media()) +end + +noctalia.state.watch("media", function(value) + renderFrom(value) +end) + +noctalia.setUpdateInterval(2000) + +function onClick() + noctalia.togglePanel("tranzem/media-lyrics:" .. panelIdForSize()) +end + +function onMiddleClick() + noctalia.state.set("panel_cmd", { action = "toggle" }) +end + +-- scroll up = next, down = previous (mirrors the built-in media widget) +function onScroll(axis, steps, startsGesture) + if axis ~= "vertical" or startsGesture then return end + local dir = (steps or 0) > 0 and "next" or "prev" + noctalia.state.set("panel_cmd", { action = dir }) +end \ No newline at end of file