From 74bae74f9fee6f8ae0af7252fa7057a89fc200ac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 22:39:24 -0400 Subject: [PATCH] fix(web): songs-like play overlay + scrubber smoothing PlaylistCard: per-artist system variants (songs_like_artist) all share one variant tag but exist as one playlist per seed artist; routing their play handler through systemShuffle(variant) hit the wrong playlist (or 404). Detect via seed_artist_id != null and fall through to getPlaylist(playlist.id) for those; still tag the queue with the variant so source attribution stays correct. smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors Android's rememberSmoothPositionMs. player.position only updates ~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly; $effect resets on each canonical tick / seek / track-change and a rAF loop extrapolates at playback rate between ticks. Wired into both PlayerBar.svelte (mini + expanded seek rows) and now-playing/+page.svelte. Seek input handler still reads the raw range value (not smoothed.value) so user drags stay authoritative. --- web/src/lib/components/PlayerBar.svelte | 21 ++++++--- web/src/lib/components/PlaylistCard.svelte | 15 +++++- web/src/lib/player/smoothPosition.svelte.ts | 51 +++++++++++++++++++++ web/src/routes/now-playing/+page.svelte | 9 +++- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/player/smoothPosition.svelte.ts diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index fc9bdc0a..30dc8ac4 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -14,11 +14,17 @@ import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor'; + import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; const current = $derived(player.current); + // Per-frame interpolated playhead so the scrubber bar moves + // smoothly between server ticks instead of stepping every ~250ms. + // Reset on track/play-state change via the helper's $effect. + const smoothed = useSmoothPosition(); + const skipPrevDisabled = $derived(player.index === 0 && player.position < 3); const skipNextDisabled = $derived( player.index === player.queue.length - 1 && player.repeat === 'off' @@ -226,10 +232,12 @@ - +
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} @@ -346,10 +354,11 @@
- +
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 40948f51..3f871edf 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -64,7 +64,15 @@ starting = true; try { const variant = playlist.system_variant; - if (variant != null) { + // systemShuffle keys by variant alone, which only works for + // SINGLE-instance variants (for_you, discover, deep_cuts, …). + // songs_like_artist has one playlist PER seed artist; using + // the variant alone would return the wrong one (or fail). + // For those, fall through to getPlaylist so the play handler + // hits the exact playlist the user clicked on. Detect via + // seed_artist_id which is non-null only for per-artist mixes. + const isPerArtist = playlist.seed_artist_id != null; + if (variant != null && !isPerArtist) { // #415: server returns the rotation-aware order (unplayed // this rotation first). Play it as-is — no client shuffle — // and tag the queue so play_started carries `source` and @@ -78,7 +86,10 @@ const detail = await getPlaylist(playlist.id); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { - playQueue(refs, 0); + // Tag the queue with the variant when one exists so play + // events still carry the correct source attribution even + // though we went through the per-id path. + playQueue(refs, 0, variant != null ? { source: variant } : undefined); } } } finally { diff --git a/web/src/lib/player/smoothPosition.svelte.ts b/web/src/lib/player/smoothPosition.svelte.ts new file mode 100644 index 00000000..7ba471cf --- /dev/null +++ b/web/src/lib/player/smoothPosition.svelte.ts @@ -0,0 +1,51 @@ +import { player } from './store.svelte'; + +/** + * Reactive smoothed playhead position in seconds. Mirrors Android's + * `rememberSmoothPositionMs`: the underlying `player.position` updates + * every ~250 ms (HTML audio `timeupdate` fires ~4 Hz), so reading it + * directly snaps the scrubber forward in discrete steps. This helper + * extrapolates per-frame between server ticks so the bar glides at + * playback rate. + * + * The `$effect` re-runs whenever the canonical position / isPlaying / + * duration changes, which both handles natural ticks (re-sync to the + * new authoritative value) and seeks (instant jump on user action). + * The rAF loop advances at wall-clock seconds since the segment + * started, so as long as the canonical position keeps up with real + * time the resync on each tick is sub-frame and invisible. + * + * Returns a `{ value }` accessor instead of a bare number so callers + * can read it reactively inside components (Svelte's rune wiring + * follows the property access). + */ +export function useSmoothPosition(): { readonly value: number } { + let displayed = $state(player.position); + + $effect(() => { + // Reset whenever the canonical position changes (server tick, + // seek, track change) so we anchor to the truth. + displayed = player.position; + + if (!player.isPlaying || player.duration <= 0) return; + + const startReal = performance.now(); + const startPos = player.position; + const duration = player.duration; + + let rafId = 0; + const tick = () => { + const elapsed = (performance.now() - startReal) / 1000; + displayed = Math.min(startPos + elapsed, duration); + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }); + + return { + get value() { + return displayed; + } + }; +} diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index 54ccd4a1..ad963b81 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -14,11 +14,16 @@ } from '$lib/player/store.svelte'; import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; + import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import LikeButton from '$lib/components/LikeButton.svelte'; import { pageTitle } from '$lib/branding'; const current = $derived(player.current); + // Per-frame interpolated playhead so the scrubber thumb glides + // smoothly between server ticks. Shares the helper with PlayerBar. + const smoothed = useSmoothPosition(); + const skipPrevDisabled = $derived(player.index === 0 && player.position < 3); const skipNextDisabled = $derived( player.index === player.queue.length - 1 && player.repeat === 'off' @@ -123,13 +128,13 @@ min="0" max={player.duration || 0} step="0.1" - value={player.position} + value={smoothed.value} oninput={onSeekInput} aria-label="Seek" class="w-full accent-accent" />
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} {formatDuration(player.duration)}