c466e6c317
test-web / test (push) Successful in 31s
test-go / test (pull_request) Successful in 34s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (pull_request) Successful in 5m4s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 11m1s
Operator-tunable 0-12s crossfade in Settings → Playback. Default 0 (off). Most albums sound best at 0 — gapless masters, classical, and live recordings all suffer noticeable crossfades. Implementation: pure-function position-derived volume scalar. `deriveFadeScalar(position, duration, crossfadeSec)` ramps from 0 to 1 over the leading X seconds, 1 to 0 over the trailing X seconds, and stays at 1 in between. Tracks shorter than 2X don't fade. The layout's audio-volume effect now multiplies player.volume by the fade scalar — no timers, no AudioContext, no element swapping. Re-renders at the ~4Hz `timeupdate` cadence give 16 discrete steps over a 4s fade, audibly close to smooth. Smoothing to per-frame ramps via rAF is a follow-up if needed. Setting persists to localStorage as `minstrel.crossfade` (matches the existing volume-storage convention). Tests cover the pure derivation (off, too-short track, fade-in, fade-out) + clamp/persist on setCrossfade + invalid-input recovery on readStoredCrossfade.
183 lines
6.2 KiB
Svelte
183 lines
6.2 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 { 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();
|
|
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 />
|