Compare commits

...

12 Commits

Author SHA1 Message Date
7a052c4e68 formatting fix 2026-03-17 01:27:13 +01:00
1c7736f832 cleanup svelte legacy APIs and stuff 2026-03-17 01:27:07 +01:00
64681befad replace carta with bun's own api 2026-03-17 01:26:52 +01:00
090a145211 add previous/next track button handling 2026-03-17 00:05:44 +01:00
1dc3f4505b fix seeking problems due to progress bar width inconsistency 2026-03-16 23:21:00 +01:00
7febb497e9 update homepage message 2026-03-16 20:38:35 +01:00
6c12cfd49b Merge branch 'offline-mode' 2026-03-16 20:33:03 +01:00
3aa762d544 add stream caching ("offline mode") 2026-03-16 20:32:33 +01:00
8573b82515 more ts switches, use setcontext instead of stores 2026-03-15 17:20:16 +01:00
b47a433414 update packages 2026-03-15 17:18:43 +01:00
4d8f7c2b02 add types 2026-03-15 14:50:56 +01:00
f777873432 work around opus bug 2026-02-20 16:21:22 +01:00
25 changed files with 794 additions and 5259 deletions

836
bun.lock

File diff suppressed because it is too large Load Diff

4327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,33 +15,32 @@
"generate_iconfont_subset": "cd scripts && bash ./generate_iconfont_subset.sh" "generate_iconfont_subset": "cd scripts && bash ./generate_iconfont_subset.sh"
}, },
"dependencies": { "dependencies": {
"@sveltejs/kit": "^2.17.1", "@sveltejs/kit": "^2.55.0",
"@types/bun": "^1.2.2", "@types/bun": "^1.3.10",
"@types/node": "^20.17.17", "@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^8.57.0",
"carta-md": "^4.6.7", "dotenv": "^16.6.1",
"dotenv": "^16.4.7", "eslint": "^10.0.3",
"eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^8.10.0", "eslint-plugin-svelte": "^3.15.2",
"eslint-plugin-svelte": "^2.46.1",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"isomorphic-dompurify": "^2.21.0", "prettier": "^3.8.1",
"prettier": "^3.4.2", "prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-svelte": "^3.3.3",
"sqids": "0.3.0", "sqids": "0.3.0",
"svelte": "^5.19.9", "svelte": "^5.53.12",
"svelte-check": "^4.1.4", "svelte-check": "^4.4.5",
"svelte-select": "^5.8.3", "svelte-select": "^5.8.3",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.3", "typescript": "^5.9.3",
"vite": "^5.4.14" "vite": "^8.0.0"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/adapter-node": "^5.5.4",
"svelte-adapter-bun": "https://github.com/gornostay25/svelte-adapter-bun", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"typescript-svelte-plugin": "^0.3.45" "svelte-adapter-bun": "^1.0.1",
"typescript-svelte-plugin": "^0.3.50"
}, },
"trustedDependencies": [ "trustedDependencies": [
"svelte-adapter-bun" "svelte-adapter-bun"

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

@@ -1,36 +0,0 @@
import { Database } from 'bun:sqlite';
const dbName = './db/strimserve.db';
// Create a new database object and initialize it with the schema
const db = new Database(dbName);
export function getStreams() {
const indexData = db
.prepare(
'SELECT id, stream_date, title, tags, length_seconds ' + 'FROM Stream ORDER BY id DESC'
)
.all();
indexData.forEach((stream) => {
stream['stream_date'] = Date.parse(stream['stream_date']);
stream['tags'] = JSON.parse(stream['tags']);
});
return indexData;
}
export function getStreamInfo(streamId) {
const streamData = db
.prepare(
'SELECT id, stream_date, filename, ' +
'format, title, description, tags, ' +
'length_seconds, tracks FROM Stream ' +
'WHERE id = ?'
)
.get(streamId);
if (streamData) {
streamData['stream_date'] = Date.parse(streamData['stream_date']);
streamData['tracks'] = JSON.parse(streamData['tracks']);
streamData['tags'] = JSON.parse(streamData['tags']);
}
return streamData;
}

46
src/lib/database.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Database } from 'bun:sqlite';
import type { Stream, StreamSummary } from './types.ts';
const dbName = './db/strimserve.db';
// Create a new database object and initialize it with the schema
const db = new Database(dbName);
export function getStreams(): StreamSummary[] {
const indexData = db
.prepare(
'SELECT id, stream_date, filename, title, tags, length_seconds ' + 'FROM Stream ORDER BY id DESC'
)
.all() as Record<string, unknown>[];
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
}));
}
export function getStreamInfo(streamId: string): Stream | null {
const streamData = db
.prepare(
'SELECT id, stream_date, filename, ' +
'format, title, description, tags, ' +
'length_seconds, tracks FROM Stream ' +
'WHERE id = ?'
)
.get(streamId) as Record<string, unknown> | null;
if (!streamData) return null;
return {
id: streamData.id as string,
stream_date: Date.parse(streamData.stream_date as string),
filename: streamData.filename as string,
format: streamData.format as string,
title: streamData.title as string | null,
description: streamData.description as string | null,
tags: JSON.parse(streamData.tags as string) as string[],
length_seconds: streamData.length_seconds as number,
tracks: JSON.parse(streamData.tracks as string)
};
}

