27766ae063
test-web / test (push) Successful in 39s
When the whole queue proves unplayable (e.g. a tab left open across the daily system-playlist rebuild — the exact stale-snapshot case), the player now re-pulls the fresh snapshot and resumes instead of dead-ending on "Try again". The seeder hands the store an opaque refetch closure so the store stays decoupled from the playlist API and the per-artist (songs_like_artist) identity problem: single-instance variants re-pull via systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from its track. Bounded to one self-heal per exhaustion (reset on the next successful play) so a still-broken refresh can't loop; "Try again" stays the genuine last resort. Wired from PlaylistCard, the playlist detail page, and playRadio. Issue #968. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
653 lines
20 KiB
TypeScript
653 lines
20 KiB
TypeScript
import type { TrackRef, RadioResponse } from '$lib/api/types';
|
||
import { untrack } from 'svelte';
|
||
import { api } from '$lib/api/client';
|
||
import { user } from '$lib/auth/user.svelte';
|
||
import { sessionEnd } from '$lib/auth/sessionEnd.svelte';
|
||
import * as storage from '$lib/util/safeLocalStorage';
|
||
import { readPersistedQueue, writePersistedQueue, clearPersistedQueue } from './persisted';
|
||
|
||
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
||
export type RepeatMode = 'off' | 'all' | 'one';
|
||
|
||
const VOLUME_KEY = 'minstrel.volume';
|
||
const CROSSFADE_KEY = 'minstrel.crossfade';
|
||
|
||
const CROSSFADE_MIN = 0;
|
||
const CROSSFADE_MAX = 12;
|
||
|
||
export function readStoredVolume(): number {
|
||
const raw = storage.read(VOLUME_KEY);
|
||
if (raw == null) return 1;
|
||
const n = Number(raw);
|
||
if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
|
||
return 1;
|
||
}
|
||
|
||
export function readStoredCrossfade(): number {
|
||
const raw = storage.read(CROSSFADE_KEY);
|
||
if (raw == null) return 0;
|
||
const n = Number(raw);
|
||
if (Number.isFinite(n) && n >= CROSSFADE_MIN && n <= CROSSFADE_MAX) {
|
||
return Math.round(n);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Pure-function: maps (position, duration, crossfadeSec) → volume scalar
|
||
// in [0,1]. Outside the crossfade windows it's 1. In the leading X
|
||
// seconds: position/X (fade-in). In the trailing X seconds:
|
||
// (duration-position)/X (fade-out). Tracks shorter than 2X don't fade.
|
||
export function deriveFadeScalar(
|
||
position: number,
|
||
duration: number,
|
||
crossfadeSec: number
|
||
): number {
|
||
if (crossfadeSec <= 0 || duration <= 0) return 1;
|
||
if (duration < crossfadeSec * 2) return 1;
|
||
if (position < crossfadeSec) {
|
||
return Math.max(0, Math.min(1, position / crossfadeSec));
|
||
}
|
||
if (duration - position < crossfadeSec) {
|
||
return Math.max(0, Math.min(1, (duration - position) / crossfadeSec));
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
let _queue = $state<TrackRef[]>([]);
|
||
let _index = $state(0);
|
||
let _state = $state<PlayerState>('idle');
|
||
let _position = $state(0);
|
||
let _duration = $state(0);
|
||
let _volume = $state(readStoredVolume());
|
||
let _crossfadeSec = $state(readStoredCrossfade());
|
||
let _shuffle = $state(false);
|
||
let _repeat = $state<RepeatMode>('off');
|
||
let _error = $state<string | null>(null);
|
||
let _queueDrawerOpen = $state(false);
|
||
|
||
// C1: one-shot restore position — set by restoreQueue, consumed once by
|
||
// the layout's loadedmetadata handler so the audio element seeks to the
|
||
// saved position after metadata loads.
|
||
let _pendingRestorePosition: number | null = null;
|
||
|
||
// M4c: track when the queue was seeded by a radio call so we can
|
||
// auto-refresh at 80% consumption. Cleared when the user enqueues
|
||
// from a non-radio source.
|
||
let _radioSeedId = $state<string | null>(null);
|
||
let _radioRefreshInFlight = false;
|
||
|
||
// #415: which system playlist (if any) this queue was seeded from.
|
||
// 'for_you' | 'discover' | null. Read by the events dispatcher so
|
||
// play_started carries `source` and the play counts against that
|
||
// playlist's rotation. Re-set on every playQueue (a fresh non-system
|
||
// queue clears it).
|
||
let _queueSource = $state<string | null>(null);
|
||
|
||
// Track B (#968): consecutive load failures since the last successful
|
||
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
|
||
// (capped against queue length) stops a fully-unplayable queue from
|
||
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||
let _failureStreak = 0;
|
||
|
||
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||
// left open across the daily rebuild) we call it once to self-heal before
|
||
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||
let _selfHealInFlight = false;
|
||
let _selfHealAttempts = 0;
|
||
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||
|
||
let _audioEl: HTMLAudioElement | null = null;
|
||
|
||
export const player = {
|
||
get queue() { return _queue; },
|
||
get index() { return _index; },
|
||
get current() { return _queue[_index] as TrackRef | undefined; },
|
||
get state() { return _state; },
|
||
get isPlaying() { return _state === 'playing'; },
|
||
get position() { return _position; },
|
||
get duration() { return _duration; },
|
||
get volume() { return _volume; },
|
||
get crossfadeSec() { return _crossfadeSec; },
|
||
get shuffle() { return _shuffle; },
|
||
get repeat() { return _repeat; },
|
||
get error() { return _error; },
|
||
get queueSource() { return _queueSource; },
|
||
get queueDrawerOpen() { return _queueDrawerOpen; }
|
||
};
|
||
|
||
export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||
_audioEl = el;
|
||
}
|
||
|
||
export function playQueue(
|
||
tracks: TrackRef[],
|
||
startIndex = 0,
|
||
opts: {
|
||
shuffle?: boolean;
|
||
source?: string | null;
|
||
refetch?: () => Promise<TrackRef[]>;
|
||
} = {},
|
||
): void {
|
||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||
// #415: a fresh queue resets the system-playlist source. Set only
|
||
// when seeded from a system playlist (server already returned the
|
||
// rotation-aware order, so no client shuffle in that path).
|
||
_queueSource = opts.source ?? null;
|
||
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||
_queueRefetch = opts.refetch ?? null;
|
||
_selfHealAttempts = 0;
|
||
if (opts.shuffle && tracks.length > 1) {
|
||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||
// caller is asking for "random play from this pool," so the first
|
||
// track should also be random, not the one at startIndex.
|
||
const arr = tracks.slice();
|
||
for (let j = arr.length - 1; j > 0; j--) {
|
||
const r = Math.floor(_rng() * (j + 1));
|
||
[arr[j], arr[r]] = [arr[r], arr[j]];
|
||
}
|
||
_queue = arr;
|
||
_index = 0;
|
||
_shuffle = true;
|
||
_state = 'loading';
|
||
} else {
|
||
_queue = tracks;
|
||
if (tracks.length === 0) {
|
||
_index = 0;
|
||
_state = 'idle';
|
||
} else {
|
||
_index = Math.max(0, Math.min(startIndex, tracks.length - 1));
|
||
_state = 'loading';
|
||
}
|
||
}
|
||
_position = 0;
|
||
_duration = 0;
|
||
_error = null;
|
||
_failureStreak = 0;
|
||
}
|
||
|
||
export function togglePlay(): void {
|
||
if (_queue.length === 0) return;
|
||
// "Intent to play" (loading + playing) → paused. Anything else → loading (user
|
||
// wants to play; audio will catch up).
|
||
if (_state === 'playing' || _state === 'loading') {
|
||
_state = 'paused';
|
||
} else {
|
||
_state = 'loading';
|
||
}
|
||
}
|
||
|
||
export function skipNext(): void {
|
||
if (_queue.length === 0) return;
|
||
if (_index + 1 < _queue.length) {
|
||
_index++;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
return;
|
||
}
|
||
// At end of queue.
|
||
if (_repeat === 'all') {
|
||
_index = 0;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
} else {
|
||
// 'off' or 'one' both pause at end on EXPLICIT skip.
|
||
_state = 'paused';
|
||
_position = _duration;
|
||
}
|
||
}
|
||
|
||
export function skipPrev(): void {
|
||
if (_queue.length === 0) return;
|
||
if (_position < 3 && _index > 0) {
|
||
_index--;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
} else {
|
||
// Seek to 0 of the current track.
|
||
_position = 0;
|
||
if (_audioEl) _audioEl.currentTime = 0;
|
||
}
|
||
}
|
||
|
||
export function seekTo(sec: number): void {
|
||
_position = sec;
|
||
if (_audioEl) _audioEl.currentTime = sec;
|
||
}
|
||
|
||
export function setVolume(v: number): void {
|
||
const clamped = Math.max(0, Math.min(1, v));
|
||
_volume = clamped;
|
||
storage.write(VOLUME_KEY, String(clamped));
|
||
}
|
||
|
||
export function setCrossfade(sec: number): void {
|
||
const clamped = Math.max(CROSSFADE_MIN, Math.min(CROSSFADE_MAX, Math.round(sec)));
|
||
_crossfadeSec = clamped;
|
||
storage.write(CROSSFADE_KEY, String(clamped));
|
||
}
|
||
|
||
// Last non-zero volume captured so the M shortcut can toggle to silent
|
||
// then restore. Set lazily on the first toggle and on any setVolume
|
||
// that's non-zero. Default 0.5 keeps unmute usable even if the user
|
||
// somehow lands at volume=0 with no prior non-zero value.
|
||
let _volumeBeforeMute = 0.5;
|
||
|
||
export function toggleMute(): void {
|
||
if (_volume > 0) {
|
||
_volumeBeforeMute = _volume;
|
||
setVolume(0);
|
||
} else {
|
||
setVolume(_volumeBeforeMute > 0 ? _volumeBeforeMute : 0.5);
|
||
}
|
||
}
|
||
|
||
export function reportTimeUpdate(sec: number): void {
|
||
_position = sec;
|
||
}
|
||
|
||
export function reportDuration(sec: number): void {
|
||
_duration = Number.isFinite(sec) ? sec : 0;
|
||
}
|
||
|
||
// --- Shuffle (with injectable RNG for tests) ---
|
||
|
||
let _rng: () => number = Math.random;
|
||
export function __setShuffleRng(fn: () => number): void { _rng = fn; }
|
||
|
||
export function toggleShuffle(): void {
|
||
_shuffle = !_shuffle;
|
||
if (_shuffle && _index + 1 < _queue.length) {
|
||
// Fisher-Yates shuffle over _queue[_index+1 .. end].
|
||
const arr = _queue.slice();
|
||
for (let j = arr.length - 1; j > _index; j--) {
|
||
const r = Math.floor(_rng() * (j - _index)) + _index + 1;
|
||
[arr[j], arr[r]] = [arr[r], arr[j]];
|
||
}
|
||
_queue = arr;
|
||
}
|
||
}
|
||
|
||
// --- Repeat ---
|
||
|
||
export function cycleRepeat(): void {
|
||
_repeat = _repeat === 'off' ? 'all' : _repeat === 'all' ? 'one' : 'off';
|
||
}
|
||
|
||
export function reportStateFromAudio(
|
||
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
|
||
detail?: string
|
||
): void {
|
||
switch (event) {
|
||
case 'playing':
|
||
_state = 'playing';
|
||
_error = null;
|
||
_failureStreak = 0;
|
||
_selfHealAttempts = 0;
|
||
return;
|
||
case 'paused':
|
||
_state = 'paused';
|
||
return;
|
||
case 'waiting':
|
||
_state = 'loading';
|
||
return;
|
||
case 'error':
|
||
handleLoadFailure(detail);
|
||
return;
|
||
case 'ended':
|
||
if (_repeat === 'one') {
|
||
_position = 0;
|
||
_state = 'loading';
|
||
if (_audioEl) _audioEl.currentTime = 0;
|
||
return;
|
||
}
|
||
if (_index + 1 < _queue.length) {
|
||
_index++;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
return;
|
||
}
|
||
if (_repeat === 'all') {
|
||
_index = 0;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
} else {
|
||
_state = 'paused';
|
||
_position = _duration;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Track B (#968): a single failed track must not strand the player on the
|
||
// "Try again" dead-end. Advance past it; only surface the error once the
|
||
// whole queue has proven unplayable — every track failed, or we reached the
|
||
// end with nothing playable. The streak cap (vs queue length) stops a
|
||
// fully-broken queue from cycling forever instead of settling.
|
||
function handleLoadFailure(detail?: string): void {
|
||
_failureStreak++;
|
||
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
|
||
_index++;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
_error = null;
|
||
return;
|
||
}
|
||
// Whole queue is unplayable. If it came from a refreshable source, the
|
||
// snapshot is probably stale — re-pull it and resume before giving up.
|
||
if (trySelfHeal()) return;
|
||
_state = 'error';
|
||
_error = detail ?? 'Playback failed.';
|
||
}
|
||
|
||
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||
function trySelfHeal(): boolean {
|
||
if (_selfHealInFlight) return true;
|
||
if (_queueRefetch === null) return false;
|
||
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||
_selfHealAttempts++;
|
||
_selfHealInFlight = true;
|
||
_state = 'loading';
|
||
_error = null;
|
||
const refetch = _queueRefetch;
|
||
void refetch()
|
||
.then((refs) => {
|
||
if (refs.length > 0) {
|
||
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||
// (only a successful 'playing' clears the budget).
|
||
_queue = refs;
|
||
_index = 0;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_failureStreak = 0;
|
||
_state = 'loading';
|
||
_error = null;
|
||
} else {
|
||
_state = 'error';
|
||
_error = 'Nothing playable in this mix right now.';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
_state = 'error';
|
||
_error = 'Couldn’t refresh this mix. Try again.';
|
||
})
|
||
.finally(() => {
|
||
_selfHealInFlight = false;
|
||
});
|
||
return true;
|
||
}
|
||
|
||
export function enqueueTrack(t: TrackRef): void {
|
||
_radioSeedId = null; // M4c
|
||
_queue = [..._queue, t];
|
||
if (_state === 'idle') _index = 0;
|
||
}
|
||
|
||
/**
|
||
* M7 #372: insert a track immediately after the currently-playing one
|
||
* so it plays next. If the queue is empty, behaves like enqueueTrack
|
||
* and starts the queue with this track at index 0.
|
||
*/
|
||
export function playNext(t: TrackRef): void {
|
||
_radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state
|
||
if (_queue.length === 0) {
|
||
_queue = [t];
|
||
_index = 0;
|
||
return;
|
||
}
|
||
const next = _index + 1;
|
||
_queue = [..._queue.slice(0, next), t, ..._queue.slice(next)];
|
||
}
|
||
|
||
export function enqueueTracks(ts: TrackRef[]): void {
|
||
_radioSeedId = null; // M4c
|
||
if (ts.length === 0) return;
|
||
_queue = [..._queue, ...ts];
|
||
if (_state === 'idle') _index = 0;
|
||
}
|
||
|
||
// Bulk variant of playNext: inserts every track immediately after the
|
||
// currently-playing one, preserving order. Empty queue seeds the queue
|
||
// with the batch at index 0 (mirrors playNext's single-track shape).
|
||
export function playNextMany(ts: TrackRef[]): void {
|
||
_radioSeedId = null;
|
||
if (ts.length === 0) return;
|
||
if (_queue.length === 0) {
|
||
_queue = [...ts];
|
||
_index = 0;
|
||
return;
|
||
}
|
||
const next = _index + 1;
|
||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||
}
|
||
|
||
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||
const resp = await api.get<RadioResponse>(
|
||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||
);
|
||
return resp.tracks;
|
||
}
|
||
|
||
export async function playRadio(seedTrackId: string): Promise<void> {
|
||
const tracks = await fetchRadioTracks(seedTrackId);
|
||
if (tracks.length === 0) return;
|
||
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||
// re-seeds from the same track instead of dead-ending.
|
||
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||
}
|
||
|
||
export function moveQueueItem(from: number, to: number): void {
|
||
if (from === to) return;
|
||
if (from < 0 || from >= _queue.length) return;
|
||
if (to < 0 || to >= _queue.length) return;
|
||
const playingId = _queue[_index]?.id;
|
||
const next = _queue.slice();
|
||
const [moved] = next.splice(from, 1);
|
||
next.splice(to, 0, moved);
|
||
_queue = next;
|
||
if (playingId) {
|
||
const found = next.findIndex((x) => x.id === playingId);
|
||
if (found >= 0) _index = found;
|
||
}
|
||
}
|
||
|
||
export function removeFromQueue(idx: number): void {
|
||
if (idx < 0 || idx >= _queue.length) return;
|
||
const next = _queue.slice();
|
||
next.splice(idx, 1);
|
||
if (idx < _index) {
|
||
_queue = next;
|
||
_index = _index - 1;
|
||
return;
|
||
}
|
||
if (idx > _index) {
|
||
_queue = next;
|
||
return;
|
||
}
|
||
// idx === _index — removing the currently-playing track.
|
||
_queue = next;
|
||
if (next.length === 0) {
|
||
_index = 0;
|
||
_state = 'idle';
|
||
_position = 0;
|
||
_duration = 0;
|
||
_error = null;
|
||
return;
|
||
}
|
||
if (_index >= next.length) {
|
||
// Removed the last track. Mirror skipNext end-of-queue behaviour:
|
||
// pause on the now-last remaining track at position 0 — don't
|
||
// auto-play a track the user already heard.
|
||
_index = next.length - 1;
|
||
_state = 'paused';
|
||
_position = 0;
|
||
_duration = 0;
|
||
_error = null;
|
||
return;
|
||
}
|
||
// _index stays — now points at what was the next track. Advance into it.
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
_error = null;
|
||
}
|
||
|
||
export function playFromQueueIndex(idx: number): void {
|
||
if (idx < 0 || idx >= _queue.length) return;
|
||
_radioSeedId = null;
|
||
_index = idx;
|
||
_position = 0;
|
||
_duration = 0;
|
||
_state = 'loading';
|
||
_error = null;
|
||
}
|
||
|
||
export function toggleQueueDrawer(): void {
|
||
_queueDrawerOpen = !_queueDrawerOpen;
|
||
}
|
||
|
||
export function closeQueueDrawer(): void {
|
||
_queueDrawerOpen = false;
|
||
}
|
||
|
||
export function restoreQueue(userId: string): void {
|
||
if (!userId) return;
|
||
const persisted = readPersistedQueue(userId);
|
||
if (!persisted) return;
|
||
if (persisted.queue.length === 0) return;
|
||
_queue = persisted.queue;
|
||
_index = Math.max(0, Math.min(persisted.index, persisted.queue.length - 1));
|
||
_position = persisted.position;
|
||
_pendingRestorePosition = persisted.position;
|
||
_duration = 0;
|
||
_state = 'paused';
|
||
_error = null;
|
||
_radioSeedId = null;
|
||
}
|
||
|
||
export function consumePendingRestorePosition(): number | null {
|
||
const p = _pendingRestorePosition;
|
||
_pendingRestorePosition = null;
|
||
return p;
|
||
}
|
||
|
||
// M4c: auto-refresh radio queue when 80% consumed.
|
||
// Fires whenever the consumption ratio crosses 80% AND the queue was
|
||
// seeded by playRadio() AND no refresh is currently in-flight.
|
||
$effect.root(() => {
|
||
$effect(() => {
|
||
if (_radioSeedId === null) return;
|
||
if (_radioRefreshInFlight) return;
|
||
if (_queue.length === 0) return;
|
||
const consumedRatio = (_index + 1) / _queue.length;
|
||
if (consumedRatio < 0.8) return;
|
||
|
||
_radioRefreshInFlight = true;
|
||
const seed = _radioSeedId;
|
||
// Drift #554: cap the exclude list so a multi-hour radio session
|
||
// doesn't grow the query string past common 8KB limits and start
|
||
// 414-ing /api/radio. The .catch() below would silently swallow
|
||
// that failure and the player would stop topping up — a dead
|
||
// radio. The server's RecentlyPlayedHours filter already handles
|
||
// broader history dedup, so the request-side exclude only needs
|
||
// to cover the visible queue's recent tail.
|
||
const RADIO_EXCLUDE_CAP = 100;
|
||
const exclude = _queue
|
||
.slice(-RADIO_EXCLUDE_CAP)
|
||
.map((t) => t.id)
|
||
.join(',');
|
||
api
|
||
.get<RadioResponse>(
|
||
`/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}`
|
||
)
|
||
.then((resp) => {
|
||
// Strip the seed at index 0 (already in our queue) and any other
|
||
// tracks that happen to match the original seed id.
|
||
const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed);
|
||
if (newTracks.length > 0) {
|
||
// Append directly without calling enqueueTracks — that would
|
||
// clear _radioSeedId and prevent further refreshes.
|
||
_queue = [..._queue, ...newTracks];
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Swallow; next track-advance can retry.
|
||
})
|
||
.finally(() => {
|
||
_radioRefreshInFlight = false;
|
||
});
|
||
});
|
||
});
|
||
|
||
// Session-end teardown (#375): auth signals via sessionEnd; player
|
||
// owns the player-specific cleanup (clear persisted queue for the
|
||
// outgoing user, reset in-memory queue, close drawer) without auth
|
||
// having to import any player APIs.
|
||
$effect.root(() => {
|
||
let lastTick = sessionEnd.tick;
|
||
$effect(() => {
|
||
if (sessionEnd.tick === lastTick) return;
|
||
lastTick = sessionEnd.tick;
|
||
const prevId = sessionEnd.userId;
|
||
if (prevId) clearPersistedQueue(prevId);
|
||
playQueue([]);
|
||
closeQueueDrawer();
|
||
});
|
||
});
|
||
|
||
// Persistence (M7 #364): split-effect shape. Queue/index changes write
|
||
// immediately (rare, user-driven); position changes throttle to once
|
||
// per 5s during steady playback. See spec section "Persistence write
|
||
// effect" for the alternative debounce shape.
|
||
$effect.root(() => {
|
||
let lastPositionWriteAt = 0;
|
||
|
||
// Immediate write on queue or index changes. Position is read via
|
||
// untrack so this effect doesn't fire on every position tick — only
|
||
// queue/index changes drive it. The throttled-write effect below
|
||
// covers position drift.
|
||
$effect(() => {
|
||
const userId = user.value?.id;
|
||
if (!userId) return;
|
||
// Reading these registers the reactive dependency; the values
|
||
// themselves are unused — writePersistedQueue re-reads live state.
|
||
_queue;
|
||
_index;
|
||
writePersistedQueue(userId, {
|
||
queue: _queue,
|
||
index: _index,
|
||
position: untrack(() => _position)
|
||
});
|
||
lastPositionWriteAt = Date.now();
|
||
});
|
||
|
||
// Throttled write on position changes.
|
||
$effect(() => {
|
||
const userId = user.value?.id;
|
||
if (!userId) return;
|
||
const _p = _position;
|
||
void _p;
|
||
const now = Date.now();
|
||
if (now - lastPositionWriteAt < 5000) return;
|
||
writePersistedQueue(userId, {
|
||
queue: untrack(() => _queue),
|
||
index: untrack(() => _index),
|
||
position: _position
|
||
});
|
||
lastPositionWriteAt = now;
|
||
});
|
||
});
|