A Spotify client for the terminal written in Rust, powered by Ratatui
spotatui is a terminal-based Spotify client written in Rust.
It provides a keyboard-driven interface for controlling Spotify playback,
browsing your library, searching for music, and more, all from your terminal.
Features include native streaming support, audio visualization, Discord Rich Presence,
and media key integration.
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: 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).