View File

@@ -1,85 +0,0 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { SvelteSet } from 'svelte/reactivity';
export const currentStream = writable({});
export const currentSongIndex = writable(null);
export const favoritedStreams = new SvelteSet(
JSON.parse((browser && localStorage.getItem('favoritedStreams')) || '[]')
);
$effect.root(() => {
$effect(() => {
localStorage.setItem('favoritedStreams', JSON.stringify(Array.from(favoritedStreams)));
});
});
export const tagList = [
'acoustic',
'electronic',
'orchestral',
'rock',
'pop',
'metal',
'aggressive',
'folk',
'jazzy',
'dance.music',
'untz',
'breakbeats',
'electronica',
'chiptune',
'left.field',
'denpa',
'vocaloid',
'funky',
'lush',
'noisy',
'psychedelic',
'dark',
'calm',
'moody',
'uplifting'
];
let timestampList;
// utility
function locationOf(element, array, start, end) {
start = start || 0;
end = end || array.length;
var pivot = parseInt(start + (end - start) / 2, 10);
if (end - start <= 1 || array[pivot] === element) return pivot;
if (array[pivot] < element) {
return locationOf(element, array, pivot, end);
} else {
return locationOf(element, array, start, pivot);
}
}
// exported methods
export function getSongAtTime(currentTime) {
return locationOf(currentTime, timestampList) - 1;
}
// less operations needed when doing regular lookups
export function updateCurrentSong(currentTime, songIndex) {
function updateCurrentSongRecurse(songIndex) {
if (currentTime >= timestampList[songIndex + 2]) {
return updateCurrentSongRecurse(songIndex + 1);
} else if (currentTime < timestampList[songIndex + 1]) {
return updateCurrentSongRecurse(songIndex - 1);
}
return songIndex;
}
currentSongIndex.set(updateCurrentSongRecurse(songIndex));
}
export function updateCurrentStream(stream) {
currentStream.set(stream);
timestampList = [-Infinity, ...stream.tracks.map((track) => track[0]), Infinity];
currentSongIndex.set(0);
}

39
src/lib/stores.svelte.ts Normal file
View File

@@ -0,0 +1,39 @@
import { browser } from '$app/environment';
import { SvelteSet } from 'svelte/reactivity';
export const favoritedStreams = new SvelteSet<string>(
JSON.parse((browser && localStorage.getItem('favoritedStreams')) || '[]')
);
$effect.root(() => {
$effect(() => {
localStorage.setItem('favoritedStreams', JSON.stringify(Array.from(favoritedStreams)));
});
});
export const tagList = [
'acoustic',
'electronic',
'orchestral',
'rock',
'pop',
'metal',
'aggressive',
'folk',
'jazzy',
'dance.music',
'untz',
'breakbeats',
'electronica',
'chiptune',
'left.field',
'denpa',
'vocaloid',
'funky',
'lush',
'noisy',
'psychedelic',
'dark',
'calm',
'moody',
'uplifting'
];

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

