spotify-lyrics (#214)
* Add spotify-lyrics plugin * fix(spotify-lyrics): resolve race conditions and add plugin_api * fix(spotify-lyrics): update plugin_api to 3 * fix(spotify-lyrics): resolve github actions validation errors * fix(spotify-lyrics): resize thumbnail to 960x540 to fix validation error * fix(spotify-lyrics): bump version to 1.2.1 * fix(spotify-lyrics): update namespace and replace misleading thumbnail * fix(spotify-lyrics): declare runtime dependencies in plugin.toml and update README requirements * feat(lyrics): implement dynamic panel width sizing * Revert "feat(lyrics): implement dynamic panel width sizing" This reverts commit ef91e6f9f688df7deeb41ba9e5c7606a4904b47a. * feat(spotify-lyrics): implement dynamic panel width sizing * fix(spotify-lyrics): correct target width pre-calculation for upcoming lines * fix(spotify-lyrics): prevent vertical spill by enforcing maxLines=1 * fix(spotify-lyrics): implement dynamic height resizing to encapsulate wrapped text * fix(spotify-lyrics): remove horizontal cap to prevent vertical spill * fix(spotify-lyrics): restore minHeight and implement perfectly safe wrapping height prediction * fix(spotify-lyrics): lock panel width and use vertical dynamic resizing exclusively * fix(spotify-lyrics): implement dynamic font scaling and remove panel dimension animations * fix(spotify-lyrics): restore robust dynamic height logic and discard font scaling * refactor(spotify-lyrics): rewrite height estimation and clean up codebase Root cause: the charUnits per-character width estimation consistently underestimated real rendered widths because the 0.80 multipliers in getLineWidth and getLinesCount cancelled each other out, making the effective calculation ignore the safety margin entirely. Fix: replaced the complex charUnits/toChars/getLineWidth machinery with a simple #text / chars-per-line heuristic using a conservative 0.60x character width factor. This reliably overestimates line count, ensuring the panel always allocates enough height for wrapped text. Quality of life improvements: - Split monolithic render() into renderEmpty/renderPaused/renderPlaying - Reduced update interval from 33ms (30 FPS) to 100ms (10 FPS) - Removed file-read timer (reads every frame at lower FPS instead) - Added clear section headers and inline documentation - Removed all dead code (charUnits, toChars, getLineWidth, etc.) * fix(spotify-lyrics): set panel height=280 in plugin.toml — the actual fix The root cause of the lyrics spilling was never in the Lua code. Noctalia panels are sized exclusively by plugin.toml, not by minHeight on the column layout. Since we had removed width/height from plugin.toml to make sizing 'dynamic', noctalia used a tiny default that couldn't contain wrapped lyrics. minHeight on ui.column had zero effect on the actual panel window size. Set height=280 to comfortably fit 3 lyrics lines even when they wrap. * feat(spotify-lyrics): add dynamic font scaling for long lyrics Long lyrics (>40 chars) now get progressively smaller fonts: - Every 15 chars beyond 40 reduces font by 2px - Minimum font: 10px (panel) / 11px (widget) This prevents vertical overflow regardless of container size by ensuring long lines take up less vertical space when they wrap. * fix(spotify-lyrics): fix plugin IDs and tilde path expansion - Updated bar.luau to toggle correct panel ID - Replaced ~ in noctalia.readFile with absolute path since Lua doesn't auto-expand it - Updated plugin.toml height to 280 and id to noctalia/spotify-lyrics * Fix UI bugs, implement reactive updates, and add album art * Fix plugin manifest validation errors * fix(spotify-lyrics): address PR review comments - Change plugin id from noctalia/ to goatnath/ namespace - Declare runtime dependencies: playerctl, python3, syncedlyrics - Replace hardcoded /home/goatnath path with noctalia.expandPath() - Add [[desktop_widget]] manifest entry for widget.luau - Rewrite README to follow README_TEMPLATE.md structure - Update all references to use corrected plugin id --------- Co-authored-by: goatnath <aadinathkeshav1978@gmail.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
# Spotify Lyrics
|
||||||
|
|
||||||
|
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 — no API keys, cookies, or web scraping required.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `goatnath/spotify-lyrics` |
|
||||||
|
| Entries | Bar widget: `lyrics`; panel: `lyrics-panel`; desktop widget: `lyrics-desktop` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
This plugin requires `playerctl`, `python3`, and the `syncedlyrics` Python package.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Enable the 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 goatnath/spotify-lyrics
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the `lyrics` widget to your bar's layout in your `~/.local/state/noctalia/settings.toml` (next to the `media` widget).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
start = [ "launcher", "workspaces", "media", "lyrics" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Toggle the Lyrics Panel
|
||||||
|
|
||||||
|
Click the `♫` bar icon to toggle the panel, or run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle goatnath/spotify-lyrics:lyrics-panel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Zero Configuration:** Lyrics are pulled from public databases (LRCLIB, NetEase) automatically.
|
||||||
|
- **Caching:** The daemon caches lyrics and album art to `~/.cache/noctalia/lyrics/` so subsequent plays load instantly.
|
||||||
|
- **Network:** The daemon makes HTTPS requests to LRCLIB and NetEase for lyrics, and to the album art URL provided by MPRIS metadata.
|
||||||
|
- **Processes:** Requires a separate `spotify_lyrics_daemon.py` process running as a systemd user service.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- Minimal bar trigger: shows a small lyrics glyph next to the media widget.
|
||||||
|
-- Auto-hides when no music is playing. Click to toggle the lyrics panel.
|
||||||
|
--
|
||||||
|
-- Also acts as the data bridge: reads the daemon's JSON file and publishes
|
||||||
|
-- all lyrics fields into noctalia.state so the panel can reactively consume
|
||||||
|
-- them without polling the filesystem itself.
|
||||||
|
|
||||||
|
local STATE_PATH = noctalia.expandPath("~/.cache/noctalia/lyrics/current.json")
|
||||||
|
|
||||||
|
local function readState()
|
||||||
|
local content = noctalia.readFile(STATE_PATH)
|
||||||
|
if not content then return nil end
|
||||||
|
local state, _ = noctalia.json.decode(content)
|
||||||
|
return state
|
||||||
|
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("goatnath/spotify-lyrics:lyrics-panel")
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
id = "goatnath/spotify-lyrics"
|
||||||
|
name = "Spotify Lyrics"
|
||||||
|
version = "1.2.0"
|
||||||
|
plugin_api = 3
|
||||||
|
author = "goatnath"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = ["playerctl", "python3", "syncedlyrics"]
|
||||||
|
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
|
||||||
|
|
||||||
|
[[desktop_widget]]
|
||||||
|
id = "lyrics-desktop"
|
||||||
|
entry = "widget.luau"
|
||||||
@@ -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()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Spotify Lyrics",
|
||||||
|
"description": "Time-synced lyrics panel linked to the media widget.",
|
||||||
|
"no_music": "No music playing"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user