From 40641fb9eb4b984f1f2f30205d43961cf56b9f51 Mon Sep 17 00:00:00 2001 From: Luke Stebner Date: Thu, 9 Jul 2026 22:03:09 -0700 Subject: [PATCH 1/4] feat: custom sort dropdown, spacebar toggle, queue loop, Ctrl+Q quit - Sort-by control now uses a new reusable Dropdown component (extracted from the equalizer's listbox pattern) instead of a native ui.setAlbumSort(e.currentTarget.value as AlbumSort)} - > - - - - - + ui.setAlbumSort(sort)} + ariaLabel="Sort albums by" + /> {/if} @@ -186,43 +216,6 @@ white-space: nowrap; } - .sort-select-wrap { - position: relative; - } - - .sort-select-wrap::after { - content: ""; - position: absolute; - top: 50%; - right: 0.5em; - width: 12px; - height: 12px; - transform: translateY(-50%); - pointer-events: none; - background-color: var(--text-tertiary); - mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - } - - .sort-select { - appearance: none; - background-color: var(--bg-hover); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-primary); - cursor: pointer; - font-family: inherit; - font-size: inherit; - padding: 0.4em 1.8em 0.4em 0.6em; - outline: none; - } - - .sort-select:focus { - border-color: var(--accent); - } - .toolbar-right { display: flex; align-items: center; From 499d34175c53857e990606afa86c1eba36b70e49 Mon Sep 17 00:00:00 2001 From: Luke Stebner Date: Thu, 9 Jul 2026 22:03:15 -0700 Subject: [PATCH 2/4] fix: reconcile progress bar seek with backend confirmation Seeking is dispatched fire-and-forget over Tauri IPC, and the backend's position only reaches the frontend via the ~4Hz playback-progress event. Clearing the "seeking" flag immediately on commit raced that event: the bar would flash back to the stale pre-seek position before snapping forward once the real update arrived. Now it holds the optimistic position until an incoming snapshot lands within tolerance of the target, with a fallback timeout in case a seek silently fails. --- .../components/transport/TransportBar.svelte | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/components/transport/TransportBar.svelte b/src/lib/components/transport/TransportBar.svelte index 24c9b78..79ede8f 100644 --- a/src/lib/components/transport/TransportBar.svelte +++ b/src/lib/components/transport/TransportBar.svelte @@ -25,17 +25,49 @@ let seeking = $state(false); let seekValue = $state(0); + // Seeking is dispatched fire-and-forget over Tauri IPC, and the backend's + // position only reaches the frontend via the ~4Hz playback-progress + // event — so the target isn't confirmed the instant player.seek() + // returns. Clearing `seeking` immediately on commit raced that event: + // the bar would flash back to the stale pre-seek position before + // snapping forward once the real update arrived. Instead, keep showing + // the optimistic value until an incoming snapshot's position lands + // within tolerance of the target (or the fallback timeout gives up, in + // case the seek silently failed on the backend). + let pendingSeekTargetMs = $state(null); + let seekFallbackTimer: ReturnType | undefined; + const SEEK_CONFIRM_TOLERANCE_MS = 750; + const SEEK_CONFIRM_FALLBACK_MS = 2000; + + function clearPendingSeek() { + seeking = false; + pendingSeekTargetMs = null; + clearTimeout(seekFallbackTimer); + } + function onSeekInput(e: Event) { seeking = true; + pendingSeekTargetMs = null; + clearTimeout(seekFallbackTimer); seekValue = Number((e.target as HTMLInputElement).value); } function onSeekCommit(e: Event) { const positionMs = Number((e.target as HTMLInputElement).value); + seekValue = positionMs; + pendingSeekTargetMs = positionMs; player.seek(positionMs); - seeking = false; + clearTimeout(seekFallbackTimer); + seekFallbackTimer = setTimeout(clearPendingSeek, SEEK_CONFIRM_FALLBACK_MS); } + $effect(() => { + if (pendingSeekTargetMs === null) return; + if (Math.abs(player.snapshot.position_ms - pendingSeekTargetMs) <= SEEK_CONFIRM_TOLERANCE_MS) { + clearPendingSeek(); + } + }); + function onVolumeChange(e: Event) { player.setVolume(Number((e.target as HTMLInputElement).value)); } From 6fb96cdd4596f74a8302b799a057d8bfa8d0ed05 Mon Sep 17 00:00:00 2001 From: Luke Stebner Date: Thu, 9 Jul 2026 22:03:19 -0700 Subject: [PATCH 3/4] fix: correct end-of-track seek borrow arithmetic to stop a crash Seeking near the end of a track nudges the target time back by a small epsilon; when that subtraction underflowed the fractional-second part, the borrow-a-second correction computed 1.0 - frac on an already-negative frac, producing values like 1.00005. That violates symphonia's Time::frac invariant of [0.0, 1.0) and panicked deep in TimeBase::calc_timestamp, which poisoned rodio's sink mutex in the audio callback thread and cascaded into a full process abort. Fixed to 1.0 + frac and added unit tests covering the failing case. --- src-tauri/src/audio/decode.rs | 66 +++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/audio/decode.rs b/src-tauri/src/audio/decode.rs index 73d82cc..39224a6 100644 --- a/src-tauri/src/audio/decode.rs +++ b/src-tauri/src/audio/decode.rs @@ -17,6 +17,11 @@ use symphonia::default::{get_codecs, get_probe}; const MAX_DECODE_RETRIES: usize = 3; +// Seeking exactly at (or past) a track's total duration leaves symphonia's +// format reader with no packet to land on, so an end-of-track seek target +// is nudged just inside the track by this many seconds instead. +const END_OF_TRACK_SEEK_EPSILON: f64 = 0.0001; + /// Wraps a file with a correctly-reported byte length, unlike rodio 0.20's /// internal `ReadSeekSource`, which hardcodes `byte_len()` to `None`. Without /// a known byte length, symphonia's FLAC/format readers can't compute a seek @@ -207,6 +212,20 @@ impl FileSource { } } +/// Nudges a `Time` back by `END_OF_TRACK_SEEK_EPSILON`, borrowing a whole +/// second (via `1.0 + frac`, not `1.0 - frac`) when the subtraction pushes +/// the fractional part negative — `Time::frac` must stay within `[0.0, +/// 1.0)` or symphonia's `TimeBase::calc_timestamp` panics. +fn time_just_before(duration: Time) -> Time { + let mut t = duration; + t.frac -= END_OF_TRACK_SEEK_EPSILON; + if t.frac < 0.0 { + t.seconds = t.seconds.saturating_sub(1); + t.frac += 1.0; + } + t +} + impl Iterator for FileSource { type Item = i16; @@ -259,13 +278,7 @@ impl Source for FileSource { .is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1); let time: Time = if seek_beyond_end { - let mut t = self.total_duration.expect("checked by seek_beyond_end above"); - t.frac -= 0.0001; - if t.frac < 0.0 { - t.seconds = t.seconds.saturating_sub(1); - t.frac = 1.0 - t.frac; - } - t + time_just_before(self.total_duration.expect("checked by seek_beyond_end above")) } else { pos.as_secs_f64().into() }; @@ -290,3 +303,42 @@ impl Source for FileSource { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid_time(time: Time) { + assert!( + time.frac >= 0.0 && time.frac < 1.0, + "Time::frac must stay in [0.0, 1.0), got {}", + time.frac + ); + } + + #[test] + fn time_just_before_subtracts_the_epsilon_without_borrowing() { + let result = time_just_before(Time::new(10, 0.5)); + assert_valid_time(result); + assert_eq!(result.seconds, 10); + assert!((result.frac - (0.5 - END_OF_TRACK_SEEK_EPSILON)).abs() < f64::EPSILON); + } + + #[test] + fn time_just_before_borrows_a_second_when_frac_underflows() { + // A previous version computed `1.0 - t.frac` here on an already-negative + // `t.frac`, producing e.g. 1.00005 and panicking deep in symphonia's + // `TimeBase::calc_timestamp` ("Invalid range for Time fractional part"). + let result = time_just_before(Time::new(5, 0.00005)); + assert_valid_time(result); + assert_eq!(result.seconds, 4); + assert!((result.frac - 0.99995).abs() < 1e-9); + } + + #[test] + fn time_just_before_saturates_seconds_at_zero_for_sub_second_tracks() { + let result = time_just_before(Time::new(0, 0.0)); + assert_valid_time(result); + assert_eq!(result.seconds, 0); + } +} From ea3a8c11a1d8a67e39d2abd0c48e08c2fa1ecaf7 Mon Sep 17 00:00:00 2001 From: Luke Stebner Date: Thu, 9 Jul 2026 22:16:05 -0700 Subject: [PATCH 4/4] fix: address code review notes on spacebar repeat and queue-end MPRIS - Guard the spacebar handler against OS auto-repeat: player.snapshot.state only updates from the backend's ~4Hz playback-progress event, so a held key would re-read stale state and spam the same toggle command. - Document why poll_queue_advance's no-queue branch no longer sends an MPRIS Stopped notice: sink and queue are always paired, so it's unreachable today, kept only to keep last_sink_len in sync. --- src-tauri/src/audio/engine.rs | 6 ++++++ src/routes/+layout.svelte | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index c67ad61..97e67f8 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -132,6 +132,12 @@ fn poll_queue_advance(state: &mut EngineState, mpris: &Mpris) { start_playback_from_queue(state, mpris, false, None); persist_session(state); } else { + // state.sink and state.queue are always set/cleared together (Stop, + // SetQueue, and start_playback_from_queue all pair them), so this + // branch should be unreachable while state.sink is Some — kept only + // to keep last_sink_len in sync if that invariant is ever broken. + // No MPRIS update needed here: whatever paired teardown cleared the + // queue already sent its own Stopped notice. state.last_sink_len = sink.len(); } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cbdb81a..8775900 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -53,6 +53,11 @@ return; } event.preventDefault(); + // Ignore OS auto-repeat: player.snapshot.state only updates from the + // backend's ~4Hz playback-progress event, not synchronously after a + // command is sent, so a held key would re-read the same stale state + // and spam the same toggle command on every repeat. + if (event.repeat) return; player.togglePlayPause(); }