Files
minstrel/web/src/routes/+layout.svelte
T
bvandeusen 5a80a1e460
test-web / test (push) Successful in 39s
feat(web): subscribe to SSE and refresh home/playlists on playlist.system_rebuilt
The web client only ever SENT events; it had no inbound SSE listener, so a
tab left open across the daily system-playlist rebuild kept showing
yesterday's home + playlist snapshots until a manual reload (the stale-
browse-view bug behind #968). Add useServerEvents(): opens /api/events/stream
while authenticated and, on playlist.system_rebuilt, invalidates the home,
playlists, and system-playlist-status query caches. Deliberately does not
disturb the active playback queue — that self-heals on the failure path.
Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:44:05 -04:00

185 lines
6.3 KiB
Svelte

<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { queryClient } from '$lib/query/client';
import { user } from '$lib/auth/store.svelte';
import { isPublicRoute } from '$lib/auth/publicRoutes';
import Shell from '$lib/components/Shell.svelte';
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
import SelectionBar from '$lib/components/SelectionBar.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import {
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
player,
closeQueueDrawer,
consumePendingRestorePosition,
deriveFadeScalar
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
import { useEventsDispatcher } from '$lib/player/events.svelte';
import { useServerEvents } from '$lib/serverEvents.svelte';
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader';
import { attachStallRetry } from '$lib/player/stallRetry';
let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state();
let prefetchEl: HTMLAudioElement | undefined = $state();
// Reactive guard: runs every time page.url or user.value changes.
$effect(() => {
const path = page.url.pathname;
const isPublic = isPublicRoute(path);
if (user.value === null && !isPublic) {
const returnTo = path + page.url.search;
goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true });
} else if (user.value !== null && isPublic) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
// Auto-recovery for transient network failures during playback.
$effect(() => {
if (!audioEl) return;
return attachStallRetry(audioEl);
});
$effect(() => {
if (!audioEl) return;
const cur = player.current;
const src = cur ? audioLoader.resolve(cur) : null;
if (src) {
const absolute = new URL(src, window.location.origin).href;
if (audioEl.src !== absolute) audioEl.src = src;
} else {
audioEl.removeAttribute('src');
audioEl.load();
}
});
// Phase 2 prefetch: when the current track passes the halfway mark,
// start loading the next track's bytes into a hidden <audio> element.
// The browser HTTP cache + audio buffer warm-up means the swap on
// track-end is gap-free on slow networks. Reset src when no next
// track exists or the index advances.
$effect(() => {
if (!prefetchEl) return;
const next = player.queue[player.index + 1];
const ratio = player.duration > 0 ? player.position / player.duration : 0;
if (next && ratio >= 0.5) {
const nextSrc = audioLoader.resolve(next);
if (prefetchEl.src !== new URL(nextSrc, window.location.origin).href) {
prefetchEl.src = nextSrc;
audioLoader.prefetch?.(next);
}
} else if (!next && prefetchEl.src) {
prefetchEl.removeAttribute('src');
prefetchEl.load();
}
});
$effect(() => {
if (!audioEl) return;
// Per-position fade scalar: 1 normally, ramping in/out of the
// crossfade window at track boundaries. Pure function of position
// + duration + the operator's chosen crossfade duration.
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
audioEl.volume = player.volume * scalar;
});
$effect(() => {
if (!audioEl) return;
// "Intent to play" = loading or playing. During 'loading' the DOM hasn't
// emitted 'playing' yet, but we still need to call play() to get there.
const intent = player.state === 'playing' || player.state === 'loading';
if (intent) {
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else {
audioEl.pause();
}
});
$effect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Selection bar takes priority over queue drawer: pressing Esc
// with both open clears the (less-modal) selection first.
if (selection.active) clearSelection();
else if (player.queueDrawerOpen) closeQueueDrawer();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
// Route-change reset: selection state is per-page; navigating away
// (or to a different track-list surface) clears whatever was picked.
$effect(() => {
page.url.pathname; // subscribe
clearSelection();
});
useMediaSession();
useEventsDispatcher();
useServerEvents();
useGlobalShortcuts();
applyMetaThemeColor();
</script>
<audio
bind:this={audioEl}
preload="auto"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => {
if (!audioEl) return;
reportDuration(audioEl.duration);
const restorePos = consumePendingRestorePosition();
if (restorePos !== null) {
audioEl.currentTime = restorePos;
}
}}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
<!-- Hidden prefetch element. Loads the next track's bytes ahead of
time so the swap on track-end is gap-free. Muted + display:none
so it can't be played or interacted with directly. -->
<audio
bind:this={prefetchEl}
preload="auto"
muted
aria-hidden="true"
style="display: none"
></audio>
<QueueDrawer />
<QueryClientProvider client={queryClient}>
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
<Shell>{@render children()}</Shell>
{:else}
{@render children()}
{/if}
<!-- SelectionBar must be inside the QueryClientProvider so its
Like-all action can use useQueryClient. -->
<SelectionBar />
</QueryClientProvider>
<ToastHost />