- {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"
/>