Skip to content

v0.40.3

Latest

Choose a tag to compare

@github-actions github-actions released this 27 Jul 05:30

Added

  • Lua scripting API v6: cover art and composable layouts in plugin screens: Custom plugin screens are no longer limited to a vertical stack of paragraphs, lists, and gauges. A new { type = "cover_art" } widget draws the current track's artwork from the image spotatui has already downloaded and decoded, so a plugin never re-downloads it and never writes terminal graphics escapes itself; the app keeps ownership of the protocol, resizing, placement, and cleanup, and the widget works for every source that provides art. New row and column containers nest to build grids, and every widget accepts a per-axis size hint (height/width in cells, or height_percent/width_percent), so "cover art beside synchronized lyrics" is now a handful of lines — see the new examples/plugins/now-playing.lua. Paragraphs gained scroll = false so a fixed header stays put while PageUp/PageDown scroll the rest. All v5 widget tables keep working unchanged (#396).

  • Searchable Help menu: Press the configured search key (/ by default) while viewing Help to filter keybinding rows across descriptions, keys, and contexts. Multiple terms are matched together using smart-case matching; press Enter to apply the filter and Esc to clear it.

  • Lyrics view: smooth scrolling, browsing, and a timing nudge: The lyrics view now glides between lines with a short eased animation instead of snapping. Scroll with / (or the mouse wheel) to read ahead or behind: auto-follow pauses while you browse (the browsed line is highlighted and a hint row appears) and resumes with f, Esc, or automatically after 8 seconds without input. Ctrl+d/Ctrl+u page five lines at a time and Ctrl+a/Ctrl+e jump to the first/last line. For LRC files whose timestamps do not line up with your audio, / nudge the lyric timing half a second earlier/later per press; the current offset is shown in the block title and resets on track change. The title also notes "(timing estimated)" when LRCLIB only had plain lyrics, so synthesized line timings are no longer presented as real ones.

  • Banner Gradient toggle: A new setting (Settings → Theme → Banner Gradient, or behavior.banner_gradient in config.yml) turns off the home banner's animated RGB gradient and draws it in the theme's banner color instead. The Terminal (ANSI) preset defaults the gradient off so the banner follows the terminal palette out of the box — tools like pywal recolor it live along with the rest of the UI; an explicit banner_gradient: in the config overrides the preset default either way (#336).

  • Shuffle and repeat for Local Files, Subsonic, and YouTube: The shuffle (Ctrl+s) and repeat (Ctrl+r) keys now work when one of the decoded sources owns playback, not just on Spotify. Repeat cycles Off → All → Track exactly as it does for Spotify: All wraps the context from the last track back to the first, Track replays the current song. Shuffle reorders in place and keeps the currently playing song playing (it becomes the new first track), so toggling it never interrupts audio; turning it back off restores the original order and keeps your place in it. Both modes, plus the shuffle order itself, survive a restart along with the rest of the playback session in last_session.yml. The modes are also routed through MPRIS, so playerctl shuffle/loop and desktop media controls drive them. Internet radio and native queue slots have no track queue of their own, so their playbar and MPRIS snapshot drop the shuffle/repeat controls entirely rather than showing inert ones — which is why the default format.playbar_status_source template gained self-contained {shuffle}/{repeat} segments (see docs/configuration.md).

  • Spotify-style shuffle for native streaming: When shuffle is on and you start a playlist, album, or Liked Songs with spotatui as the playback device, the app now owns the shuffled play order itself instead of leaving it to Spotify. It shuffles the tracklist once (the song you picked plays first and everything else follows exactly once per pass), and on a queue suspend/resume it reloads that same order rather than reshuffling, so interrupting playback with the native queue no longer repeats songs you already heard. The full playlist or Liked Songs context loads in the background (capped at 3000 tracks) so playback starts instantly, turning shuffle off restores the original order and keeps your place, and each repeat-all lap reshuffles for a fresh order like the Spotify client does.

  • Global Song Counter toggle: The anonymous global song counter can now be turned on and off at any time from Settings → Behavior, and the worldwide count refreshes live when you enable it. The first-run opt-in prompt also moved ahead of the source picker so the choice applies to whichever source you pick, and it now keys off the absence of client.yml (the picker's own first-run signal) rather than the contents of config.yml, so a fresh login asks again instead of silently inheriting a stale setting.

Changed

  • Plugin screens: a gauge now honors its height: docs/scripting.md has always documented that a widget's height takes exactly that many rows, but gauges silently ignored it and were pinned to 3 rows. They now take the height they ask for, keeping 3 rows as the default when none is given. A plugin that already set height on a gauge will see that gauge change size.

Fixed

  • Cover art no longer sticks on the previous album with native streaming: With spotatui as the playback device, the cover pane kept showing the last album for seconds after a skip, and for a track started from the native queue it showed the wrong art for the entire song. The art was read from the polled Spotify context, which lags behind a skip and never reports a natively queued track at all - those play through a direct player.load that Spirc does not surface - so the pane stayed pinned to whatever the context still held. librespot already hands over the album art in the very TrackChanged event that starts the song, so that art now wins. Each URL it carries is already the same resolved i.scdn.co form the Web API returns, so the cover-art cache key stays stable when the polled context later catches up and takes over. The context item is still used when librespot supplies no covers at all, which is the normal case for local files, whose art is embedded in the file (#402).

  • Plugins: spotatui.get_lyrics no longer returns the previous track's lyrics: Calling it from a track_change handler - the obvious place to call it - delivered the outgoing track's lyrics and spent the one-shot callback, so a plugin's lyrics never updated on skip. The runner ticks the script engine before its shared track-change detector, so at that moment lyrics_status was still Found for the old track and the request resolved synchronously against it. The immediate-delivery path now also checks that the stored lyrics belong to the item playing right now, falling back to waiting for the in-flight fetch when they do not.

  • No more "Access token missing" error screen between songs: With playback on an external Spotify Connect device, a track transition could throw up the full-screen error route with a 401 Access token missing from me/player, even though the music never stopped and the very next poll succeeded with the same token. Spotify's player service intermittently rejects a valid token, and spotatui amplified it: every 401 forced a full token refresh (which rotates the PKCE refresh token, so a whole new token family was minted every few seconds), the single retry fired immediately and landed back inside the same failure window, and the playback poll then treated the result as fatal. Forced refreshes are now rate limited to one per 30 seconds and a 401 inside that window is retried with the token already in hand, the post-401 retry backs off first so it clears the transition, and the playback poll tolerates a short run of 401s before surfacing anything. Every non-2xx Spotify response is now logged with its endpoint, status, body, and the age of the token that was attached (never the token itself), so the remaining server-side behaviour is diagnosable from a log. Startup now also says which Spotify app the session actually signed in as: the setup wizard makes the shared ncspot client ID the primary for both of its options and keeps your own app as the fallback, so "I set up my own app" has never meant "I am using my own app" — and nothing said so (#395).

  • Native shuffle: the queue resumes at the right track: With shuffle on and spotatui as the playback device, finishing a queued track jumped playback back to an already-played song, or replayed the current one from the beginning, instead of advancing. The client-side shuffle session's position was never updated: librespot 0.8 reports full spotify:track:<id> URIs while the index sync compared bare track ids, so the session index stayed frozen at its starting value and every queue resume targeted the second shuffled track. Track ids are now normalized once at the player-event boundary, which also stops the self-advance guard needlessly reloading every queued track, stops the global song count double-counting, and removes redundant playback polls. A playlist whose background tracklist fetch fails no longer strands the session on a single track: the fetch is retried, the failure is surfaced, and a queue drain falls back to resuming the Spotify context instead of dead-ending in "Queue finished". Disconnect recovery likewise converts a pending shuffled resume to the context route (using the context captured at suspension time) instead of silently dropping the queued tracks, and playback restored at startup (which has no server-side context) resumes from the recovery snapshot after a queued song instead of stopping at "No active playback" (#385).

  • Native playback starts no longer freeze behind a stalled Spotify API call: Starting a playlist or album on the native device went through a me/player/play Web API round trip on the serial event pump, so one stalled call (30 s timeout, retried) froze every queued playback command for 25 s or more on a degraded connection. Context starts now use the direct librespot load first (context resolution happens inside the player, off the pump), with the Web API route kept as a watchdog-covered fallback for loads the player rejects or that fail silently, so a bad session recovers in seconds instead of freezing the app. Liked-state lookups (the heart icons) no longer run on that pump either: opening a large playlist used to queue 8-15 sequential me/library/contains calls that delayed a skip-to-queued-track by ~20 seconds; a single detached worker now resolves them in the background, coalescing rapid navigation into one deduped sweep and never clobbering a like you toggled while it was reading (#386).

  • Startup login no longer hangs: On a fresh install or with a stale token, the auth wizard's "Waiting for authorization callback" step ran a blocking callback server on the async runtime, which could park a worker thread and hang startup before the browser redirect was ever served. The wizard now uses the same async callback server as the in-TUI login (#364).

  • First-run double login completes without a restart: On a fresh first run, both OAuth consents (the Web API login and the native-streaming "Spotify for Desktop" login) use callback port 8989 back-to-back, and the second could fail because the first server hadn't fully released the port — forcing a quit-and-relaunch to finish the streaming login. The streaming flow now waits for the port to be free before opening its consent, so both logins succeed in one run.

  • Playback keys pressed during streaming recovery are no longer lost: If native streaming is mid-recovery when you hit play, the request is stashed and replayed once the session is back (with a 30s recency gate), instead of being silently swallowed. Transfer/activate errors are surfaced, and pressing play with no usable backend shows a status message instead of the Error screen.

  • Home banner animates again: The banner gradient's animation tick was gated on the Home block having keyboard focus, which was almost never the case; it now runs whenever the Home screen is displayed.

  • Playlist sidebar robustness: Refreshing playlists no longer clobbers the folder you have open or your selection, and a playlist list that fails to paginate fully keeps the previous complete list instead of silently publishing a truncated one. Playlist folders also reconcile correctly after deferred streaming startup.

  • Stale cover art and lyrics races: Cover-art and lyrics results are now dropped unless they match the currently playing track, so a slow fetch can no longer overwrite the display with data for a previous song.

  • Native streaming survives a dropped connection: Losing the network - a Wi-Fi blip, a laptop suspend, an access point handing the session off - killed the librespot session, and playback stayed dead until you restarted spotatui. The bundled librespot fork now carries the in-place dealer reconnect from librespot-org/librespot#1692 and exits its spirc task promptly on a session TCP loss, and spotatui reacts to that exit rather than sitting on a dead session: it rebuilds immediately when idle, and defers until the buffered audio actually stalls when a song is playing, so a brief blip never interrupts the music. A published queue slot survives the reconnect window instead of being skipped past, a pause you made before the drop is honoured through it, and the stall watchdog no longer rebuilds the backend when a URI list simply runs out with repeat off (#388).

  • Per-song cloud sync silently stopped firing: With behavior.sync_token set, listening history was meant to upload each song as it finished; instead nothing was sent until you quit, which made quitting slow while the whole session went up in one batch. The history collector read the sync token once through a try_lock, and startup pre-work added later (the recap check and the listens-file load) pushed that read into the live render loop, where tokio's fair mutex hands a freed permit straight to a queued waiter - so the try_lock intermittently lost and pinned the token to None for the rest of the process. The collector now takes the lock properly and re-reads the token every tick, so pasting or clearing it in Settings takes effect mid-session, and startup logs whether cloud sync is enabled. The listens-file parse moved off the runtime worker, and the exit path restores the terminal before its network calls and bounds each one separately, keeping the now-playing clear strictly last so an upsert cannot undo it (#393).

  • A failing audio backend no longer takes playback down: If the output device could not be opened or a write failed - a misconfigured ALSA setup, a device that disappears mid-session - the sink error propagated into librespot's player thread and killed native playback with nothing on screen to explain it. The sink now absorbs start and write failures and keeps consuming packets so the player thread stays alive, and the error is handed to the TUI, which pauses native playback and shows Audio backend failed: … instead of leaving the app looping on a dead device (#384).

  • Play/Pause works on a queued Spotify track: With the native queue playing a queued Spotify track, the playbar showed that track but Play/Pause would not pause it. The toggle fell past every per-source arm to the streaming branch, found no item on the polled Spotify context, and dispatched a bare resume that no queue router consumed - so it reached the Web API as a plain resume, and Spotify either errored with no active device or resumed the previous context on top of the queued song. The queue slot now consumes its own transport controls, pausing and resuming the queued track while leaving the suspended Spotify context intact, and internet radio's controls likewise no longer fall through into streaming resume logic (#374).

  • Liking no longer saves the wrong track: Liking always acted on the polled Spotify context's item, but starting a local, Subsonic, or YouTube file only pauses librespot and never clears that context - so it still held whatever Spotify track played earlier in the session, and liking quietly saved that instead, with nothing on screen to say so. The same happened while the native queue owned playback, where the context holds the suspended track rather than the queued one you are hearing. Liking now acts on the queued Spotify track when the queue owns playback, and shows "The current playback source cannot be liked" when the active source has nothing to save (#375).

  • Plugin repeat no longer leaks to Spotify during internet radio: A plugin changing the repeat mode while a station was playing changed repeat on your real Spotify device instead of being ignored. The decoded routers report "not handled" for radio, which has no queue to repeat, and the runtime then forwarded the event to the Spotify handler. Radio now consumes repeat as a no-op alongside seek and next/previous, matching what the keyboard and MPRIS paths already do. This covers the internet-radio path only, so #376 stays open for the rest.

  • nix develop opens a dev shell again: devShells and apps were nested under packages in flake.nix, which evaluates fine but leaves them invisible to Nix - so nix run had no target and nix develop silently fell back to building the package derivation, the case since v0.37.0. Both move to the proper top-level per-system attributes, the dev shell shares the package's native build inputs and sets LIBCLANG_PATH for bindgen-backed dependencies, and cargoLock.outputHashes now covers the patched librespot git dependencies so nix build vendors them reproducibly (#401).

Performance

  • Instant startup: The TUI now appears immediately; the update check, token validation, and librespot session init run as background tasks, startup reuses one /me response instead of three round trips, and the first page of playlists is shown right away with the rest (and rootlist folders) fetched in the background.
  • Concurrent event pump: Non-Spotify work (lyrics, cover art, Subsonic/radio/local sources, telemetry) runs on a concurrent service lane, so a slow Spotify API call no longer head-of-line-blocks everything behind it. Playlists open on Enter immediately with a loading state.
  • Track tables render only visible rows: Tables format just the rows that fit on screen instead of the entire backing collection every frame (10k-track frame draw: 58ms → 2.5ms in debug builds), and the track table keeps its scroll position anchored while the cursor moves within the visible rows.
  • Home screen frame cost: The changelog pane and banner gradient are cached instead of being rebuilt (and deep-cloned) every frame, changelog lines are pre-wrapped, and scrolling no longer re-composes every line above the offset — frame cost no longer grows with scroll depth.
  • Less redundant work per input: A keypress no longer triggers a second full redraw, API pacing allows bursts of 5 so fan-outs (search, artist pages) start immediately, config saves from volume/resize/shuffle keys are debounced instead of hitting disk per key repeat, and album/artist metadata is cached with HTTP clients reused across requests.

Internal

  • Dependency maintenance: Bumped rand to 0.10 (call sites migrated to the new API), tokio-tungstenite to 0.30, open to 5.4, and tempfile to 3.27.
  • Nix CI leg: A new workflow runs nix flake check --all-systems, nix develop --command cargo --version, and nix build .#default. Between them they catch outputs declared under the wrong attribute path (the class of bug behind the nix develop fix above) and drift between the librespot-* entries in cargoLock.outputHashes and the fork rev in Cargo.toml's [patch] block, which breaks nix build for every Nix user and is only realized by an actual build. Neither is reachable from the Rust test matrix (#405).

Downloads

  • On Windows 10/11 (64-bit)spotatui-windows-x86_64.zip
  • On Linux (Ubuntu, Arch, Fedora, etc.)spotatui-linux-x86_64.tar.gz
  • On macOS with Intel CPUspotatui-macos-x86_64.tar.gz
  • On macOS with Apple Silicon (M1/M2/M3)spotatui-macos-aarch64.tar.gz

Checksums (.sha256) are optional and only needed if you want to verify the download.

The Linux and Windows builds include the extra music sources (Local Files, Subsonic, Internet Radio, YouTube). The YouTube source needs yt-dlp installed (ffmpeg recommended).

To update use "spotatui update --install" or use your package manager of choice.