@@ -0,0 +1,47 @@
import { createContext } from 'svelte';
import type { Stream, StreamSummary } from './types.ts';
export class StreamContext {
streams: StreamSummary[];
current = $state<Stream | null>(null);
songIndex = $state<number | null>(null);
#timestamps: number[] = [];
constructor(streams: StreamSummary[]) {
this.streams = streams;
}
setCurrent(stream: Stream) {
this.current = stream;
this.#timestamps = [-Infinity, ...stream.tracks.map((t) => t[0]), Infinity];
this.songIndex = 0;
}
clearCurrent() {
this.current = null;
this.songIndex = null;
}
getSongAtTime(time: number): number {
return this.#locationOf(time, this.#timestamps) - 1;
}
updateCurrentSong(currentTime: number, songIndex: number) {
const ts = this.#timestamps;
const recurse = (idx: number): number => {
if (currentTime >= ts[idx + 2]) return recurse(idx + 1);
if (currentTime < ts[idx + 1]) return recurse(idx - 1);
return idx;
};
this.songIndex = recurse(songIndex);
}
#locationOf(element: number, array: number[], start = 0, end = array.length): number {
const pivot = Math.floor(start + (end - start) / 2);
if (end - start <= 1 || array[pivot] === element) return pivot;
if (array[pivot] < element) return this.#locationOf(element, array, pivot, end);
return this.#locationOf(element, array, start, pivot);
}
}
export const [getStreamContext, setStreamContext] = createContext<StreamContext>();

20
src/lib/types.ts Normal file
View File

@@ -0,0 +1,20 @@
/** A track entry: [timestamp_seconds, artist, title] */
export type Track = [number, string, string];
/** The summary shape used in sidebar listings. */
export interface StreamSummary {
id: string;
stream_date: number;
filename: string;
title: string | null;
tags: string[];
length_seconds: number;
}
/** The full stream shape used on the detail/player page. */
export interface Stream extends StreamSummary {
format: string;
description: string | null;
descriptionHtml: string;
tracks: Track[];
}

View File

