Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fc04a4d8 | ||
|
|
39593d470d | ||
|
|
c038316461 | ||
|
|
e98293fa7c | ||
|
|
9c3fd4c1a2 | ||
|
|
d7999785c8 | ||
|
|
62af3683a4 |
+4
-1
@@ -23,7 +23,10 @@ const lookup = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Retrieve existing IDs from Stream
|
// Retrieve existing IDs from Stream
|
||||||
const existingIds = db.prepare('SELECT id FROM Stream').all().map(row => row.id);
|
const existingIds = db
|
||||||
|
.prepare('SELECT id FROM Stream')
|
||||||
|
.all()
|
||||||
|
.map((row) => row.id);
|
||||||
// Create a set of existing IDs for efficient lookup
|
// Create a set of existing IDs for efficient lookup
|
||||||
const idSet = new Set(existingIds);
|
const idSet = new Set(existingIds);
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference lib="dom.asynciterable" />
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
+4
-3
@@ -9,7 +9,8 @@ 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, filename, 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) => ({
|
||||||
@@ -17,7 +18,7 @@ export function getStreams(): StreamSummary[] {
|
|||||||
stream_date: Date.parse(stream.stream_date as string),
|
stream_date: Date.parse(stream.stream_date as string),
|
||||||
filename: stream.filename 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
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export function getStreamInfo(streamId: string): Stream | null {
|
|||||||
format: streamData.format as string,
|
format: streamData.format as string,
|
||||||
title: streamData.title as string | null,
|
title: streamData.title as string | null,
|
||||||
description: streamData.description as string | null,
|
description: streamData.description as string | null,
|
||||||
tags: JSON.parse(streamData.tags as string) as string[],
|
tags: (JSON.parse(streamData.tags as string) as string[]) ?? [],
|
||||||
length_seconds: streamData.length_seconds as number,
|
length_seconds: streamData.length_seconds as number,
|
||||||
tracks: JSON.parse(streamData.tracks as string)
|
tracks: JSON.parse(streamData.tracks as string)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { browser } from '$app/environment';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { readStored, writeStored } from './utils.ts';
|
||||||
|
|
||||||
export const favoritedStreams = new SvelteSet<string>(
|
export const favoritedStreams = new SvelteSet<string>(readStored('favoritedStreams', []));
|
||||||
JSON.parse((browser && localStorage.getItem('favoritedStreams')) || '[]')
|
|
||||||
);
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
localStorage.setItem('favoritedStreams', JSON.stringify(Array.from(favoritedStreams)));
|
writeStored('favoritedStreams', Array.from(favoritedStreams));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+183
-46
@@ -1,66 +1,203 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
||||||
import type { StreamSummary } from './types.ts';
|
import { readStored, writeStored } from './utils.ts';
|
||||||
|
import type { StreamSummary, PartialDownload } from './types.ts';
|
||||||
|
|
||||||
const STORAGE_KEY = 'cachedStreams';
|
const STORAGE_KEY = 'cachedStreams';
|
||||||
|
const PARTIALS_KEY = 'partialStreams';
|
||||||
|
const PARTS_DIR = 'parts';
|
||||||
|
|
||||||
export const cached = new SvelteSet<string>(
|
// MAX_ATTEMPTS is consecutive attempts that acquired zero chunks, so the exponential
|
||||||
browser ? JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') : []
|
// backoff below tolerates roughly 8 minutes of outage before giving up
|
||||||
);
|
const MAX_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
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
|
||||||
|
const CHUNK_TIMEOUT = 60_000;
|
||||||
|
|
||||||
|
export const cached = new SvelteSet<string>(readStored<string[]>(STORAGE_KEY, []));
|
||||||
export const downloading = new SvelteMap<string, number>();
|
export const downloading = new SvelteMap<string, number>();
|
||||||
|
|
||||||
|
// bytes already written to OPFS for each incomplete download
|
||||||
|
export const partials = new SvelteMap<string, PartialDownload>(
|
||||||
|
Object.entries(readStored<Record<string, PartialDownload>>(PARTIALS_KEY, {}))
|
||||||
|
);
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...cached]));
|
writeStored(STORAGE_KEY, [...cached]);
|
||||||
});
|
});
|
||||||
});
|
$effect(() => {
|
||||||
|
writeStored(PARTIALS_KEY, Object.fromEntries(partials));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function download(stream: StreamSummary) {
|
export async function download(stream: StreamSummary) {
|
||||||
const res = await fetch(`/media/tracks/${stream.filename}`);
|
if (downloading.has(stream.id) || cached.has(stream.id)) return;
|
||||||
if (!res.ok || !res.body) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const total = Number(res.headers.get('content-length') || 0);
|
const resumed = partials.get(stream.id);
|
||||||
const reader = res.body.getReader();
|
downloading.set(stream.id, percent(resumed?.received ?? 0, resumed?.total ?? 0));
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let received = 0;
|
|
||||||
|
|
||||||
downloading.set(stream.id, 0);
|
try {
|
||||||
|
let attempt = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const before = partials.get(stream.id)?.received ?? 0;
|
||||||
if (done) break;
|
try {
|
||||||
chunks.push(value);
|
await attemptDownload(stream);
|
||||||
received += value.length;
|
partials.delete(stream.id);
|
||||||
if (total) downloading.set(stream.id, Math.round((received / total) * 100));
|
cached.add(stream.id);
|
||||||
}
|
return;
|
||||||
|
} catch {
|
||||||
const root = await navigator.storage.getDirectory();
|
// reset attempts if download of at least one chunk successful
|
||||||
const handle = await root.getFileHandle(stream.id, { create: true });
|
attempt = (partials.get(stream.id)?.received ?? 0) > before ? 0 : attempt + 1;
|
||||||
const writable = await handle.createWritable();
|
if (attempt === MAX_ATTEMPTS) return;
|
||||||
await writable.write(new Blob(chunks));
|
}
|
||||||
await writable.close();
|
await waitForRetry(attempt);
|
||||||
|
}
|
||||||
downloading.delete(stream.id);
|
} finally {
|
||||||
cached.add(stream.id);
|
downloading.delete(stream.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(streamId: string) {
|
export async function remove(streamId: string) {
|
||||||
const root = await navigator.storage.getDirectory();
|
cached.delete(streamId);
|
||||||
await root.removeEntry(streamId);
|
await discard(streamId);
|
||||||
cached.delete(streamId);
|
const root = await navigator.storage.getDirectory();
|
||||||
|
await root.removeEntry(streamId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUrl(streamId: string): Promise<string | null> {
|
export async function getUrl(streamId: string): Promise<string | null> {
|
||||||
if (!cached.has(streamId)) return null;
|
if (!cached.has(streamId)) return null;
|
||||||
try {
|
try {
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
const handle = await root.getFileHandle(streamId);
|
const handle = await root.getFileHandle(streamId);
|
||||||
const file = await handle.getFile();
|
const file = await handle.getFile();
|
||||||
return URL.createObjectURL(file);
|
return URL.createObjectURL(file);
|
||||||
} catch {
|
} catch {
|
||||||
cached.delete(streamId);
|
cached.delete(streamId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptDownload(stream: StreamSummary) {
|
||||||
|
let state = partials.get(stream.id);
|
||||||
|
while (!state || state.received < state.total) {
|
||||||
|
state = await fetchChunk(stream, state);
|
||||||
|
partials.set(stream.id, state);
|
||||||
|
downloading.set(stream.id, percent(state.received, state.total));
|
||||||
|
}
|
||||||
|
await assemble(stream.id, state.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetch chunk starting from the last valid one, store in OPFS, and return updated state */
|
||||||
|
async function fetchChunk(stream: StreamSummary, state?: PartialDownload) {
|
||||||
|
const start = state?.received ?? 0;
|
||||||
|
const headers = new Headers({ Range: `bytes=${start}-${start + CHUNK_SIZE - 1}` });
|
||||||
|
// attach existing ETag to the request
|
||||||
|
if (state?.etag) headers.set('If-Range', state.etag);
|
||||||
|
|
||||||
|
const res = await fetch(`/media/tracks/${stream.filename}`, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(CHUNK_TIMEOUT)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 200 means the range was ignored or the file changed (different ETag)
|
||||||
|
// 416 that our start offset is past its end
|
||||||
|
// anything else can be transient, so the chunks already on disk are kept
|
||||||
|
if (res.status !== 206) {
|
||||||
|
if (res.status === 200 || res.status === 416) await discard(stream.id);
|
||||||
|
throw new Error(`Download failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number(res.headers.get('content-range')?.split('/')[1]);
|
||||||
|
if (!total) throw new Error('Malformed Content-Range');
|
||||||
|
|
||||||
|
const etag = res.headers.get('etag') ?? res.headers.get('last-modified');
|
||||||
|
if (state && state.total !== total) {
|
||||||
|
await discard(stream.id);
|
||||||
|
throw new Error('File changed while downloading');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await res.arrayBuffer();
|
||||||
|
const dir = await partsDir(stream.id, true);
|
||||||
|
const handle = await dir.getFileHandle(String(start), { create: true });
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(bytes);
|
||||||
|
await writable.close();
|
||||||
|
|
||||||
|
return { received: start + bytes.byteLength, total, etag };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** concatenate stream file once all the parts are downloaded */
|
||||||
|
async function assemble(streamId: string, total: number) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const dir = await partsDir(streamId, true);
|
||||||
|
const handle = await root.getFileHandle(streamId, { create: true });
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
|
||||||
|
// check at each step the chunk names (ranges) are coherent
|
||||||
|
let written = 0;
|
||||||
|
while (written < total) {
|
||||||
|
const part = await dir.getFileHandle(String(written)).catch(() => null);
|
||||||
|
if (!part) break;
|
||||||
|
const file = await part.getFile();
|
||||||
|
if (!file.size) break;
|
||||||
|
await writable.write(file);
|
||||||
|
written += file.size;
|
||||||
|
}
|
||||||
|
await writable.close();
|
||||||
|
|
||||||
|
await discard(streamId);
|
||||||
|
// chunks went missing, shit's fucked...
|
||||||
|
if (written !== total) throw new Error(`Assembled ${written} of ${total} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** returns directory handle dedicated to partial downloads of given stream id */
|
||||||
|
async function partsDir(streamId: string, create = false) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const parts = await root.getDirectoryHandle(PARTS_DIR, { create });
|
||||||
|
return parts.getDirectoryHandle(streamId, { create });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** discard stream's partial download state and run a sweep of leftover parts files */
|
||||||
|
async function discard(streamId: string) {
|
||||||
|
partials.delete(streamId);
|
||||||
|
await sweep(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** remove unreferenced chunk directories (that aren't still downloading)
|
||||||
|
* `force` is there for `discard()` to use as an escape hatch, otherwise it'd be caught in
|
||||||
|
* the guard condition due to still being in `downloading` by then
|
||||||
|
*/
|
||||||
|
async function sweep(force?: string) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const parts = await root.getDirectoryHandle(PARTS_DIR, { create: true });
|
||||||
|
const orphans: string[] = [];
|
||||||
|
for await (const name of parts.keys()) {
|
||||||
|
if (partials.has(name)) continue;
|
||||||
|
if (downloading.has(name) && name !== force) continue;
|
||||||
|
orphans.push(name);
|
||||||
|
}
|
||||||
|
for (const name of orphans) await parts.removeEntry(name, { recursive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** exponential backoff, cut short if the connection comes back before it elapses */
|
||||||
|
function waitForRetry(attempt: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const done = () => {
|
||||||
|
removeEventListener('online', done);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(done, 2 ** attempt * 1000);
|
||||||
|
addEventListener('online', done);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(received: number, total: number) {
|
||||||
|
return total ? Math.round((received / total) * 100) : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,47 @@ import { createContext } from 'svelte';
|
|||||||
import type { Stream, StreamSummary } from './types.ts';
|
import type { Stream, StreamSummary } from './types.ts';
|
||||||
|
|
||||||
export class StreamContext {
|
export class StreamContext {
|
||||||
streams: StreamSummary[];
|
streams: StreamSummary[];
|
||||||
current = $state<Stream | null>(null);
|
current = $state<Stream | null>(null);
|
||||||
songIndex = $state<number | null>(null);
|
songIndex = $state<number | null>(null);
|
||||||
#timestamps: number[] = [];
|
#timestamps: number[] = [];
|
||||||
|
|
||||||
constructor(streams: StreamSummary[]) {
|
constructor(streams: StreamSummary[]) {
|
||||||
this.streams = streams;
|
this.streams = streams;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrent(stream: Stream) {
|
setCurrent(stream: Stream) {
|
||||||
this.current = stream;
|
this.current = stream;
|
||||||
this.#timestamps = [-Infinity, ...stream.tracks.map((t) => t[0]), Infinity];
|
this.#timestamps = [-Infinity, ...stream.tracks.map((t) => t[0]), Infinity];
|
||||||
this.songIndex = 0;
|
this.songIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrent() {
|
clearCurrent() {
|
||||||
this.current = null;
|
this.current = null;
|
||||||
this.songIndex = null;
|
this.songIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSongAtTime(time: number): number {
|
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) {
|
updateCurrentSong(currentTime: number, songIndex: number) {
|
||||||
const ts = this.#timestamps;
|
const ts = this.#timestamps;
|
||||||
const recurse = (idx: number): number => {
|
// the first track need not start at 0, so descending past index 0 is possible
|
||||||
if (currentTime >= ts[idx + 2]) return recurse(idx + 1);
|
const recurse = (idx: number): number => {
|
||||||
if (currentTime < ts[idx + 1]) return recurse(idx - 1);
|
if (currentTime >= ts[idx + 2]) return recurse(idx + 1);
|
||||||
return idx;
|
if (idx > 0 && currentTime < ts[idx + 1]) return recurse(idx - 1);
|
||||||
};
|
return idx;
|
||||||
this.songIndex = recurse(songIndex);
|
};
|
||||||
}
|
this.songIndex = recurse(songIndex);
|
||||||
|
}
|
||||||
|
|
||||||
#locationOf(element: number, array: number[], start = 0, end = array.length): number {
|
#locationOf(element: number, array: number[], start = 0, end = array.length): number {
|
||||||
const pivot = Math.floor(start + (end - start) / 2);
|
const pivot = Math.floor(start + (end - start) / 2);
|
||||||
if (end - start <= 1 || array[pivot] === element) return pivot;
|
if (end - start <= 1 || array[pivot] === element) return pivot;
|
||||||
if (array[pivot] < element) return this.#locationOf(element, array, pivot, end);
|
if (array[pivot] < element) return this.#locationOf(element, array, pivot, end);
|
||||||
return this.#locationOf(element, array, start, pivot);
|
return this.#locationOf(element, array, start, pivot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const [getStreamContext, setStreamContext] = createContext<StreamContext>();
|
export const [getStreamContext, setStreamContext] = createContext<StreamContext>();
|
||||||
|
|||||||
+18
-10
@@ -3,18 +3,26 @@ export type Track = [number, string, string];
|
|||||||
|
|
||||||
/** The summary shape used in sidebar listings. */
|
/** The summary shape used in sidebar listings. */
|
||||||
export interface StreamSummary {
|
export interface StreamSummary {
|
||||||
id: string;
|
id: string;
|
||||||
stream_date: number;
|
stream_date: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
length_seconds: number;
|
length_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
format: string;
|
format: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
descriptionHtml: string;
|
// rendered at page load, so absent straight out of the database
|
||||||
tracks: Track[];
|
descriptionHtml?: string;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partial stream downloads, as well as associated etag for cache validation. */
|
||||||
|
export interface PartialDownload {
|
||||||
|
received: number;
|
||||||
|
total: number;
|
||||||
|
etag: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
import Sqids from 'sqids';
|
import Sqids from 'sqids';
|
||||||
|
|
||||||
const sqids = new Sqids({ minLength: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789' });
|
const sqids = new Sqids({ minLength: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789' });
|
||||||
|
|
||||||
|
// browser blocks site data => localStorage throws
|
||||||
|
// so we're wrapping our calls to ensure things running at module init
|
||||||
|
// don't bring down the site render entirely
|
||||||
|
export function readStored<T>(key: string, fallback: T): T {
|
||||||
|
if (!browser) return fallback;
|
||||||
|
try {
|
||||||
|
return (JSON.parse(localStorage.getItem(key) ?? '') as T) ?? fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStored(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export function hashcode(str: string): number {
|
export function hashcode(str: string): number {
|
||||||
let h = 9;
|
let h = 9;
|
||||||
for (let i = 0; i < str.length; ) h = Math.imul(h ^ str.charCodeAt(i++), 9 ** 9);
|
for (let i = 0; i < str.length; ) h = Math.imul(h ^ str.charCodeAt(i++), 9 ** 9);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<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 { untrack } from 'svelte';
|
||||||
import { setStreamContext, StreamContext } from '$lib/streamContext.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
|
||||||
const { data, children } = $props();
|
const { data, children } = $props();
|
||||||
setStreamContext(new StreamContext(data.streams));
|
setStreamContext(new StreamContext(untrack(() => data.streams)));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="mainContainer">
|
<div id="mainContainer">
|
||||||
|
|||||||
@@ -54,7 +54,9 @@
|
|||||||
<TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} />
|
<TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} />
|
||||||
<button
|
<button
|
||||||
onclick={() => (cachedOnly = !cachedOnly)}
|
onclick={() => (cachedOnly = !cachedOnly)}
|
||||||
class="material-icons filter-download {cachedOnly ? 'select-faves-star' : 'unselect-faves-star'}">file_download</button
|
class="material-icons filter-download {cachedOnly
|
||||||
|
? 'select-faves-star'
|
||||||
|
: 'unselect-faves-star'}">file_download</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => (favoritesOnly = !favoritesOnly)}
|
onclick={() => (favoritesOnly = !favoritesOnly)}
|
||||||
@@ -68,7 +70,9 @@
|
|||||||
{@const isCached = streamCache.cached.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) || (cachedOnly && !isCached)}
|
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}"
|
||||||
>
|
>
|
||||||
@@ -102,13 +106,25 @@
|
|||||||
<span class="stream-item-cached">{streamCache.downloading.get(stream.id)}%</span>
|
<span class="stream-item-cached">{streamCache.downloading.get(stream.id)}%</span>
|
||||||
{:else if isCached}
|
{:else if isCached}
|
||||||
<button
|
<button
|
||||||
onclick={(e) => { e.stopPropagation(); streamCache.remove(stream.id); }}
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
streamCache.remove(stream.id);
|
||||||
|
}}
|
||||||
class="material-icons stream-item-cached stream-item-cached-btn">delete</button
|
class="material-icons stream-item-cached stream-item-cached-btn">delete</button
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const partial = streamCache.partials.get(stream.id)}
|
||||||
<button
|
<button
|
||||||
onclick={(e) => { e.stopPropagation(); streamCache.download(stream); }}
|
onclick={(e) => {
|
||||||
class="material-icons filter-download stream-item-cached stream-item-cached-btn">file_download</button
|
e.stopPropagation();
|
||||||
|
streamCache.download(stream);
|
||||||
|
}}
|
||||||
|
title={partial
|
||||||
|
? `Partially downloaded (${Math.round((partial.received / partial.total) * 100)}%). Click to resume.`
|
||||||
|
: undefined}
|
||||||
|
class="material-icons filter-download stream-item-cached stream-item-cached-btn {partial
|
||||||
|
? 'stream-item-partial'
|
||||||
|
: ''}">file_download</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
@@ -241,6 +257,11 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-item-cached.stream-item-partial {
|
||||||
|
color: #c8a415;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.material-icons::-moz-focus-inner {
|
.material-icons::-moz-focus-inner {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(e) {
|
function handleChange(e: CustomEvent) {
|
||||||
if (e.type === 'clear' && Array.isArray(e.detail)) checked = [];
|
if (e.type === 'clear' && Array.isArray(e.detail)) checked = [];
|
||||||
else
|
else
|
||||||
checked.includes(e.detail.value)
|
checked.includes(e.detail.value)
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
handleSelectable();
|
handleSelectable();
|
||||||
}
|
}
|
||||||
|
|
||||||
let itemFilter = function (label, filterText, option) {
|
let itemFilter = function (label: string, filterText: string, option: { value: string }) {
|
||||||
return (
|
return (
|
||||||
remainingTags.includes(option['value']) &&
|
remainingTags.includes(option['value']) &&
|
||||||
label.toLowerCase().includes(filterText.toLowerCase())
|
label.toLowerCase().includes(filterText.toLowerCase())
|
||||||
@@ -55,14 +55,13 @@
|
|||||||
--max-height="42px"
|
--max-height="42px"
|
||||||
listOffset={0}
|
listOffset={0}
|
||||||
>
|
>
|
||||||
{#snippet item({ item })}
|
<!-- svelte-select 5.8.3 exposes this as a slot, not a snippet prop -->
|
||||||
<div class="item">
|
<div class="item" slot="item" let:item>
|
||||||
<label for={item.value}>
|
<label for={item.value}>
|
||||||
<input type="checkbox" id={item.value} checked={isChecked[item.value]} />
|
<input type="checkbox" id={item.value} checked={isChecked[item.value]} />
|
||||||
{item.label}
|
{item.label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -13,10 +13,13 @@
|
|||||||
<div class="description-bubble">
|
<div class="description-bubble">
|
||||||
<p>Hello, there, welcome to V1.6.</p>
|
<p>Hello, there, welcome to V1.6.</p>
|
||||||
<p>
|
<p>
|
||||||
<u>2026-03-16 update:</u> you can now cache streams offline for playback in low-connectivity environments (and to prevent buffering)!<br>
|
<u>2026-03-16 update:</u> you can now cache streams offline for playback in low-connectivity
|
||||||
Simply click on the download icon in the stream list (and click again to remove them from cache). You can also filter by cached streams at the top. This all goes into your browser storage, and a stream is generally 100~250MB depending on its length.
|
environments (and to prevent buffering)!<br />
|
||||||
|
Simply click on the download icon in the stream list (and click again to remove them from cache).
|
||||||
|
You can also filter by cached streams at the top. This all goes into your browser storage, and
|
||||||
|
a stream is generally 100~250MB depending on its length.
|
||||||
</p>
|
</p>
|
||||||
<hr>
|
<hr />
|
||||||
<p>
|
<p>
|
||||||
Still in construction. The design is responsive now, so phones should work well enough!
|
Still in construction. The design is responsive now, so phones should work well enough!
|
||||||
Needs touch events though.
|
Needs touch events though.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import path from 'path';
|
import path from 'node:path';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getStreamInfo } from '$lib/database.ts';
|
import { getStreamInfo } from '$lib/database.ts';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import { STREAM_JSON_LOCATION } from '$env/static/private';
|
import { STREAM_JSON_LOCATION } from '$env/static/private';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
let getOriginalJson, writeSideloadJson;
|
type SideloadJson = { title: unknown; description: unknown; tags: unknown };
|
||||||
export let actions;
|
|
||||||
|
let getOriginalJson: (streamId: string) => string;
|
||||||
|
let writeSideloadJson: (streamId: string, newJson: SideloadJson) => void;
|
||||||
|
export let actions: Actions | undefined;
|
||||||
|
|
||||||
// utilities for manipulating original stream JSONs
|
// utilities for manipulating original stream JSONs
|
||||||
if (dev) {
|
if (dev) {
|
||||||
@@ -30,11 +34,11 @@ if (dev) {
|
|||||||
actions = {
|
actions = {
|
||||||
default: async ({ params, request }) => {
|
default: async ({ params, request }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
// let newJson = JSON.parse(getOriginalJson(params.stream_id));
|
const newJson: SideloadJson = {
|
||||||
const newJson = {};
|
title: data.get('title'),
|
||||||
newJson['title'] = data.get('title');
|
description: data.get('description'),
|
||||||
newJson['description'] = data.get('description');
|
tags: data.getAll('tags')
|
||||||
newJson['tags'] = data.getAll('tags');
|
};
|
||||||
writeSideloadJson(params.stream_id, newJson);
|
writeSideloadJson(params.stream_id, newJson);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,9 +50,7 @@ export function load({ params }) {
|
|||||||
error(404);
|
error(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.descriptionHtml = Bun.markdown.html(
|
result.descriptionHtml = Bun.markdown.html(result.description || 'No description available.');
|
||||||
result.description || 'No description available.'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dev) {
|
if (dev) {
|
||||||
// pass raw JSON for metadata editor
|
// pass raw JSON for metadata editor
|
||||||
|
|||||||
@@ -2,26 +2,49 @@
|
|||||||
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 { untrack } from 'svelte';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
||||||
import * as streamCache from '$lib/streamCache.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);
|
|
||||||
|
|
||||||
// reactivity runs on `stream` and `streamCache.cached` here
|
let source = $state<{ id: string; url: string } | null>(null);
|
||||||
$effect(() => {
|
|
||||||
const stream = data.stream;
|
const streamId = $derived(data.stream.id);
|
||||||
ctx.setCurrent(stream);
|
const isCached = $derived(streamCache.cached.has(streamId));
|
||||||
playerSrc = null;
|
|
||||||
if (streamCache.cached.has(stream.id)) {
|
// update stream context only when we actually swap streams
|
||||||
streamCache.getUrl(stream.id).then((url) => {
|
// this avoids reloading the audio element when we click on the same stream
|
||||||
playerSrc = url ?? `/media/tracks/${stream.filename}`;
|
$effect.pre(() => {
|
||||||
});
|
streamId;
|
||||||
} else {
|
ctx.setCurrent(untrack(() => data.stream));
|
||||||
playerSrc = `/media/tracks/${stream.filename}`;
|
});
|
||||||
|
|
||||||
|
$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);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,9 +56,10 @@
|
|||||||
<StreamPage />
|
<StreamPage />
|
||||||
</div>
|
</div>
|
||||||
<div id="player">
|
<div id="player">
|
||||||
{#key playerSrc}
|
{#key streamId}
|
||||||
{#if playerSrc}
|
<!-- until streamCache.getUrl() resolves, source still points at the previous stream -->
|
||||||
<Player display={true} src={playerSrc} />
|
{#if source?.id === streamId}
|
||||||
|
<Player display={true} src={source.url} />
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
let { original = $bindable() } = $props();
|
let { original = $bindable() } = $props();
|
||||||
|
|
||||||
let tagMap = new Map();
|
let tagMap = new Map<string, boolean>();
|
||||||
|
|
||||||
// Create a mapping of tags and their checked status
|
// Create a mapping of tags and their checked status
|
||||||
let reloadTags = (original) => {
|
let reloadTags = (original: { tags: string[] }) => {
|
||||||
tagList.forEach((tag) => {
|
tagList.forEach((tag) => {
|
||||||
tagMap.set(tag, original.tags.includes(tag));
|
tagMap.set(tag, original.tags.includes(tag));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
ISC License
|
ISC License
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script module>
|
<script module lang="ts">
|
||||||
let getAudio = null;
|
let getAudio: (() => HTMLAudioElement) | null = null;
|
||||||
|
|
||||||
export function jumpToTrack(s) {
|
export function jumpToTrack(s: number) {
|
||||||
getAudio().currentTime = s;
|
const el = getAudio?.();
|
||||||
getAudio().play();
|
if (!el) return;
|
||||||
|
el.currentTime = s;
|
||||||
|
el.play();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -16,18 +18,18 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
||||||
|
import { readStored, writeStored } from '$lib/utils.ts';
|
||||||
import type { Track } from '$lib/types';
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
const ctx = getStreamContext();
|
const ctx = getStreamContext();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: any;
|
src: any;
|
||||||
audio?: any;
|
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
preload?: string;
|
preload?: 'none' | 'metadata' | 'auto';
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
barPrimaryColor?: string;
|
barPrimaryColor?: string;
|
||||||
@@ -41,7 +43,6 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
src,
|
src,
|
||||||
audio = $bindable(null),
|
|
||||||
paused = $bindable(true),
|
paused = $bindable(true),
|
||||||
duration = $bindable(0),
|
duration = $bindable(0),
|
||||||
muted = $bindable(false),
|
muted = $bindable(false),
|
||||||
@@ -58,12 +59,30 @@
|
|||||||
onended
|
onended
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
getAudio = () => {
|
let audio = $state<HTMLAudioElement>()!;
|
||||||
return audio;
|
|
||||||
};
|
// 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 currentTime = $state(0);
|
||||||
let tooltip = $state();
|
let tooltip = $state<HTMLElement>();
|
||||||
let tooltipX = $state(0);
|
let tooltipX = $state(0);
|
||||||
let tooltipY = $state(0);
|
let tooltipY = $state(0);
|
||||||
let showTooltip = $state(false);
|
let showTooltip = $state(false);
|
||||||
@@ -72,10 +91,10 @@
|
|||||||
let seeking = $state(false);
|
let seeking = $state(false);
|
||||||
let volumeSeeking = $state(false);
|
let volumeSeeking = $state(false);
|
||||||
let pendingSeekTime = $state<number | null>(null);
|
let pendingSeekTime = $state<number | null>(null);
|
||||||
let songBar = $state();
|
let songBar = $state<HTMLProgressElement>();
|
||||||
let volumeBar = $state();
|
let volumeBar = $state<HTMLProgressElement>();
|
||||||
let innerWidth = $state();
|
let innerWidth = $state(0);
|
||||||
let innerHeight = $state();
|
let innerHeight = $state(0);
|
||||||
let isSafari = $state(false);
|
let isSafari = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -87,14 +106,18 @@
|
|||||||
isSafari = isIOS || isDesktopSafari;
|
isSafari = isIOS || isDesktopSafari;
|
||||||
|
|
||||||
// default volume
|
// default volume
|
||||||
const volumeData = localStorage.getItem('volume');
|
volume = readStored('volume', 0.67);
|
||||||
volume = volumeData ? parseFloat(volumeData) : 0.67;
|
|
||||||
setVolume(volume);
|
setVolume(volume);
|
||||||
|
|
||||||
// set actions for previous/next track media buttons
|
// set actions for previous/next track media buttons
|
||||||
|
// mediaSession is global, so handlers of a destroyed instance would keep firing
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', previousTrack);
|
navigator.mediaSession.setActionHandler('previoustrack', previousTrack);
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
|
||||||
|
return () => {
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,14 +148,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seek(event, bounds) {
|
function seek(event: MouseEvent, bounds: DOMRect) {
|
||||||
let x = event.clientX - bounds.left;
|
let x = event.clientX - bounds.left;
|
||||||
return Math.min(Math.max(x / bounds.width, 0), 1);
|
return Math.min(Math.max(x / bounds.width, 0), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// exponential volume bar
|
// exponential volume bar
|
||||||
// default is linear, which doesn't correspond to human hearing
|
// default is linear, which doesn't correspond to human hearing
|
||||||
function setVolume(volume) {
|
function setVolume(volume: number) {
|
||||||
if (volume != 0) {
|
if (volume != 0) {
|
||||||
audio.volume = Math.pow(10, 2.5 * (volume - 1));
|
audio.volume = Math.pow(10, 2.5 * (volume - 1));
|
||||||
} else {
|
} else {
|
||||||
@@ -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
|
// need to init duration & volume after SSR first load
|
||||||
// also workaround for bug https://github.com/sveltejs/svelte/issues/5914
|
// also workaround for bug https://github.com/sveltejs/svelte/issues/5914
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -162,6 +185,12 @@
|
|||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
duration = audio.duration;
|
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;
|
paused = audio.paused;
|
||||||
setVolume(volume);
|
setVolume(volume);
|
||||||
};
|
};
|
||||||
@@ -172,22 +201,22 @@
|
|||||||
return () => audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
return () => audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateSeekVisual(event) {
|
function updateSeekVisual(event: MouseEvent) {
|
||||||
if (!songBar) return;
|
if (!songBar) return;
|
||||||
pendingSeekTime = seek(event, songBar.getBoundingClientRect()) * duration;
|
pendingSeekTime = seek(event, songBar.getBoundingClientRect()) * duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekVolume(event) {
|
function seekVolume(event: MouseEvent) {
|
||||||
if (!volumeBar) return;
|
if (!volumeBar) return;
|
||||||
volume = seek(event, volumeBar.getBoundingClientRect());
|
volume = seek(event, volumeBar.getBoundingClientRect());
|
||||||
setVolume(volume);
|
setVolume(volume);
|
||||||
localStorage.setItem('volume', volume.toString());
|
writeStored('volume', volume);
|
||||||
muted = false;
|
muted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSeconds(totalSeconds, forceHours = false) {
|
function formatSeconds(totalSeconds: number, forceHours = false) {
|
||||||
if (isNaN(totalSeconds)) return 'No Data';
|
if (isNaN(totalSeconds)) return 'No Data';
|
||||||
totalSeconds = parseInt(totalSeconds, 10);
|
totalSeconds = Math.trunc(totalSeconds);
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
@@ -198,8 +227,9 @@
|
|||||||
.join(':');
|
.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekTooltip(event) {
|
function seekTooltip(event: MouseEvent) {
|
||||||
if (!inlineTooltip) {
|
if (!songBar) return;
|
||||||
|
if (!inlineTooltip && tooltip) {
|
||||||
let tooltipBounds = tooltip.getBoundingClientRect();
|
let tooltipBounds = tooltip.getBoundingClientRect();
|
||||||
tooltipX = Math.min(event.clientX + 10, innerWidth - tooltipBounds.width);
|
tooltipX = Math.min(event.clientX + 10, innerWidth - tooltipBounds.width);
|
||||||
tooltipY = Math.min(songBar.offsetTop - 30, innerHeight - tooltipBounds.height);
|
tooltipY = Math.min(songBar.offsetTop - 30, innerHeight - tooltipBounds.height);
|
||||||
@@ -216,7 +246,7 @@
|
|||||||
seekText = formatSeconds(seekValue);
|
seekText = formatSeconds(seekValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackMouse(event) {
|
function trackMouse(event: MouseEvent) {
|
||||||
if (seeking) updateSeekVisual(event);
|
if (seeking) updateSeekVisual(event);
|
||||||
if (showTooltip && !disableTooltip) seekTooltip(event);
|
if (showTooltip && !disableTooltip) seekTooltip(event);
|
||||||
if (volumeSeeking) seekVolume(event);
|
if (volumeSeeking) seekVolume(event);
|
||||||
@@ -230,7 +260,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -263,15 +292,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<progress
|
<progress
|
||||||
bind:this={songBar}
|
bind:this={songBar}
|
||||||
value={seeking && pendingSeekTime != null ? pendingSeekTime : (currentTime || 0)}
|
value={seeking && pendingSeekTime != null ? pendingSeekTime : currentTime || 0}
|
||||||
max={duration}
|
max={duration}
|
||||||
onmousedown={(e) => { seeking = true; updateSeekVisual(e); }}
|
onmousedown={(e) => {
|
||||||
|
seeking = true;
|
||||||
|
updateSeekVisual(e);
|
||||||
|
}}
|
||||||
onmouseenter={() => (showTooltip = true)}
|
onmouseenter={() => (showTooltip = true)}
|
||||||
onmouseleave={() => (showTooltip = false)}
|
onmouseleave={() => (showTooltip = false)}
|
||||||
style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}"
|
style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}"
|
||||||
class="song-progress"
|
class="song-progress"
|
||||||
></progress>
|
></progress>
|
||||||
<div class="control-times">{formatSeconds(currentTime, duration >= 3600)}/{formatSeconds(duration)}</div>
|
<div class="control-times">
|
||||||
|
{formatSeconds(currentTime, duration >= 3600)}/{formatSeconds(duration)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
style="--icon-color:{iconColor}"
|
style="--icon-color:{iconColor}"
|
||||||
class="material-icons"
|
class="material-icons"
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
|
|
||||||
const ctx = getStreamContext();
|
const ctx = getStreamContext();
|
||||||
|
|
||||||
let formattedStreamDate = $derived(
|
let formattedStreamDate = $derived(ctx.current ? formatDate(ctx.current.stream_date) : '');
|
||||||
ctx.current ? formatDate(ctx.current.stream_date) : ''
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export let GET;
|
export let GET: RequestHandler | undefined;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
const jsonFolder = './db/stream_json/';
|
const jsonFolder = './db/stream_json/';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user