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.
This commit is contained in:
@@ -14,11 +14,17 @@
|
|||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
||||||
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
|
|
||||||
const current = $derived(player.current);
|
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 skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||||
const skipNextDisabled = $derived(
|
const skipNextDisabled = $derived(
|
||||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||||
@@ -226,10 +232,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row: full-width seek slider + time labels -->
|
<!-- Bottom row: full-width seek slider + time labels.
|
||||||
|
Reads the smoothed position so the slider thumb glides
|
||||||
|
between server ticks at playback rate. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
||||||
{formatDuration(player.position)}
|
{formatDuration(smoothed.value)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,7 +245,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
class="flex-1 accent-accent"
|
class="flex-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
@@ -346,10 +354,11 @@
|
|||||||
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Seek row -->
|
<!-- Seek row — reads smoothed position so the thumb glides
|
||||||
|
between server ticks. See useSmoothPosition. -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
||||||
{formatDuration(player.position)}
|
{formatDuration(smoothed.value)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -357,7 +366,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
class="flex-1 accent-accent"
|
class="flex-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,7 +64,15 @@
|
|||||||
starting = true;
|
starting = true;
|
||||||
try {
|
try {
|
||||||
const variant = playlist.system_variant;
|
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
|
// #415: server returns the rotation-aware order (unplayed
|
||||||
// this rotation first). Play it as-is — no client shuffle —
|
// this rotation first). Play it as-is — no client shuffle —
|
||||||
// and tag the queue so play_started carries `source` and
|
// and tag the queue so play_started carries `source` and
|
||||||
@@ -78,7 +86,10 @@
|
|||||||
const detail = await getPlaylist(playlist.id);
|
const detail = await getPlaylist(playlist.id);
|
||||||
const refs = toTrackRefs(detail.tracks);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
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 {
|
} finally {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,11 +14,16 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
|
|
||||||
const current = $derived(player.current);
|
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 skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||||
const skipNextDisabled = $derived(
|
const skipNextDisabled = $derived(
|
||||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||||
@@ -123,13 +128,13 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max={player.duration || 0}
|
max={player.duration || 0}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={player.position}
|
value={smoothed.value}
|
||||||
oninput={onSeekInput}
|
oninput={onSeekInput}
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
class="w-full accent-accent"
|
class="w-full accent-accent"
|
||||||
/>
|
/>
|
||||||
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
|
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
|
||||||
<span>{formatDuration(player.position)}</span>
|
<span>{formatDuration(smoothed.value)}</span>
|
||||||
<span>{formatDuration(player.duration)}</span>
|
<span>{formatDuration(player.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user