@@ -1,37 +0,0 @@
import Sqids from 'sqids';
let sqids = new Sqids({ minLength: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789' });
export function hashcode(str) {
for (var i = 0, h = 9; i < str.length; ) h = Math.imul(h ^ str.charCodeAt(i++), 9 ** 9);
return h ^ (h >>> 9);
}
// for mnemonic display
export function shorthandCode(str) {
return sqids.encode([hashcode(str) & 0xffff]);
}
// for tag display
export function hashColor(str) {
const hash = hashcode(str);
return `hsl(${hash % 360}, ${65 + (hash % 30) + 1}%, ${85 + (hash % 10) + 1}%)`;
}
export function formatSecondsToHms(s) {
s = Number(s);
var h = Math.floor(s / 3600);
var m = Math.ceil((s % 3600) / 60);
var hDisplay = h > 0 ? h + (h == 1 ? ' hr' : ' hrs') + (m > 0 ? ', ' : '') : '';
var mDisplay = m > 0 ? m + (m == 1 ? ' min' : ' mins') : '';
return hDisplay + mDisplay;
}
export function formatDate(unix_timestamp) {
return new Date(unix_timestamp).toISOString().split('T')[0];
}
export function formatTrackTime(s) {
return new Date(s * 1000).toISOString().slice(11, 19);
}

38
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,38 @@
import Sqids from 'sqids';
const sqids = new Sqids({ minLength: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789' });
export function hashcode(str: string): number {
let h = 9;
for (let i = 0; i < str.length; ) h = Math.imul(h ^ str.charCodeAt(i++), 9 ** 9);
return h ^ (h >>> 9);
}
// for mnemonic display
export function shorthandCode(str: string): string {
return sqids.encode([hashcode(str) & 0xffff]);
}
// for tag display
export function hashColor(str: string): string {
const hash = hashcode(str);
return `hsl(${hash % 360}, ${65 + (hash % 30) + 1}%, ${85 + (hash % 10) + 1}%)`;
}
export function formatSecondsToHms(s: number): string {
s = Number(s);
const h = Math.floor(s / 3600);
const m = Math.ceil((s % 3600) / 60);
const hDisplay = h > 0 ? h + (h == 1 ? ' hr' : ' hrs') + (m > 0 ? ', ' : '') : '';
const mDisplay = m > 0 ? m + (m == 1 ? ' min' : ' mins') : '';
return hDisplay + mDisplay;
}
export function formatDate(unix_timestamp: number): string {
return new Date(unix_timestamp).toISOString().split('T')[0];
}
export function formatTrackTime(s: number): string {
return new Date(s * 1000).toISOString().slice(11, 19);
}

View File

@@ -1,4 +1,4 @@
import { getStreams } from '$lib/database.js'; import { getStreams } from '$lib/database.ts';
export function load() { export function load() {
return { return {

View File

@@ -1,11 +1,16 @@
<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';
let { data, children } = $props(); import { setStreamContext, StreamContext } from '$lib/streamContext.svelte.ts';
// streams are grabbed from the server here, then accessed throughout the rest
// of the components through the svelte context api
const { data, children } = $props();
setStreamContext(new StreamContext(data.streams));
</script> </script>
<div id="mainContainer"> <div id="mainContainer">
<div id="sidebar" class="panel"><Sidebar streams={data.streams} /></div> <div id="sidebar" class="panel"><Sidebar /></div>
<div id="footer" class="panel-alt"><Footer /></div> <div id="footer" class="panel-alt"><Footer /></div>
<div id="content">{@render children?.()}</div> <div id="content">{@render children?.()}</div>
</div> </div>

View File

@@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { currentStream, favoritedStreams } from '$lib/stores.svelte.js'; import { favoritedStreams } from '$lib/stores.svelte.ts';
import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.js'; import * as streamCache from '$lib/streamCache.svelte.ts';
import { getStreamContext } from '$lib/streamContext.svelte.ts';
import type { StreamSummary } from '$lib/types';
import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.ts';
import TagSelect from './TagSelect.svelte'; import TagSelect from './TagSelect.svelte';
let { streams } = $props(); 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);
@@ -19,15 +23,15 @@
}); });
function streamsToDisplay() { function streamsToDisplay() {
return streams.filter( return ctx.streams.filter(
(stream) => (stream) =>
!('tags' in stream) || filteredTags.every((tag) => stream['tags'].includes(tag)) !('tags' in stream) || filteredTags.every((tag) => stream.tags.includes(tag))
); );
} }
function getRemainingTags() { function getRemainingTags() {
let tagCounts = displayedStreams.reduce((tags, stream) => { let tagCounts = displayedStreams.reduce((tags: Record<string, number>, stream) => {
stream['tags'].forEach((tag) => (tag in tags ? (tags[tag] += 1) : (tags[tag] = 1))); stream.tags.forEach((tag) => (tags[tag] ? (tags[tag] += 1) : (tags[tag] = 1)));
return tags; return tags;
}, {}); }, {});
return Object.entries(tagCounts) return Object.entries(tagCounts)
@@ -35,19 +39,23 @@
.map((el) => el[0]); .map((el) => el[0]);
} }
function getDisplayedTagsInList(streamTags, remainingTags) { function getDisplayedTagsInList(streamTags: string[], remainingTags: string[]) {
return streamTags.filter((tag) => remainingTags.includes(tag)); return streamTags.filter((tag) => remainingTags.includes(tag));
} }
function updateFavorites(stream) { function updateFavorites(stream: StreamSummary) {
favoritedStreams.has(stream['id']) favoritedStreams.has(stream.id)
? favoritedStreams.delete(stream['id']) ? favoritedStreams.delete(stream.id)
: favoritedStreams.add(stream['id']); : favoritedStreams.add(stream.id);
} }
</script> </script>
<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
@@ -55,29 +63,28 @@
</div> </div>
<ul class="stream-list"> <ul class="stream-list">
{#each streams as stream} {#each ctx.streams as stream}
{@const favorited = favoritedStreams.has(stream['id'])} {@const favorited = favoritedStreams.has(stream.id)}
{@const current = $currentStream['id'] === stream['id']} {@const isCached = streamCache.cached.has(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}"
> >
<button class="stream-item-button" onclick={() => goto('/streams/' + stream['id'])}> <button class="stream-item-button" onclick={() => goto('/streams/' + stream.id)}>
<span class="stream-item-id"> <span class="stream-item-id">
ID: ID:
{shorthandCode(stream['id'])}</span {shorthandCode(stream.id)}</span
> >
<span class="stream-item-date">{formatDate(stream['stream_date'])}</span> <span class="stream-item-date">{formatDate(stream.stream_date)}</span>
<span class="stream-item-length" <span class="stream-item-length">{formatSecondsToHms(stream.length_seconds)}</span>
>{formatSecondsToHms(stream['length_seconds'])}</span
>
<p class="stream-item-tags" hidden={!remainingTags.length}> <p class="stream-item-tags" hidden={!remainingTags.length}>
Tags: <span hidden={!filteredTags.length}>[...] </span>{getDisplayedTagsInList( Tags: <span hidden={!filteredTags.length}>[...] </span>{getDisplayedTagsInList(
stream['tags'], stream.tags,
remainingTags remainingTags
).join(', ')} ).join(', ')}
</p> </p>
@@ -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,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from 'svelte-select'; import Select from 'svelte-select';
import { tagList } from '$lib/stores.svelte.js'; import { tagList } from '$lib/stores.svelte.ts';
let { checked = $bindable([]), remainingTags = [], listOpen = $bindable() } = $props(); let { checked = $bindable([]), remainingTags = [], listOpen = $bindable() } = $props();
let items = $state(tagList.map((x) => ({ value: x, label: x }))); let items = $state(tagList.map((x) => ({ value: x, label: x })));
@@ -58,7 +58,7 @@
{#snippet item({ item })} {#snippet item({ item })}
<div class="item"> <div class="item">
<label for={item.value}> <label for={item.value}>
<input type="checkbox" id={item.value} bind:checked={isChecked[item.value]} /> <input type="checkbox" id={item.value} checked={isChecked[item.value]} />
{item.label} {item.label}
</label> </label>
</div> </div>

View File

@@ -1,7 +1,8 @@
<script> <script>
import { currentStream } from '$lib/stores.svelte.js'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
currentStream.set({}); const ctx = getStreamContext();
ctx.clearCurrent();
</script> </script>
<div class="stream-information"> <div class="stream-information">
@@ -10,7 +11,12 @@
</div> </div>
<div class="description-bubble"> <div class="description-bubble">
<p>Hello, there, welcome to V1.5.</p> <p>Hello, there, welcome to V1.6.</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>
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>
<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.

View File

@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getStreamInfo } from '$lib/database.js'; 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';
@@ -46,6 +46,10 @@ export function load({ params }) {
error(404); error(404);
} }
result.descriptionHtml = Bun.markdown.html(
result.description || 'No description available.'
);
if (dev) { if (dev) {
// pass raw JSON for metadata editor // pass raw JSON for metadata editor
const original = JSON.parse(getOriginalJson(params.stream_id)); const original = JSON.parse(getOriginalJson(params.stream_id));

View File

@@ -1,16 +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 { currentStream, updateCurrentStream } from '$lib/stores.svelte.js'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import * as streamCache from '$lib/streamCache.svelte.ts';
let { data } = $props(); let { data } = $props();
const ctx = getStreamContext();
let playerSrc = $state<string | null>(null);
run(() => { // reactivity runs on `stream` and `streamCache.cached` here
updateCurrentStream(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>
@@ -22,8 +33,10 @@
<StreamPage /> <StreamPage />
</div> </div>
<div id="player"> <div id="player">
{#key $currentStream} {#key playerSrc}
<Player display={true} src="/media/tracks/{$currentStream.filename}" /> {#if playerSrc}
<Player display={true} src={playerSrc} />
{/if}
{/key} {/key}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { tagList } from '$lib/stores.svelte.js'; import { tagList } from '$lib/stores.svelte.ts';
let { original = $bindable() } = $props(); let { original = $bindable() } = $props();

View File

@@ -2,7 +2,6 @@
taken from https://github.com/Linkcube/svelte-audio-controls taken from https://github.com/Linkcube/svelte-audio-controls
ISC License ISC License
--> -->
<svelte:options />
<script module> <script module>
let getAudio = null; let getAudio = null;
@@ -14,17 +13,12 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { run, createBubbler } from 'svelte/legacy'; import { onMount, untrack } from 'svelte';
const bubble = createBubbler();
import { onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { import { getStreamContext } from '$lib/streamContext.svelte.ts';
currentSongIndex, import type { Track } from '$lib/types';
currentStream,
getSongAtTime, const ctx = getStreamContext();
updateCurrentSong
} from '$lib/stores.svelte.js';
interface Props { interface Props {
src: any; src: any;
@@ -42,6 +36,7 @@
display?: boolean; display?: boolean;
inlineTooltip?: boolean; inlineTooltip?: boolean;
disableTooltip?: boolean; disableTooltip?: boolean;
onended?: () => void;
} }
let { let {
@@ -59,7 +54,8 @@
backgroundColor = 'white', backgroundColor = 'white',
display = false, display = false,
inlineTooltip = false, inlineTooltip = false,
disableTooltip = false disableTooltip = false,
onended
}: Props = $props(); }: Props = $props();
getAudio = () => { getAudio = () => {
@@ -75,29 +71,62 @@
let seekTrack = $state(''); let seekTrack = $state('');
let seeking = $state(false); let seeking = $state(false);
let volumeSeeking = $state(false); let volumeSeeking = $state(false);
let pendingSeekTime = $state<number | null>(null);
let songBar = $state(); let songBar = $state();
let volumeBar = $state(); let volumeBar = $state();
let innerWidth = $state(); let innerWidth = $state();
let innerHeight = $state(); let innerHeight = $state();
let isSafari = $state(false);
onMount(() => {
// work around coreaudio bug
// see https://git.webbieweb.org/apt-get/strimserve/issues/1
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua);
const isDesktopSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/Chromium/.test(ua);
isSafari = isIOS || isDesktopSafari;
onMount(async () => {
// default volume // default volume
const volumeData = localStorage.getItem('volume'); const volumeData = localStorage.getItem('volume');
volume = volumeData ? parseFloat(volumeData) : 0.67; volume = volumeData ? parseFloat(volumeData) : 0.67;
setVolume(volume); setVolume(volume);
// update browser metadata on track changes // set actions for previous/next track media buttons
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
currentSongIndex.subscribe((val) => { navigator.mediaSession.setActionHandler('previoustrack', previousTrack);
if (val != null && !paused) { navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
setMediaMetadata($currentStream.tracks[val]);
}
});
} }
}); });
// update browser metadata on track changes
$effect(() => {
if ('mediaSession' in navigator && ctx.songIndex != null && !paused && ctx.current) {
setMediaMetadata(ctx.current.tracks[ctx.songIndex]);
}
});
function previousTrack() {
if (!ctx.current || ctx.songIndex == null) return;
const tracks = ctx.current.tracks;
const trackStart = tracks[ctx.songIndex][0];
// if more than 3s into the track, restart it; otherwise go to previous
if (audio.currentTime - trackStart > 3 || ctx.songIndex === 0) {
audio.currentTime = currentTime = trackStart;
} else {
audio.currentTime = currentTime = tracks[ctx.songIndex - 1][0];
}
}
function nextTrack() {
if (!ctx.current || ctx.songIndex == null) return;
const tracks = ctx.current.tracks;
if (ctx.songIndex < tracks.length - 1) {
audio.currentTime = currentTime = tracks[ctx.songIndex + 1][0];
}
}
function seek(event, bounds) { function seek(event, bounds) {
let x = event.pageX - 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);
} }
@@ -111,7 +140,7 @@
} }
} }
function setMediaMetadata(track) { function setMediaMetadata(track: Track) {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
artist: track[1], artist: track[1],
title: track[2] title: track[2]
@@ -120,28 +149,32 @@
// browsers don't like if you update this while no media is playing // browsers don't like if you update this while no media is playing
function setMediaMetadataOnPlay() { function setMediaMetadataOnPlay() {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator && ctx.current && ctx.songIndex != null) {
setMediaMetadata($currentStream.tracks[$currentSongIndex]); setMediaMetadata(ctx.current.tracks[ctx.songIndex]);
} }
} }
// workaround for bug https://github.com/sveltejs/svelte/issues/5347 // 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
function updateAudioAttributes(audio) { // also workaround for bug https://github.com/sveltejs/svelte/issues/5914
if (audio && audio.duration) { $effect(() => {
if (!audio) return;
const onLoadedMetadata = () => {
duration = audio.duration; duration = audio.duration;
paused = audio.paused;
setVolume(volume); setVolume(volume);
};
// workaround for bug https://github.com/sveltejs/svelte/issues/5914 if (audio.duration) onLoadedMetadata();
audio.addEventListener('loadedmetadata', (event) => {
paused = audio.paused;
});
}
}
function seekAudio(event) { audio.addEventListener('loadedmetadata', onLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', onLoadedMetadata);
});
function updateSeekVisual(event) {
if (!songBar) return; if (!songBar) return;
audio.currentTime = seek(event, songBar.getBoundingClientRect()) * duration; pendingSeekTime = seek(event, songBar.getBoundingClientRect()) * duration;
} }
function seekVolume(event) { function seekVolume(event) {
@@ -152,67 +185,66 @@
muted = false; muted = false;
} }
function formatSeconds(totalSeconds) { function formatSeconds(totalSeconds, forceHours = false) {
if (isNaN(totalSeconds)) return 'No Data'; if (isNaN(totalSeconds)) return 'No Data';
totalSeconds = parseInt(totalSeconds, 10); totalSeconds = parseInt(totalSeconds, 10);
var hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
var minutes = Math.floor(totalSeconds / 60) % 60; const minutes = Math.floor(totalSeconds / 60) % 60;
var seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return [hours, minutes, seconds] return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v)) .map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0) .filter((v, i) => v !== '00' || i > 0 || forceHours)
.join(':'); .join(':');
} }
function seekTooltip(event) { function seekTooltip(event) {
if (!inlineTooltip) { if (!inlineTooltip) {
let tooltipBounds = tooltip.getBoundingClientRect(); let tooltipBounds = tooltip.getBoundingClientRect();
tooltipX = Math.min(event.pageX + 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);
} }
let bounds = songBar.getBoundingClientRect(); let bounds = songBar.getBoundingClientRect();
let seekValue = ((event.pageX - bounds.left) * duration) / bounds.width; let seekValue = ((event.clientX - bounds.left) * duration) / bounds.width;
let trackArray = $currentStream.tracks[getSongAtTime(seekValue)]; updateTooltipText(seekValue);
}
function updateTooltipText(seekValue: number) {
if (!ctx.current) return;
let trackArray = ctx.current.tracks[ctx.getSongAtTime(seekValue)];
seekTrack = trackArray[1] + ' - ' + trackArray[2]; seekTrack = trackArray[1] + ' - ' + trackArray[2];
seekText = formatSeconds(seekValue); seekText = formatSeconds(seekValue);
} }
function trackMouse(event) { function trackMouse(event) {
if (seeking) seekAudio(event); if (seeking) updateSeekVisual(event);
if (showTooltip && !disableTooltip) seekTooltip(event); if (showTooltip && !disableTooltip) seekTooltip(event);
if (volumeSeeking) seekVolume(event); if (volumeSeeking) seekVolume(event);
} }
run(() => {
updateCurrentSong(currentTime, $currentSongIndex); $effect(() => {
}); const time = currentTime;
run(() => { untrack(() => {
updateAudioAttributes(audio); if (ctx.songIndex != null) {
ctx.updateCurrentSong(time, ctx.songIndex);
}
});
}); });
export {
src,
audio,
paused,
duration,
muted,
volume,
preload,
iconColor,
textColor,
barPrimaryColor,
barSecondaryColor,
backgroundColor,
display,
inlineTooltip,
disableTooltip
};
</script> </script>
<svelte:window <svelte:window
bind:innerWidth bind:innerWidth
bind:innerHeight bind:innerHeight
onmouseup={() => (seeking = volumeSeeking = false)} onmouseup={() => {
if (seeking && pendingSeekTime != null) {
audio.currentTime = pendingSeekTime;
currentTime = pendingSeekTime;
updateTooltipText(pendingSeekTime);
pendingSeekTime = null;
}
seeking = volumeSeeking = false;
}}
onmousemove={trackMouse} onmousemove={trackMouse}
/> />
@@ -231,16 +263,15 @@
</button> </button>
<progress <progress
bind:this={songBar} bind:this={songBar}
value={currentTime ? currentTime : 0} value={seeking && pendingSeekTime != null ? pendingSeekTime : (currentTime || 0)}
max={duration} max={duration}
onmousedown={() => (seeking = true)} onmousedown={(e) => { seeking = true; updateSeekVisual(e); }}
onmouseenter={() => (showTooltip = true)} onmouseenter={() => (showTooltip = true)}
onmouseleave={() => (showTooltip = false)} onmouseleave={() => (showTooltip = false)}
onclick={seekAudio}
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)}/{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"
@@ -297,11 +328,16 @@
bind:currentTime bind:currentTime
{muted} {muted}
onplay={setMediaMetadataOnPlay} onplay={setMediaMetadataOnPlay}
onended={bubble('ended')} {onended}
{preload} {preload}
> >
<source {src} type="audio/ogg;codecs=opus" /> {#if isSafari}
<source src="{src}.mp3" type="audio/mpeg" /> <source src="{src}.mp3" type="audio/mpeg" />
<source {src} type="audio/ogg;codecs=opus" />
{:else}
<source {src} type="audio/ogg;codecs=opus" />
<source src="{src}.mp3" type="audio/mpeg" />
{/if}
</audio> </audio>
<style> <style>
@@ -326,6 +362,9 @@
.control-times { .control-times {
margin: auto; margin: auto;
margin-right: 5px; margin-right: 5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: max-content;
} }
.tooltip { .tooltip {

View File

@@ -1,57 +1,57 @@
<script> <script>
import { currentStream, currentSongIndex } from '$lib/stores.svelte.js'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.js'; import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts';
import { jumpToTrack } from './Player.svelte'; import { jumpToTrack } from './Player.svelte';
import { Carta } from 'carta-md';
import DOMPurify from 'isomorphic-dompurify';
const carta = new Carta({ const ctx = getStreamContext();
sanitizer: DOMPurify.sanitize
});
let formattedStreamDate = $derived(formatDate($currentStream.stream_date)); let formattedStreamDate = $derived(
ctx.current ? formatDate(ctx.current.stream_date) : ''
);
</script> </script>
<svelte:head> <svelte:head>
<title>{formattedStreamDate} | apt-get's auditorium</title> <title>{formattedStreamDate} | apt-get's auditorium</title>
</svelte:head> </svelte:head>
<div class="stream-information"> {#if ctx.current}
<div class="stream-information-flexbox"> <div class="stream-information">
<h3 class="stream-id">ID: {shorthandCode($currentStream.id)}</h3> <div class="stream-information-flexbox">
<h1 class="stream-date">{formattedStreamDate}</h1> <h3 class="stream-id">ID: {shorthandCode(ctx.current.id)}</h3>
<h1 class="stream-date">{formattedStreamDate}</h1>
</div>
<h5 class="stream-tags"><u>Tags</u>: {ctx.current.tags.join(', ')}</h5>
</div> </div>
<h5 class="stream-tags"><u>Tags</u>: {$currentStream.tags.join(', ')}</h5>
</div>
<div class="description-bubble"> <div class="description-bubble">
{@html carta.renderSSR($currentStream.description || 'No description available.')} {@html ctx.current.descriptionHtml}
</div> </div>
<div id="table-container"> <div id="table-container">
<table> <table>
<tbody> <tbody>
<tr><th>Timestamp</th><th>Artist</th><th>Title</th></tr> <tr><th>Timestamp</th><th>Artist</th><th>Title</th></tr>
{#each $currentStream.tracks as track, i} {#each ctx.current.tracks as track, i}
<tr class:current={i == $currentSongIndex}> <tr class:current={i == ctx.songIndex}>
<td class="timestamp-field" <td class="timestamp-field"
><div class="timestamp-field-flex"> ><div class="timestamp-field-flex">
{formatTrackTime(track[0])} {formatTrackTime(track[0])}
<button <button
onclick={() => jumpToTrack(track[0])} onclick={() => jumpToTrack(track[0])}
class="material-icons" class="material-icons"
style="padding: 4px 6px; margin-top: -4px; margin-bottom: -4px; margin-right: -6px" style="padding: 4px 6px; margin-top: -4px; margin-bottom: -4px; margin-right: -6px"
>fast_forward</button >fast_forward</button
> >
</div> </div>
</td> </td>
<td class="artist-field">{track[1]}</td> <td class="artist-field">{track[1]}</td>
<td class="track-field">{track[2]}</td> <td class="track-field">{track[2]}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
{/if}
<style> <style>
.stream-information { .stream-information {

View File

@@ -1,4 +1,3 @@
//import adapter from 'svelte-adapter-bun';
import adapter from 'svelte-adapter-bun'; import adapter from 'svelte-adapter-bun';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';