- Added remote URL support through
src/plugin/with service matchers for YouTube, SoundCloud, and HypeM plus a genericyt-dlpfallback - Added OS cache directory support via
directories::ProjectDirs - Local files still play directly, but remote tracks now resolve into
TrackInfovalues 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-dlperror reporting, especially around YouTube403failures and invalid HypeM URLs
- Keep YouTube on a download-first cached path for now because direct/process streaming was less reliable than cached playback with current
yt-dlpbehavior - 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
- Cookie/authenticated
yt-dlpsupport 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
- Replaced
playcrate withrodio(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:AudioPlayerstruct wrappingOutputStream+Sink;_streamfield 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.rsas a 100ms-tick crossterm event loop; tracks loop count viaInstantelapsed vs. duration (notSink::get_pos()which doesn't reset onrepeat_infinite) - Added panic hook to restore terminal before printing panic output
- Keys:
Spacepause/resume,qorCtrl+Cquit - Visualizer is simulated (two sine waves per bar, no FFT) — looks good, no audio sample access needed
- Updated
CLAUDE.mdto reflect new architecture andmakecommands - Added
Makefilereference (added in prior commit by user)
- Keep
structoptrather than migrating toclapv4 — 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
Instanton main thread, notSink::get_pos(), becauserepeat_infinite()doesn't reset the sink position counter between loops is_paused()removed fromAudioPlayer— pause state is tracked inAppState, no need to query rodio
total_duration()returnsNonefor 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
- Added
spectrum-analyzer(1.7) for FFT-based audio analysis - New
SampleTap<S>wrapper inaudio.rs: intercepts samples on rodio's audio thread viaIterator::next(), writes to a sharedVecDeque<f32>ring buffer (8192 cap). Usestry_lock()so audio thread never blocks. AudioPlayernow exposessample_buf,sample_rate,channelsupdate_visualizer()inplay_loop.rs: reads latest 2048 mono samples (down-mixing stereo), applies Hann window, runs FFT, maps bins to 32 log-spaced bands (20 Hz–20 kHz), applies asymmetric smoothing (attack 0.6, decay 0.25)- Visualizer rewritten as multi-row multi-color bar chart: green (bass) → yellow (mids) → red (treble), 7 inner rows, fills from bottom
- Progress bar now shows elapsed time even when
total_duration()returnsNone(most VBR MP3s):0:12 / --:--instead of--:-- / --:-- - Tick rate increased from 100ms to 50ms for more responsive visualizer
- Use
try_lock()notlock()inSampleTap::next()— audio thread must never block; occasional missed samples don't matter spectrum-analyzerchosen over rawrustfft— bundles Hann window support and handles windowing/bin extraction cleanly- Scale multiplier of 8.0 on raw FFT magnitude — tuned empirically; may need adjustment for very quiet/loud tracks
- 32 bands, 2048 FFT window — good resolution without latency
- Scale factor (8.0) might need tuning per-track; could auto-normalize based on rolling max
- Could add volume control (
+/-keys →sink.set_volume()) - Color gradient could be more granular (lerp across RGB rather than 3 discrete zones)
- Rewrote visualizer as scatter/particle dot style: deterministic per-cell hash (
cell_noise) for stable non-flickering dots; quadratic density falloff toward amplitude ceiling - Color gradient: pink (bass) → amber → yellow → lime → cyan (treble) via
Color::Rgb(r,g,b) - Added fullscreen toggle (
fkey): full-window scatter with micro-status bar at bottom - Replaced Gauge widget progress bar with custom Paragraph:
━━━●──── 0:42/3:12style - Added symphonia fallback for VBR MP3 duration (
probe_duration_symphoniausing Xing/VBRI headers); total time now shows correctly for most MP3s - Added
libcdep and TTY reattachment (/dev/ttydup2) so crossterm works when stdin is a pipe (xargs invocation) - Fixed
mzkdotfiles function: now capturesskoutput in$()then invokes looper directly instead of piping via xargs - Published v0.1.0 Homebrew tap:
program247365/homebrew-tapwithFormula/looper.rb - Added
brew tap program247365/tap+brew 'program247365/tap/looper'to~/.dotfiles/Brewfile - Updated
Makefilewithrelease,release-patch,release-minor,bump-formulatargets - Updated
README.mdwith Homebrew install, keys table, dev commands, release workflow
- Deterministic cell hash over random noise — prevents flickering on each redraw tick
- Scatter over bar chart — closer to the reference screenshot the user provided
- symphonia was already a transitive dep via rodio; adding it as explicit dep costs nothing
/dev/ttyreattachment is a defensive measure; the real fix is inmzkbut belt-and-suspenders is fine here
- Volume control (
+/-keys →sink.set_volume()) - Auto-normalize FFT scale based on rolling max amplitude per track
- RGB lerp gradient instead of discrete color stops
frame_count: u64added toAppState; incremented unconditionally each tick (including when paused)cell_noisenow takest: usize(=frame_count / 4); dot pattern shifts every ~120ms creating gentle shimmer on all bands, including sustained bassband_peak: Vec<f32>added toAppState; per-band rolling max with 0.998 decay (noise floor 0.02)- FFT bin aggregation changed from mean → max; improves sensitivity for bass bands with 1-2 sparse bins
- Scale factor
* 8.0removed; replaced by per-band peak normalization (raw_mag / band_peak[i]) - Decay smoothing:
0.25/0.75→0.35/0.65(snappier falloff) - Tick rate: 50ms → 30ms (~33 Hz)
scatter_colorrewritten with RGB lerp through 5 stops (pink → amber → yellow → lime → cyan); no more hard zone boundaries
- 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 / 4divisor at 30ms tick = 120ms shimmer rate; empirically feels organic vs. noisy
- Volume control (
+/-keys →sink.set_volume()) - Amplitude-coupled twinkle speed (louder bands twinkle faster)
- Beat-flash: brief brightness boost on kick drum detection
- Added
souvlaki = "0.8"(withuse_zbus, nolibdbus-1-devneeded on Linux); macOS-only depscocoa+objcto driveNSApplication.run() - New
src/media_controls.rscross-platform façade:MediaSession::start() -> (MediaSession, Receiver<KeyCommand>),MediaSessionHandle::{set_metadata, set_playback}(cheap-cloneableArc<Mutex<MediaControls>>) - New
src/macos_runloop.rs: spawns alooper-tuiworker thread, runsNSApp.run()on the main thread (activation policyAccessory, no Dock icon). Worker callsstd::process::exiton completion. - Refactored
play_loop.rsto thread aPlaybackContext { cmd_rx: &Receiver<KeyCommand>, media: Option<MediaSessionHandle> }throughplay_file → play_file_session → play_tracks → loop_playlist/play_single_track → run_loop - Extracted dispatch logic from
run_loopinto adispatch_commandhelper so keyboard events and external (media-key) events flow through one match - Added
KeyCommand::{NextTrack, PreviousTrack},LoopAction::PreviousTrack,AudioPlayer::skip()(callsSink::stop()) - Playlist loop now uses
while idx < total_trackswithidx.saturating_sub(1)for Previous (restarts track 0 if pressed there) instead of aforrange - Bound
n(Next) andb(Previous) keyboard shortcuts in playback mode so the playlist control surface is testable without media keys - Updated
--helpto 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
- 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()+ workerprocess::exit. ConsideredCFRunLoopStop+CFRunLoopWakeUpand[NSApp stop:]+postEvent— both add boilerplate;process::exitafter 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_zbusover defaultuse_dbusso Linux builds don't pull inlibdbus-1-devsystem package — better for distro packaging.
- 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_playbackonce per second from the TUI tick. - Graceful NSApp shutdown if we ever care about Drop-running for
MediaControls(currently sidestepped viaprocess::exit).
/opens a Spotify catalog search overlay from the playback screen and the history browser (src/spotify/search.rs, overlay rendering insrc/tui.rs, key routing insrc/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)".
- 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_tokentakes a comma-separated&str, not a slice (docs.rs rendering misleads).
- Live search-as-you-type and/or category tabs if the sectioned list feels cramped.
- Search-first launch mode (
looper searchor/from a barelooperbefore 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).
cargolives in~/.cargo/bin— new shells should pick it up via the rustup env hooks.
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_panelinsrc/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).
- 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_secondsonly 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.
- Consider persisting
total_play_secondsincrementally (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.
- Migration
2026-07-05-000004_add_kind:played_tracks.kind TEXT NOT NULL DEFAULT 'track'('track' | 'collection'), surfaced asRecordKindthroughTrackRecord/HistoryRow. - A playlist/album launch records a collection row (
collection_recordinstorage.rs, called fromplay_tracks): keyed by the requested URL soenterin 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_keythreaded throughplay_single_track), so the Time Played sort ranks collections honestly. - yt-dlp entries now parse
playlist_title/playlistintoMetadataEntry.collectionand stamp it onTrackInfo— 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_recordtitle/URL-fallback,parse_entryplaylist-title parsing, glyph render test. Migration verified against a copy of the real 19-row DB (all rows backfill astrack).
- Record collection + per-track rows (not collection-only): keeps favorites/replay on individual tracks, no regression.
- Same table +
kindcolumn over a separateplayed_collectionstable: 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).
- 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/searchis 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_LIMIT5→8; requestlimit8→10;type=artistadded 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) viapanel.pending_artistand 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).
- Added
HistorySortField::Favorites(label "Favorites") between Last Played and Platform in the h/l cycle, with comparatoris_favorite→last_played_at→title. - Decided: a plain sort mode, not a pin-favorites overlay — one axis of state, and
rreverse stays consistent across all modes. Within the starred group, most recently played sorts first (user's pick). - Note:
descendingis 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 viacompare_history_rows); fullcargo testgreen.
- 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:
sstar (Storage::ensure_track_row, play_count 0) orl-loop (normalrecord_play, seconds to the track, not the collection). - New
lkey on playlist tracks: arms "loop this song" — song finishes, then re-plays solo withloop_foreveron (LoopAction::LoopCurrentTrack,solo_loopflag threaded throughplay_single_track).n/bresume the playlist; secondldisarms before the song ends;linert 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_secondshad to learnsolo_loopor 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.
- 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::readblocks 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 inread, andAudioPlayerteardown then hangs in_devicedrop (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:
AudioPlayernow keeps the download'sCancellationToken(HTTP and process inputs) and cancels it in a customDropbefore 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 testdrop_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_timetreats a missing row as a no-op (respect the delete; kills the "Record not found" warning smeared over the TUI), ands/star alwaysensure_track_rowfirst 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-patchto 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.