Compare commits

...

1 Commits

Author SHA1 Message Date
3aa762d544 add stream caching ("offline mode") 2026-03-16 20:32:33 +01:00
12 changed files with 153 additions and 17 deletions

View File

@@ -8,6 +8,7 @@ fontFile="$(basename "$fontUrl")"
unicodePoints=( unicodePoints=(
"61-7a,5f" # ascii lowercase + underscore "61-7a,5f" # ascii lowercase + underscore
"e2c4" # file_download "e2c4" # file_download
"e872" # delete
"e037" # play_arrow "e037" # play_arrow
"e034" # pause "e034" # pause
"e04f" # volume_off "e04f" # volume_off

View File

@@ -9,12 +9,13 @@ const db = new Database(dbName);
export function getStreams(): StreamSummary[] { export function getStreams(): StreamSummary[] {
const indexData = db const indexData = db
.prepare( .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<string, unknown>[]; .all() as Record<string, unknown>[];
return indexData.map((stream) => ({ return indexData.map((stream) => ({
id: stream.id as string, id: stream.id as string,
stream_date: Date.parse(stream.stream_date as string), stream_date: Date.parse(stream.stream_date as string),
filename: stream.filename as string,
title: stream.title as string | null, title: stream.title as string | null,
tags: JSON.parse(stream.tags as string) as string[], tags: JSON.parse(stream.tags as string) as string[],
length_seconds: stream.length_seconds as number length_seconds: stream.length_seconds as number

View File

@@ -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<string>(
browser ? JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') : []
);
export const downloading = new SvelteMap<string, number>();
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<string | null> {
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;
}
}

View File

@@ -5,6 +5,7 @@ export type Track = [number, string, string];
export interface StreamSummary { export interface StreamSummary {
id: string; id: string;
stream_date: number; stream_date: number;
filename: string;
title: string | null; title: string | null;
tags: string[]; tags: string[];
length_seconds: number; length_seconds: number;
@@ -12,7 +13,6 @@ export interface StreamSummary {
/** The full stream shape used on the detail/player page. */ /** The full stream shape used on the detail/player page. */
export interface Stream extends StreamSummary { export interface Stream extends StreamSummary {
filename: string;
format: string; format: string;
description: string | null; description: string | null;
tracks: Track[]; tracks: Track[];

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Sidebar from './Sidebar.svelte'; import Sidebar from './Sidebar.svelte';
import Footer from './Footer.svelte'; import Footer from './Footer.svelte';
import { setStreamContext, StreamContext } from '$lib/stream-context.svelte.ts'; import { setStreamContext, StreamContext } from '$lib/streamContext.svelte.ts';
// streams are grabbed from the server here, then accessed throughout the rest // streams are grabbed from the server here, then accessed throughout the rest
// of the components through the svelte context api // of the components through the svelte context api

View File

@@ -2,7 +2,8 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { favoritedStreams } from '$lib/stores.svelte.ts'; import { favoritedStreams } from '$lib/stores.svelte.ts';
import { getStreamContext } from '$lib/stream-context.svelte.ts'; import * as streamCache from '$lib/streamCache.svelte.ts';
import { getStreamContext } from '$lib/streamContext.svelte.ts';
import type { StreamSummary } from '$lib/types'; import type { StreamSummary } from '$lib/types';
import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.ts'; import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.ts';
import TagSelect from './TagSelect.svelte'; import TagSelect from './TagSelect.svelte';
@@ -10,6 +11,7 @@
const ctx = getStreamContext(); const ctx = getStreamContext();
let filteredTags = $state([]); let filteredTags = $state([]);
let favoritesOnly = $state(false); let favoritesOnly = $state(false);
let cachedOnly = $state(false);
let listOpen = $state(); let listOpen = $state();
let displayedStreams = $derived.by(streamsToDisplay); let displayedStreams = $derived.by(streamsToDisplay);
let remainingTags = $derived.by(getRemainingTags); let remainingTags = $derived.by(getRemainingTags);
@@ -50,6 +52,10 @@
<div class="tag-select"> <div class="tag-select">
<TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} /> <TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} />
<button
onclick={() => (cachedOnly = !cachedOnly)}
class="material-icons filter-download {cachedOnly ? 'select-faves-star' : 'unselect-faves-star'}">file_download</button
>
<button <button
onclick={() => (favoritesOnly = !favoritesOnly)} onclick={() => (favoritesOnly = !favoritesOnly)}
class="material-icons {favoritesOnly ? '' : 'un'}select-faves-star">star</button class="material-icons {favoritesOnly ? '' : 'un'}select-faves-star">star</button
@@ -59,9 +65,10 @@
<ul class="stream-list"> <ul class="stream-list">
{#each ctx.streams as stream} {#each ctx.streams as stream}
{@const favorited = favoritedStreams.has(stream.id)} {@const favorited = favoritedStreams.has(stream.id)}
{@const isCached = streamCache.cached.has(stream.id)}
{@const current = ctx.current?.id === stream.id} {@const current = ctx.current?.id === stream.id}
<li <li
hidden={!displayedStreams.includes(stream) || (favoritesOnly && !favorited)} hidden={!displayedStreams.includes(stream) || (favoritesOnly && !favorited) || (cachedOnly && !isCached)}
class="stream-item {current ? 'current-stream' : ''}" class="stream-item {current ? 'current-stream' : ''}"
id="stream-{stream.id}" id="stream-{stream.id}"
> >
@@ -91,6 +98,19 @@
class="material-icons stream-item-star {favorited ? 'stream-item-star-faved' : ''}" class="material-icons stream-item-star {favorited ? 'stream-item-star-faved' : ''}"
>{favorited ? 'star' : 'star_border'}</button >{favorited ? 'star' : 'star_border'}</button
> >
{#if streamCache.downloading.has(stream.id)}
<span class="stream-item-cached">{streamCache.downloading.get(stream.id)}%</span>
{:else if isCached}
<button
onclick={(e) => { e.stopPropagation(); streamCache.remove(stream.id); }}
class="material-icons stream-item-cached stream-item-cached-btn">delete</button
>
{:else}
<button
onclick={(e) => { e.stopPropagation(); streamCache.download(stream); }}
class="material-icons filter-download stream-item-cached stream-item-cached-btn">file_download</button
>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -168,24 +188,51 @@
.select-faves-star { .select-faves-star {
color: rgba(255, 219, 88, 1); color: rgba(255, 219, 88, 1);
font-size: 24px; font-size: 24px;
transform: translateX(-1px);
} }
.unselect-faves-star { .unselect-faves-star {
color: #bbbbbb; color: #bbbbbb;
font-size: 24px; font-size: 24px;
transform: translateX(-1px);
}
.filter-download {
transform: translateY(1px);
} }
.stream-item-star { .stream-item-star {
font-size: 18px; font-size: 18px;
position: absolute; position: absolute;
bottom: 0; bottom: 2px;
right: 0; right: 2px;
opacity: 0; opacity: 0;
} }
.stream-item-star-faved { .stream-item-star-faved {
opacity: 0.5; opacity: 0.5;
} }
.stream-item-cached {
font-size: 18px;
position: absolute;
bottom: 2px;
right: 22px;
opacity: 0;
}
.stream-item-cached-btn {
cursor: pointer;
}
.stream-item:hover .stream-item-cached {
opacity: 0.5;
}
.stream-item:hover .stream-item-cached-btn:hover {
opacity: 1;
}
.stream-item:hover .stream-item-star { .stream-item:hover .stream-item-star {
opacity: 0.5; opacity: 0.5;
} }
@@ -202,6 +249,15 @@
.stream-item-star { .stream-item-star {
opacity: 0.5; opacity: 0.5;
font-size: 24px; font-size: 24px;
bottom: 4px;
right: 4px;
}
.stream-item-cached {
opacity: 0.4;
font-size: 24px;
bottom: 4px;
right: 30px;
} }
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script> <script>
import { getStreamContext } from '$lib/stream-context.svelte.ts'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
const ctx = getStreamContext(); const ctx = getStreamContext();
ctx.clearCurrent(); ctx.clearCurrent();

View File

@@ -1,17 +1,27 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
import StreamPage from './StreamPage.svelte'; import StreamPage from './StreamPage.svelte';
import MetadataEditor from './MetadataEditor.svelte'; import MetadataEditor from './MetadataEditor.svelte';
import Player from './Player.svelte'; import Player from './Player.svelte';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { getStreamContext } from '$lib/stream-context.svelte.ts'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import * as streamCache from '$lib/streamCache.svelte.ts';
let { data } = $props(); let { data } = $props();
const ctx = getStreamContext(); const ctx = getStreamContext();
let playerSrc = $state<string | null>(null);
run(() => { // reactivity runs on `stream` and `streamCache.cached` here
ctx.setCurrent(data.stream); $effect(() => {
const stream = data.stream;
ctx.setCurrent(stream);
playerSrc = null;
if (streamCache.cached.has(stream.id)) {
streamCache.getUrl(stream.id).then((url) => {
playerSrc = url ?? `/media/tracks/${stream.filename}`;
});
} else {
playerSrc = `/media/tracks/${stream.filename}`;
}
}); });
</script> </script>
@@ -23,8 +33,10 @@
<StreamPage /> <StreamPage />
</div> </div>
<div id="player"> <div id="player">
{#key ctx.current} {#key playerSrc}
<Player display={true} src="/media/tracks/{ctx.current?.filename}" /> {#if playerSrc}
<Player display={true} src={playerSrc} />
{/if}
{/key} {/key}
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@
const bubble = createBubbler(); const bubble = createBubbler();
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition'; 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'; import type { Track } from '$lib/types';
const ctx = getStreamContext(); const ctx = getStreamContext();

View File

@@ -1,5 +1,5 @@
<script> <script>
import { getStreamContext } from '$lib/stream-context.svelte.ts'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts'; import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts';
import { jumpToTrack } from './Player.svelte'; import { jumpToTrack } from './Player.svelte';
import { Carta } from 'carta-md'; import { Carta } from 'carta-md';