diff --git a/src/lib/streamCache.svelte.ts b/src/lib/streamCache.svelte.ts index 63678b2..e2efd07 100644 --- a/src/lib/streamCache.svelte.ts +++ b/src/lib/streamCache.svelte.ts @@ -11,7 +11,7 @@ const PARTS_DIR = 'parts'; // backoff below tolerates roughly 8 minutes of outage before giving up const MAX_ATTEMPTS = 8; -const CHUNK_SIZE = 8 * 1024 * 1024; +const CHUNK_SIZE = 4 * 1024 * 1024; // 60s as the limit for downloading one chunk is a reasonable guess // as the intent is to catch socket breakage during cellular network changes for example diff --git a/src/lib/streamContext.svelte.ts b/src/lib/streamContext.svelte.ts index dda70b2..5e26c31 100644 --- a/src/lib/streamContext.svelte.ts +++ b/src/lib/streamContext.svelte.ts @@ -23,14 +23,15 @@ export class StreamContext { } getSongAtTime(time: number): number { - return this.#locationOf(time, this.#timestamps) - 1; + return Math.max(this.#locationOf(time, this.#timestamps) - 1, 0); } updateCurrentSong(currentTime: number, songIndex: number) { const ts = this.#timestamps; + // the first track need not start at 0, so descending past index 0 is possible const recurse = (idx: number): number => { if (currentTime >= ts[idx + 2]) return recurse(idx + 1); - if (currentTime < ts[idx + 1]) return recurse(idx - 1); + if (idx > 0 && currentTime < ts[idx + 1]) return recurse(idx - 1); return idx; }; this.songIndex = recurse(songIndex); diff --git a/src/routes/streams/[stream_id]/+page.svelte b/src/routes/streams/[stream_id]/+page.svelte index 162f520..6ff75ba 100644 --- a/src/routes/streams/[stream_id]/+page.svelte +++ b/src/routes/streams/[stream_id]/+page.svelte @@ -2,29 +2,47 @@ import StreamPage from './StreamPage.svelte'; import MetadataEditor from './MetadataEditor.svelte'; import Player from './Player.svelte'; + import { untrack } from 'svelte'; import { dev } from '$app/environment'; import { getStreamContext } from '$lib/streamContext.svelte.ts'; import * as streamCache from '$lib/streamCache.svelte.ts'; let { data } = $props(); const ctx = getStreamContext(); - let playerSrc = $state(null); - // reactivity runs on `stream` and `streamCache.cached` here - $effect(() => { - const stream = data.stream; - ctx.setCurrent(stream); - playerSrc = null; - let objectUrl: string | null = null; - if (streamCache.cached.has(stream.id)) { - streamCache.getUrl(stream.id).then((url) => { - objectUrl = url; - playerSrc = url ?? `/media/tracks/${stream.filename}`; - }); - } else { - playerSrc = `/media/tracks/${stream.filename}`; + let source = $state<{ id: string; url: string } | null>(null); + + const streamId = $derived(data.stream.id); + const isCached = $derived(streamCache.cached.has(streamId)); + + // update stream context only when we actually swap streams + // this avoids reloading the audio element when we click on the same stream + $effect.pre(() => { + streamId; + ctx.setCurrent(untrack(() => data.stream)); + }); + + $effect.pre(() => { + const id = streamId; + const cached = isCached; + const networkUrl = untrack(() => `/media/tracks/${data.stream.filename}`); + + // not in cache (or just deleted from cache)? use network url + if (!cached) { + source = { id, url: networkUrl }; + return; } + + // otherwise use the cached blob + // if the cache finished downloading while the stream is playing, + // Player.svelte will notice and swap it in at the right time uninterrupted + let objectUrl: string | null = null; + streamCache.getUrl(id).then((url) => { + objectUrl = url; + source = { id, url: url ?? networkUrl }; + }); return () => { + // cleanup blob handle when switching away from cached stream if (objectUrl) URL.revokeObjectURL(objectUrl); }; }); @@ -38,9 +56,10 @@
- {#key playerSrc} - {#if playerSrc} - + {#key streamId} + + {#if source?.id === streamId} + {/if} {/key}
diff --git a/src/routes/streams/[stream_id]/Player.svelte b/src/routes/streams/[stream_id]/Player.svelte index 09166de..9f9a80f 100644 --- a/src/routes/streams/[stream_id]/Player.svelte +++ b/src/routes/streams/[stream_id]/Player.svelte @@ -4,11 +4,13 @@ --> @@ -23,7 +25,6 @@ interface Props { src: any; - audio?: any; paused?: boolean; duration?: number; muted?: boolean; @@ -42,7 +43,6 @@ let { src, - audio = $bindable(null), paused = $bindable(true), duration = $bindable(0), muted = $bindable(false), @@ -59,9 +59,27 @@ onended }: Props = $props(); - getAudio = () => { - return audio; - }; + let audio = $state()!; + + // if src changes on the same stream id (in practice when a stream cache finishes downloading), + // save current playhead, then call load() to use the new src + // playhead is restored in the onLoadedMetadata below in this file + let pending: { time: number; playing: boolean } | null = null; + $effect(() => { + src; + if (!audio) return; + pending = + audio.currentTime > 0 ? { time: audio.currentTime, playing: !audio.paused } : null; + audio.load(); + }); + + // module scope, so it has to be released or a destroyed instance keeps answering + $effect(() => { + getAudio = () => audio; + return () => { + getAudio = null; + }; + }); let currentTime = $state(0); let tooltip = $state(); @@ -92,9 +110,14 @@ setVolume(volume); // set actions for previous/next track media buttons + // mediaSession is global, so handlers of a destroyed instance would keep firing if ('mediaSession' in navigator) { navigator.mediaSession.setActionHandler('previoustrack', previousTrack); navigator.mediaSession.setActionHandler('nexttrack', nextTrack); + return () => { + navigator.mediaSession.setActionHandler('previoustrack', null); + navigator.mediaSession.setActionHandler('nexttrack', null); + }; } }); @@ -154,7 +177,7 @@ } } - // workaround for bug https://github.com/sveltejs/svelte/issues/5347 + // partly workaround for bug https://github.com/sveltejs/svelte/issues/5347 // need to init duration & volume after SSR first load // also workaround for bug https://github.com/sveltejs/svelte/issues/5914 $effect(() => { @@ -162,6 +185,12 @@ const onLoadedMetadata = () => { duration = audio.duration; + if (pending) { + // same stream but audio url changed, reload audio at current playhead + audio.currentTime = currentTime = pending.time; + if (pending.playing) audio.play().catch(() => {}); + pending = null; + } paused = audio.paused; setVolume(volume); };