From 3aa762d5443d503f8b6c649d2471bb19836cb3eb Mon Sep 17 00:00:00 2001 From: apt-get Date: Mon, 16 Mar 2026 20:32:33 +0100 Subject: [PATCH] add stream caching ("offline mode") --- scripts/generate_iconfont_subset.sh | 1 + src/lib/database.ts | 3 +- src/lib/streamCache.svelte.ts | 66 ++++++++++++++++++ ...text.svelte.ts => streamContext.svelte.ts} | 0 src/lib/types.ts | 2 +- src/routes/streams/+layout.svelte | 2 +- src/routes/streams/Sidebar.svelte | 64 +++++++++++++++-- src/routes/streams/WelcomePage.svelte | 2 +- src/routes/streams/[stream_id]/+page.svelte | 26 +++++-- src/routes/streams/[stream_id]/Player.svelte | 2 +- .../streams/[stream_id]/StreamPage.svelte | 2 +- .../fonts/MaterialIcons-Regular-subset.woff2 | Bin 1080 -> 1120 bytes 12 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 src/lib/streamCache.svelte.ts rename src/lib/{stream-context.svelte.ts => streamContext.svelte.ts} (100%) diff --git a/scripts/generate_iconfont_subset.sh b/scripts/generate_iconfont_subset.sh index fa647ec..7f9f1e2 100755 --- a/scripts/generate_iconfont_subset.sh +++ b/scripts/generate_iconfont_subset.sh @@ -8,6 +8,7 @@ fontFile="$(basename "$fontUrl")" unicodePoints=( "61-7a,5f" # ascii lowercase + underscore "e2c4" # file_download + "e872" # delete "e037" # play_arrow "e034" # pause "e04f" # volume_off diff --git a/src/lib/database.ts b/src/lib/database.ts index 19abcb8..8fdc1c1 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -9,12 +9,13 @@ const db = new Database(dbName); export function getStreams(): StreamSummary[] { const indexData = db .prepare( - 'SELECT id, stream_date, title, tags, length_seconds ' + 'FROM Stream ORDER BY id DESC' + 'SELECT id, stream_date, filename, title, tags, length_seconds ' + 'FROM Stream ORDER BY id DESC' ) .all() as Record[]; return indexData.map((stream) => ({ id: stream.id as string, stream_date: Date.parse(stream.stream_date as string), + filename: stream.filename as string, title: stream.title as string | null, tags: JSON.parse(stream.tags as string) as string[], length_seconds: stream.length_seconds as number diff --git a/src/lib/streamCache.svelte.ts b/src/lib/streamCache.svelte.ts new file mode 100644 index 0000000..bd7ddb5 --- /dev/null +++ b/src/lib/streamCache.svelte.ts @@ -0,0 +1,66 @@ +import { browser } from '$app/environment'; +import { SvelteSet, SvelteMap } from 'svelte/reactivity'; +import type { StreamSummary } from './types.ts'; + +const STORAGE_KEY = 'cachedStreams'; + +export const cached = new SvelteSet( + browser ? JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') : [] +); +export const downloading = new SvelteMap(); + +if (browser) { + $effect.root(() => { + $effect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...cached])); + }); + }); +} + +export async function download(stream: StreamSummary) { + const res = await fetch(`/media/tracks/${stream.filename}`); + if (!res.ok || !res.body) throw new Error('Download failed'); + + const total = Number(res.headers.get('content-length') || 0); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + downloading.set(stream.id, 0); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (total) downloading.set(stream.id, Math.round((received / total) * 100)); + } + + const root = await navigator.storage.getDirectory(); + const handle = await root.getFileHandle(stream.id, { create: true }); + const writable = await handle.createWritable(); + await writable.write(new Blob(chunks)); + await writable.close(); + + downloading.delete(stream.id); + cached.add(stream.id); +} + +export async function remove(streamId: string) { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(streamId); + cached.delete(streamId); +} + +export async function getUrl(streamId: string): Promise { + if (!cached.has(streamId)) return null; + try { + const root = await navigator.storage.getDirectory(); + const handle = await root.getFileHandle(streamId); + const file = await handle.getFile(); + return URL.createObjectURL(file); + } catch { + cached.delete(streamId); + return null; + } +} diff --git a/src/lib/stream-context.svelte.ts b/src/lib/streamContext.svelte.ts similarity index 100% rename from src/lib/stream-context.svelte.ts rename to src/lib/streamContext.svelte.ts diff --git a/src/lib/types.ts b/src/lib/types.ts index 8023eb6..90d72ae 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,6 +5,7 @@ export type Track = [number, string, string]; export interface StreamSummary { id: string; stream_date: number; + filename: string; title: string | null; tags: string[]; length_seconds: number; @@ -12,7 +13,6 @@ export interface StreamSummary { /** The full stream shape used on the detail/player page. */ export interface Stream extends StreamSummary { - filename: string; format: string; description: string | null; tracks: Track[]; diff --git a/src/routes/streams/+layout.svelte b/src/routes/streams/+layout.svelte index 931ef21..3b40de4 100644 --- a/src/routes/streams/+layout.svelte +++ b/src/routes/streams/+layout.svelte @@ -1,7 +1,7 @@ @@ -23,8 +33,10 @@
- {#key ctx.current} - + {#key playerSrc} + {#if playerSrc} + + {/if} {/key}
diff --git a/src/routes/streams/[stream_id]/Player.svelte b/src/routes/streams/[stream_id]/Player.svelte index f801fb5..496d33b 100644 --- a/src/routes/streams/[stream_id]/Player.svelte +++ b/src/routes/streams/[stream_id]/Player.svelte @@ -19,7 +19,7 @@ const bubble = createBubbler(); import { onMount, onDestroy } from 'svelte'; import { fade } from 'svelte/transition'; - import { getStreamContext } from '$lib/stream-context.svelte.ts'; + import { getStreamContext } from '$lib/streamContext.svelte.ts'; import type { Track } from '$lib/types'; const ctx = getStreamContext(); diff --git a/src/routes/streams/[stream_id]/StreamPage.svelte b/src/routes/streams/[stream_id]/StreamPage.svelte index 251742a..dd7d826 100644 --- a/src/routes/streams/[stream_id]/StreamPage.svelte +++ b/src/routes/streams/[stream_id]/StreamPage.svelte @@ -1,5 +1,5 @@