You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
2026-04-16: Online playback, loading UI, service badges
What changed
Added remote URL support through src/plugin/ with service matchers for YouTube, SoundCloud, and HypeM plus a generic yt-dlp fallback
Added OS cache directory support via directories::ProjectDirs
Local files still play directly, but remote tracks now resolve into TrackInfo values that can point at cached files, HTTP streams, or process-backed streams
Added playlist orchestration in play_loop.rs: single tracks loop forever; playlists play each track once and then loop the entire list
Added bounded background prefetch for playlist tracks
Added a full-screen loading scene for uncached remote startup with progress, bytes, speed, and ETA
Added compact cache status support in the playback header
Added small source badges in the TUI: YT, SC, HM
Added clearer yt-dlp error reporting, especially around YouTube 403 failures and invalid HypeM URLs
What we decided
Keep YouTube on a download-first cached path for now because direct/process streaming was less reliable than cached playback with current yt-dlp behavior
Keep SoundCloud and HypeM on the newer hybrid path: prefer stream-first where workable, fall back to download-first
Put remote startup progress inside the TUI instead of relying on stderr logging
Use text badges instead of terminal image/SVG rendering for source icons; simpler and much more reliable
What to revisit
Cookie/authenticated yt-dlp support for YouTube when anonymous access fails
More robust in-player cache progress after the loading screen handoff
Optional fallback from text badges to Nerd Font glyphs if portability concerns are acceptable
More live-service smoke coverage across public playlist URLs
2026-04-06: Add ratatui TUI with rodio audio backend
What changed
Replaced play crate with rodio (0.17) for proper audio control (pause/resume, duration probing, repeat_infinite() looping)
Added ratatui (0.26) + crossterm (0.27) for the terminal UI
New src/audio.rs: AudioPlayer struct wrapping OutputStream + Sink; _stream field keeps the audio device alive for the process lifetime
New src/tui.rs: AppState, setup_terminal/restore_terminal, draw() with 4-section layout (header, progress bar, animated visualizer, footer)
Rewrote src/play_loop.rs as a 100ms-tick crossterm event loop; tracks loop count via Instant elapsed vs. duration (not Sink::get_pos() which doesn't reset on repeat_infinite)
Added panic hook to restore terminal before printing panic output
Keys: Space pause/resume, q or Ctrl+C quit
Visualizer is simulated (two sine waves per bar, no FFT) — looks good, no audio sample access needed
Updated CLAUDE.md to reflect new architecture and make commands
Added Makefile reference (added in prior commit by user)
What we decided
Keep structopt rather than migrating to clap v4 — not in scope
Simulated visualizer over real FFT — adding FFT would require intercepting the rodio decode pipeline; not worth the complexity for a loop tool
Duration tracked via Instant on main thread, not Sink::get_pos(), because repeat_infinite() doesn't reset the sink position counter between loops
is_paused() removed from AudioPlayer — pause state is tracked in AppState, no need to query rodio
What to revisit
total_duration() returns None for some VBR MP3s — progress bar now shows elapsed correctly; total still shows --:--
Loop count stays at 1 when duration is unknown — acceptable for now
Could add volume control (+/- keys → sink.set_volume()) as a follow-up
2026-04-06: Real FFT visualizer + progress time fix
What changed
Added spectrum-analyzer (1.7) for FFT-based audio analysis
New SampleTap<S> wrapper in audio.rs: intercepts samples on rodio's audio thread via Iterator::next(), writes to a shared VecDeque<f32> ring buffer (8192 cap). Uses try_lock() so audio thread never blocks.
AudioPlayer now exposes sample_buf, sample_rate, channels
scatter_color rewritten with RGB lerp through 5 stops (pink → amber → yellow → lime → cyan); no more hard zone boundaries
What we decided
Twinkling is the right solution for sustained signals — the FFT correctly shows constant energy for a sustained bass pad; animation adds life without lying about the signal
Per-band AGC ensures the visualizer works well across all genres; a jazz bass guitar and an 808 kick both use the full visual range for their respective band
frame_count / 4 divisor at 30ms tick = 120ms shimmer rate; empirically feels organic vs. noisy
New src/macos_runloop.rs: spawns a looper-tui worker thread, runs NSApp.run() on the main thread (activation policy Accessory, no Dock icon). Worker calls std::process::exit on completion.
Refactored play_loop.rs to thread a PlaybackContext { cmd_rx: &Receiver<KeyCommand>, media: Option<MediaSessionHandle> } through play_file → play_file_session → play_tracks → loop_playlist/play_single_track → run_loop
Extracted dispatch logic from run_loop into a dispatch_command helper so keyboard events and external (media-key) events flow through one match
Playlist loop now uses while idx < total_tracks with idx.saturating_sub(1) for Previous (restarts track 0 if pressed there) instead of a for range
Bound n (Next) and b (Previous) keyboard shortcuts in playback mode so the playlist control surface is testable without media keys
Updated --help to document new keys + macOS media-key behavior
Phase 2: set_metadata(&track) on each track start, set_playback(paused, elapsed) on TogglePause — populates Control Center / lock screen / AirPods Now Playing widget
What we decided
Use one crate (souvlaki) for all three OSes rather than per-platform glue. The dep is not #[cfg]-gated; only the setup code is.
macOS thread-flip via NSApp.run() + worker process::exit. Considered CFRunLoopStop+CFRunLoopWakeUp and [NSApp stop:]+postEvent — both add boilerplate; process::exit after the worker has cleanly run terminal-restore is observably equivalent and far simpler.
Skip a custom NSStatusItem (menu-bar text) — the souvlaki integration already populates the system Now Playing widget which is the iTunes-equivalent on modern macOS. Custom NSStatusItem would duplicate that surface.
Defer Windows: would need a hidden message-only HWND + per-tick pump_event_queue (souvlaki ships an example). Additive change; ship later if anyone asks.
use_zbus over default use_dbus so Linux builds don't pull in libdbus-1-dev system package — better for distro packaging.
What to revisit
Manual smoke test on real Mac hardware: F8 (Play/Pause), F7/F9 (Prev/Next), Control Center widget, AirPods double-tap, lock screen, terminal resize during playback, q + Ctrl-C clean exit.
Souvlaki issue #77 (debug-build panic on macOS, open). Run release-build smoke if debug crashes.
Track artwork in Now Playing — yt-dlp metadata has thumbnail URLs we could pass to MediaMetadata.cover_url.
Live progress updates in the widget (currently set on track-change and pause/resume only). Could push set_playback once per second from the TUI tick.
Graceful NSApp shutdown if we ever care about Drop-running for MediaControls (currently sidestepped via process::exit).
2026-07-05: In-TUI Spotify search (/) — searched, navigated, and looped from the terminal
What changed
/ opens a Spotify catalog search overlay from the playback screen and the history browser (src/spotify/search.rs, overlay rendering in src/tui.rs, key routing in src/play_loop.rs).
Submit-to-search model: type query, Enter runs one blocking Web API call (~300ms, "searching…" frame first); results grouped SONGS/ALBUMS/PLAYLISTS.
Vim navigation: j/k (skips section headers), gg/G, / re-edits the query, Enter plays the selection, Esc closes. The overlay captures all keys while open (only Ctrl-C quits).
Selection rides the existing replay rail (LoopAction::ReplayTarget / play_file_session), so resolve, track-vs-playlist looping, history recording, and album art needed zero new code.
Search auth: user-supplied Spotify API app via SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET (client-credentials flow, token cached in-process ~1h). Missing vars → overlay shows setup steps. Docs in README + docs/spotify.md "Search (optional)".
What we decided and why
Spec'd a zero-setup path (mint Web API tokens from the librespot session) — it died in live testing: Mercury keymaster 403 (retired), login5 tokens get 429 on every api.spotify.com endpoint (with or without client-token attestation), Mercury searchview 404. Spotify has effectively blocked its public Web API for librespot's shared client id — same wall spotify-player hit. Amended the spec in place.
Client-credentials over PKCE: no browser flow, no redirect-URI registration, search needs no user context. Search is now independent of the Premium login (playback still needs it).
Chose submit-to-search over live search-as-you-type (worker thread + stale-result handling not worth it for v1) and sectioned list over tabs (tabs can layer on later).
librespot 0.8 gotcha: TokenProvider::get_token takes a comma-separated &str, not a slice (docs.rs rendering misleads).
What to revisit
Live search-as-you-type and/or category tabs if the sectioned list feels cramped.
Search-first launch mode (looper search or / from a bare looper before the history browser had anything to play).
reqwest 429/Retry-After handling in search (currently surfaces as an error in the overlay; fine for personal API apps with generous quotas).
Machine note: this Mac had no Rust toolchain; installed rustup (stable 1.96.1). cargo lives in ~/.cargo/bin — new shells should pick it up via the rustup env hooks.
2026-07-05: History panel default sort buried just-played tracks — switched to Last Played
What changed
HistoryPanelState::fresh() constructor (src/tui.rs) — fresh history panels now default to sorting by Last Played descending instead of Time Played descending. Both construction sites (browse_history_session, toggle_history_panel in src/play_loop.rs) use it.
Regression test fresh_panel_surfaces_just_played_track_first (src/tui.rs) — records an old track with 70k accumulated seconds and a brand-new play, asserts the new play sorts first under the fresh-panel default. Mutation-verified (fails under the old TimePlayed default).
What we decided and why
Bug report was "Spotify search play not tracked in history" — it was tracked (verified in looper.sqlite3: record lands at playback start). The default Time Played sort buried it: total_play_seconds only accumulates when a track ends (persist_played_time), so a first-listen track sorts to the very bottom of a descending list and looks missing.
Fixed the default sort rather than making play-seconds accumulate live: a "previously played" list should show recency first, and even live accumulation would rank a new track below multi-hour veterans. Sort remains cyclable with the existing keys.
What to revisit
Consider persisting total_play_seconds incrementally (e.g. every N seconds) so the Time Played sort is honest mid-session and a crash doesn't lose the session's playtime.
Playlists/albums are recorded per-track only; the collection itself never appears in history. If "replay that whole playlist" from history matters, that needs a collection-level record.
2026-07-05: Collection history rows — playlists/albums are replayable from history
What changed
Migration 2026-07-05-000004_add_kind: played_tracks.kind TEXT NOT NULL DEFAULT 'track' ('track' | 'collection'), surfaced as RecordKind through TrackRecord/HistoryRow.
A playlist/album launch records a collection row (collection_record in storage.rs, called from play_tracks): keyed by the requested URL so enter in the history browser re-resolves and replays the whole thing. play_count = launches, not loop passes.
Played seconds accrue to both the track row and its collection row (collection_key threaded through play_single_track), so the Time Played sort ranks collections honestly.
yt-dlp entries now parse playlist_title/playlist into MetadataEntry.collection and stamp it on TrackInfo — YouTube/SoundCloud playlists get real collection names in history, the playback header, and Now Playing. Single-JSON fallback stamps the top-level playlist title.
Collection rows render with a ≡ prefix and a warm tint in the history table.
Tests: kind round-trip, collection_record title/URL-fallback, parse_entry playlist-title parsing, glyph render test. Migration verified against a copy of the real 19-row DB (all rows backfill as track).
What we decided and why (grill session)
Record collection + per-track rows (not collection-only): keeps favorites/replay on individual tracks, no regression.
Same table + kind column over a separate played_collections table: shared favorite/delete/sort/replica code, one small migration.
Record at launch, consistent with per-track behavior — the "record at end" variant was rejected as the same class of bug as the sort issue fixed earlier today.
Delete on a collection row prunes only that row (no membership stored, no cascade).
What to revisit
Manual smoke test pending: play a Spotify album and a YouTube playlist, check the ≡ rows appear and replay.
1-track playlists take the single-track path and record no collection row.
Old-version binaries opening a migrated DB are fine (diesel selects by name, inserts get the SQL default); replica last-write-wins across versions unchanged.
2026-07-05: Search missed most of an artist's albums — added ARTISTS section + full-discography browse
Investigated "The Toxic Avenger" showing 3 of 14 albums: /v1/search is relevance-ranked
text search (681 album matches for that query), the request asked for 8, and the overlay
displayed only 5 (ALBUM_LIMIT). Two fetched albums were silently dropped by the display cap.
Live-probed the Web API: dev-mode apps now reject limit > 10 (400 "Invalid limit",
2025 restriction) — documented in CLAUDE.md; search + discography paging pinned to 10.
Changes: ALBUM_LIMIT 5→8; request limit 8→10; type=artist added to search; new
ARTISTS section (3 max) between SONGS and ALBUMS. Enter on an artist row fetches
/v1/artists/{id}/albums (paged by 10, capped at 50) and swaps the overlay to a grouped
discography (ALBUMS / SINGLES & EPS / COMPILATIONS) via panel.pending_artist and the
existing "searching…" deferred-execute rail. / + Enter re-runs the search to go back.
Decided: text search can never guarantee an artist's complete catalog — the artist-albums
endpoint is the only authoritative source, so browse-by-artist is a distinct flow, not a
bigger search limit. Discography cap bounds TUI-thread blocking (~5 sequential calls max).
Verified: TDD (parser/flatten tests RED→GREEN), plus live smoke tests
(search_smoke, discography_smoke — 14 albums returned for the artist).
Revisit: mid-list "searching…" hint says the same thing for search and discography;
singles tail truncates silently past 50 entries; artist rows have no byline/detail
(dev-mode search omits follower counts).
2026-07-07: Favorites sort mode in the history browser h/l cycle
Added HistorySortField::Favorites (label "Favorites") between Last Played and
Platform in the h/l cycle, with comparator is_favorite → last_played_at → title.
Decided: a plain sort mode, not a pin-favorites overlay — one axis of state, and
r reverse stays consistent across all modes. Within the starred group, most
recently played sorts first (user's pick).
Note: descending is a whole-list reverse after an ascending sort, so the
comparator treats favorite as greater; the default descending panel is what
floats stars to the top.
Verified: two new storage tests (starred-above-unstarred via list_history,
in-group recency via compare_history_rows); full cargo test green.
Playlist listening no longer writes per-track history rows — only the collection
row (recorded at launch) accumulates plays/seconds. Deliberate acts re-materialize
a track row: s star (Storage::ensure_track_row, play_count 0) or l-loop
(normal record_play, seconds to the track, not the collection).
New l key on playlist tracks: arms "loop this song" — song finishes, then
re-plays solo with loop_forever on (LoopAction::LoopCurrentTrack, solo_loop
flag threaded through play_single_track). n/b resume the playlist; second
l disarms before the song ends; l inert while engaged (repeat_infinite can't
be un-repeated mid-stream).
Header metadata line now always names the loop scope (playlist / will-loop-armed
in amber / looping-track-with-resume-hint / plain looping track); fullscreen
footer got compact variants.
~1.2s vortex animation on arming: scatter field damps while a spinning ring of
the visualizer palette + center ∞ eases in/out (vortex_cell, field-warp — the
scatter is density-based, there are no particles to pull).
Decided: solo loop is a detour, not a conversion — playlist position is kept.
played_seconds had to learn solo_loop or engaged loops would undercount.
Verified: cargo test green (81 tests, 5 new: ensure_track_row x2, l keymap x2,
header scope strings). Manual smoke still owed on a real Spotify playlist.
2026-07-08: Released v0.13.0 + v0.13.1
v0.13.0: Favorites sort, collection-only playlist history, l-to-loop, loop-scope
metadata, vortex animation. v0.13.1: [l] Loop added to the footer menu, shown
only where the key is live (playlist track, not solo-looping).
Both releases: tag → CI arm64 binary → tap formula updated automatically.
2026-07-09: Fixed the quit-freeze on stalled live streams (the "deleted the playing row" force-quit)
Diagnosed the reported freeze: deleting the currently-playing history row was
incidental. Root cause: rodio decodes inside the CoreAudio render callback, and
StreamDownload::read blocks on a Condvar that only writer progress or
"stream done" wakes. When a live stream goes quiet (e.g. the daily Claude FM
broadcast ending), stream-download's reconnect loop retries forever without
signalling, the callback wedges in read, and AudioPlayer teardown then
hangs in _device drop (AudioOutputUnitStop waits on the callback) — right
between the two quit warnings, matching the frozen screenshot. Force quit then
orphaned yt-dlp/ffmpeg (found still alive, PPID 1).
Fix: AudioPlayer now keeps the download's CancellationToken (HTTP and
process inputs) and cancels it in a custom Drop before fields drop.
stream-download's cancelled path kills the child pipeline and signals stream
done, waking any wedged reader. Also fixes child-process cleanup on every
teardown. New ignored test drop_does_not_hang_on_stalled_process_stream
(ffmpeg emits 6s of sine then stalls without EOF) reproduced the hang, passes
with the fix.
Companion fixes for the delete-current-row scenario: record_playback_time
treats a missing row as a no-op (respect the delete; kills the "Record not
found" warning smeared over the TUI), and s/star always ensure_track_row
first so starring after a mid-play delete materializes instead of crashing.
Verified: cargo test green (80 + 2), stalled-stream test passes in ~9s,
real-world e2e (live lofi stream, quit during playback) exits in ~2s with zero
leftover yt-dlp/ffmpeg. Note: fix is unreleased — needs make release-patch
to reach the Homebrew-installed binary Kevin actually runs.
TUI e2e gotcha for next time: grep the pty log for UI strings fails — ratatui
writes cells with escape codes between characters; use the terminal title
(emitted as one OSC string) as the playback-state signal instead.
2026-07-14: Artist · album byline in the playback header
Added a third header line under the title showing artist and the track's own
album. New album: Option<String> on TrackInfo (distinct from
collection, the playlist name): Spotify stamps track.album.name, yt-dlp
parses an album key when present, local files stay None.
AppState gained artist/album; header_lines emits the byline only when
one exists, so local files keep the compact 2-line header. The art-anchored
header had spare rows (HEADER_ART_ROWS=6); only the art-less box grows,
its height now derived from the line count instead of a hardcoded 4.
Byline suppresses the album when it equals collection (direct album play)
to avoid printing the same name on two adjacent lines.
Verified: cargo test green (81 + 2). Unreleased — needs make release-patch
to reach the Homebrew binary.