track switching fixed! (sorta!)

This commit is contained in:
2026-02-06 06:44:57 -08:00
parent 42d8e8fa8d
commit 3401967d7c

View File

@@ -60,12 +60,6 @@
let audioEl: HTMLAudioElement | null = null;
// Best-effort preload of the upcoming track's URL
let nextPreloadHref = $state<string | null>(null);
// Dedicated preloader element to warm the connection / decode pipeline a bit
let preloadEl = $state<HTMLAudioElement | null>(null);
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
@@ -110,6 +104,66 @@
prevIsMobile = nextIsMobile;
}
async function waitForEvent(el: HTMLMediaElement, eventName: string) {
await new Promise<void>((resolve) => {
const onEvent = () => {
el.removeEventListener(eventName, onEvent);
resolve();
};
el.addEventListener(eventName, onEvent, { once: true });
});
}
async function syncAndAutoplay() {
const el = audioEl;
if (!el) return;
// Centralized logic to sync and play the current track.
const track = snap.currentTrack;
if (!track) {
if (el.src) {
el.removeAttribute("src");
el.load();
}
return;
}
const desiredSrc = track.src;
const desiredAbs = new URL(desiredSrc, window.location.href).href;
const srcChanged = el.src !== desiredAbs;
if (srcChanged) {
el.src = desiredSrc;
el.load();
}
el.volume = snap.volume;
// If src changed, we MUST wait until the browser says it's ready.
if (srcChanged || el.readyState < 3) {
try {
await Promise.race([
waitForEvent(el, "canplay"),
// Add a timeout to prevent getting stuck forever
new Promise((_, reject) =>
setTimeout(() => reject(new Error("canplay timeout")), 8000),
),
]);
} catch (e) {
console.error("Audio load failed or timed out", e);
return; // Don't try to play if loading failed
}
}
// Now, try to play.
try {
await el.play();
} catch (e) {
console.warn("Autoplay was prevented.", e);
// isPlaying state will be updated by onAudioPause handler.
}
}
// Media Session bindings
const media = createMediaSessionBindings({
play: () => void audioEl?.play(),
@@ -135,83 +189,6 @@
// Scrubber is now driven by `bind:currentTime` on the <audio> element and
// `bind:value={currentTime}` on the sliders, so we don't need explicit handlers.
let isSrcChanging = $state(false);
function computeNextTrackToPreload(): Track | null {
// Prefer to preload the "linear next" item. For shuffle mode, we can still
// best-effort preload the first item in the upcoming order.
if (snap.queue.length === 0) return null;
if (snap.shuffleEnabled) {
const nextIdx = snap.order[0];
if (typeof nextIdx === "number") return snap.queue[nextIdx] ?? null;
return null;
}
if (snap.currentIndex == null) return snap.queue[0] ?? null;
const nextIdx = snap.currentIndex + 1;
return nextIdx >= 0 && nextIdx < snap.queue.length
? snap.queue[nextIdx]
: null;
}
function updatePreloadTargets() {
const nextTrack = computeNextTrackToPreload();
nextPreloadHref = nextTrack?.src ?? null;
// Also warm via an offscreen <audio> element. This may or may not help depending
// on browser caching policies, but it's safe and often reduces first-buffer delay.
if (!preloadEl) return;
if (nextTrack?.src) {
preloadEl.src = nextTrack.src;
preloadEl.load();
} else {
preloadEl.removeAttribute("src");
preloadEl.load();
}
}
async function waitForEvent(el: HTMLMediaElement, eventName: string) {
await new Promise<void>((resolve) => {
const onEvent = () => {
el.removeEventListener(eventName, onEvent);
resolve();
};
el.addEventListener(eventName, onEvent, { once: true });
});
}
async function syncAndAutoplay() {
const el = audioEl;
if (!el) return;
// If src is changing (set by $effect) or readyState is too low, wait until ready.
// This is the key fix: it respects that the $effect is asynchronously loading media.
if (isSrcChanging || el.readyState < 3) {
await waitForEvent(el, "canplay");
}
try {
await el.play();
} catch {
// Autoplay may be blocked; bail out quietly.
return;
}
if (el.paused) {
if (el.readyState < 3) {
await waitForEvent(el, "canplay");
}
await Promise.race([waitForEvent(el, "playing"), Promise.resolve()]);
try {
await el.play();
} catch {
// ignore
}
}
}
function onAudioPlay() {
isPlaying = true;
media.setPlaybackState("playing");
@@ -232,7 +209,6 @@
});
}
function onAudioLoadedMetadata() {
isSrcChanging = false;
// `duration` is synced via `bind:duration` on <audio>.
if (!audioEl) return;
@@ -520,32 +496,16 @@
media.setTrack(snap.currentTrack);
schedulePersistNow();
// Keep preload targets updated as the queue/traversal state changes
updatePreloadTargets();
if (!audioEl) return;
// When track changes for any reason, ensure volume is correct.
if (audioEl) {
audioEl.volume = snap.volume;
}
const track = snap.currentTrack;
if (!track) {
if (audioEl.src) {
// If there's no track, ensure audio element is reset
if (!snap.currentTrack && audioEl && audioEl.src) {
audioEl.removeAttribute("src");
audioEl.load();
}
return;
}
const desired = track.src;
const desiredAbs = new URL(desired, window.location.href).href;
// Use .src for a synchronous check. The .src property returns the resolved absolute URL.
if (audioEl.src !== desiredAbs) {
isSrcChanging = true;
audioEl.src = desired;
audioEl.load();
audioEl.currentTime = 0;
}
});
onMount(() => {
@@ -554,13 +514,6 @@
window.addEventListener("resize", updateIsMobile);
}
// Create the offscreen preloader audio element in the browser
if (browser) {
preloadEl = new Audio();
preloadEl.preload = "auto";
preloadEl.muted = true;
}
// Subscribe to player changes instead of polling
unsubscribe = subscribe((s) => {
snap = s;
@@ -596,10 +549,6 @@
</script>
<svelte:head>
<!-- Preload the next track (best-effort). We set this href reactively below. -->
{#if nextPreloadHref}
<link rel="preload" as="audio" href={nextPreloadHref} />
{/if}
</svelte:head>
<!-- Mobile UI is portaled to document.body so the GlobalPlayer can be mounted