Compare commits
12 Commits
347438928a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a052c4e68 | |||
| 1c7736f832 | |||
| 64681befad | |||
| 090a145211 | |||
| 1dc3f4505b | |||
| 7febb497e9 | |||
| 6c12cfd49b | |||
| 3aa762d544 | |||
| 8573b82515 | |||
| b47a433414 | |||
| 4d8f7c2b02 | |||
| f777873432 |
4327
package-lock.json
generated
4327
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -15,33 +15,32 @@
|
||||
"generate_iconfont_subset": "cd scripts && bash ./generate_iconfont_subset.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/kit": "^2.17.1",
|
||||
"@types/bun": "^1.2.2",
|
||||
"@types/node": "^20.17.17",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"carta-md": "^4.6.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.2",
|
||||
"fontkit": "^2.0.4",
|
||||
"isomorphic-dompurify": "^2.21.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"sqids": "0.3.0",
|
||||
"svelte": "^5.19.9",
|
||||
"svelte-check": "^4.1.4",
|
||||
"svelte": "^5.53.12",
|
||||
"svelte-check": "^4.4.5",
|
||||
"svelte-select": "^5.8.3",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.14"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"svelte-adapter-bun": "https://github.com/gornostay25/svelte-adapter-bun",
|
||||
"typescript-svelte-plugin": "^0.3.45"
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"typescript-svelte-plugin": "^0.3.50"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"svelte-adapter-bun"
|
||||
|
||||
@@ -8,6 +8,7 @@ fontFile="$(basename "$fontUrl")"
|
||||
unicodePoints=(
|
||||
"61-7a,5f" # ascii lowercase + underscore
|
||||
"e2c4" # file_download
|
||||
"e872" # delete
|
||||
"e037" # play_arrow
|
||||
"e034" # pause
|
||||
"e04f" # volume_off
|
||||
|
||||
@@ -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
46
src/lib/database.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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
39
src/lib/stores.svelte.ts
Normal 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'
|
||||
];
|
||||
66
src/lib/streamCache.svelte.ts
Normal file
66
src/lib/streamCache.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/lib/streamContext.svelte.ts
Normal file
47
src/lib/streamContext.svelte.ts
Normal 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
20
src/lib/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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
38
src/lib/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getStreams } from '$lib/database.js';
|
||||
import { getStreams } from '$lib/database.ts';
|
||||
|
||||
export function load() {
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Sidebar from './Sidebar.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>
|
||||
|
||||
<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="content">{@render children?.()}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentStream, favoritedStreams } from '$lib/stores.svelte.js';
|
||||
import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.js';
|
||||
import { favoritedStreams } from '$lib/stores.svelte.ts';
|
||||
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';
|
||||
|
||||
let { streams } = $props();
|
||||
const ctx = getStreamContext();
|
||||
let filteredTags = $state([]);
|
||||
let favoritesOnly = $state(false);
|
||||
let cachedOnly = $state(false);
|
||||
let listOpen = $state();
|
||||
let displayedStreams = $derived.by(streamsToDisplay);
|
||||
let remainingTags = $derived.by(getRemainingTags);
|
||||
@@ -19,15 +23,15 @@
|
||||
});
|
||||
|
||||
function streamsToDisplay() {
|
||||
return streams.filter(
|
||||
return ctx.streams.filter(
|
||||
(stream) =>
|
||||
!('tags' in stream) || filteredTags.every((tag) => stream['tags'].includes(tag))
|
||||
!('tags' in stream) || filteredTags.every((tag) => stream.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
function getRemainingTags() {
|
||||
let tagCounts = displayedStreams.reduce((tags, stream) => {
|
||||
stream['tags'].forEach((tag) => (tag in tags ? (tags[tag] += 1) : (tags[tag] = 1)));
|
||||
let tagCounts = displayedStreams.reduce((tags: Record<string, number>, stream) => {
|
||||
stream.tags.forEach((tag) => (tags[tag] ? (tags[tag] += 1) : (tags[tag] = 1)));
|
||||
return tags;
|
||||
}, {});
|
||||
return Object.entries(tagCounts)
|
||||
@@ -35,19 +39,23 @@
|
||||
.map((el) => el[0]);
|
||||
}
|
||||
|
||||
function getDisplayedTagsInList(streamTags, remainingTags) {
|
||||
function getDisplayedTagsInList(streamTags: string[], remainingTags: string[]) {
|
||||
return streamTags.filter((tag) => remainingTags.includes(tag));
|
||||
}
|
||||
|
||||
function updateFavorites(stream) {
|
||||
favoritedStreams.has(stream['id'])
|
||||
? favoritedStreams.delete(stream['id'])
|
||||
: favoritedStreams.add(stream['id']);
|
||||
function updateFavorites(stream: StreamSummary) {
|
||||
favoritedStreams.has(stream.id)
|
||||
? favoritedStreams.delete(stream.id)
|
||||
: favoritedStreams.add(stream.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tag-select">
|
||||
<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
|
||||
onclick={() => (favoritesOnly = !favoritesOnly)}
|
||||
class="material-icons {favoritesOnly ? '' : 'un'}select-faves-star">star</button
|
||||
@@ -55,29 +63,28 @@
|
||||
</div>
|
||||
|
||||
<ul class="stream-list">
|
||||
{#each streams as stream}
|
||||
{@const favorited = favoritedStreams.has(stream['id'])}
|
||||
{@const current = $currentStream['id'] === stream['id']}
|
||||
{#each ctx.streams as stream}
|
||||
{@const favorited = favoritedStreams.has(stream.id)}
|
||||
{@const isCached = streamCache.cached.has(stream.id)}
|
||||
{@const current = ctx.current?.id === stream.id}
|
||||
<li
|
||||
hidden={!displayedStreams.includes(stream) || (favoritesOnly && !favorited)}
|
||||
hidden={!displayedStreams.includes(stream) || (favoritesOnly && !favorited) || (cachedOnly && !isCached)}
|
||||
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">
|
||||
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"
|
||||
>{formatSecondsToHms(stream['length_seconds'])}</span
|
||||
>
|
||||
<span class="stream-item-length">{formatSecondsToHms(stream.length_seconds)}</span>
|
||||
|
||||
<p class="stream-item-tags" hidden={!remainingTags.length}>
|
||||
Tags: <span hidden={!filteredTags.length}>[...] </span>{getDisplayedTagsInList(
|
||||
stream['tags'],
|
||||
stream.tags,
|
||||
remainingTags
|
||||
).join(', ')}
|
||||
</p>
|
||||
@@ -91,6 +98,19 @@
|
||||
class="material-icons stream-item-star {favorited ? 'stream-item-star-faved' : ''}"
|
||||
>{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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -168,24 +188,51 @@
|
||||
.select-faves-star {
|
||||
color: rgba(255, 219, 88, 1);
|
||||
font-size: 24px;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.unselect-faves-star {
|
||||
color: #bbbbbb;
|
||||
font-size: 24px;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.filter-download {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.stream-item-star {
|
||||
font-size: 18px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stream-item-star-faved {
|
||||
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 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -202,6 +249,15 @@
|
||||
.stream-item-star {
|
||||
opacity: 0.5;
|
||||
font-size: 24px;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.stream-item-cached {
|
||||
opacity: 0.4;
|
||||
font-size: 24px;
|
||||
bottom: 4px;
|
||||
right: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 items = $state(tagList.map((x) => ({ value: x, label: x })));
|
||||
@@ -58,7 +58,7 @@
|
||||
{#snippet item({ item })}
|
||||
<div class="item">
|
||||
<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}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import { currentStream } from '$lib/stores.svelte.js';
|
||||
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
||||
|
||||
currentStream.set({});
|
||||
const ctx = getStreamContext();
|
||||
ctx.clearCurrent();
|
||||
</script>
|
||||
|
||||
<div class="stream-information">
|
||||
@@ -10,7 +11,12 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Still in construction. The design is responsive now, so phones should work well enough!
|
||||
Needs touch events though.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getStreamInfo } from '$lib/database.js';
|
||||
import { getStreamInfo } from '$lib/database.ts';
|
||||
import { dev } from '$app/environment';
|
||||
import { STREAM_JSON_LOCATION } from '$env/static/private';
|
||||
|
||||
@@ -46,6 +46,10 @@ export function load({ params }) {
|
||||
error(404);
|
||||
}
|
||||
|
||||
result.descriptionHtml = Bun.markdown.html(
|
||||
result.description || 'No description available.'
|
||||
);
|
||||
|
||||
if (dev) {
|
||||
// pass raw JSON for metadata editor
|
||||
const original = JSON.parse(getOriginalJson(params.stream_id));
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import StreamPage from './StreamPage.svelte';
|
||||
import MetadataEditor from './MetadataEditor.svelte';
|
||||
import Player from './Player.svelte';
|
||||
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();
|
||||
const ctx = getStreamContext();
|
||||
let playerSrc = $state<string | null>(null);
|
||||
|
||||
run(() => {
|
||||
updateCurrentStream(data.stream);
|
||||
// reactivity runs on `stream` and `streamCache.cached` here
|
||||
$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>
|
||||
|
||||
@@ -22,8 +33,10 @@
|
||||
<StreamPage />
|
||||
</div>
|
||||
<div id="player">
|
||||
{#key $currentStream}
|
||||
<Player display={true} src="/media/tracks/{$currentStream.filename}" />
|
||||
{#key playerSrc}
|
||||
{#if playerSrc}
|
||||
<Player display={true} src={playerSrc} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { tagList } from '$lib/stores.svelte.js';
|
||||
import { tagList } from '$lib/stores.svelte.ts';
|
||||
|
||||
let { original = $bindable() } = $props();
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
taken from https://github.com/Linkcube/svelte-audio-controls
|
||||
ISC License
|
||||
-->
|
||||
<svelte:options />
|
||||
|
||||
<script module>
|
||||
let getAudio = null;
|
||||
@@ -14,17 +13,12 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { run, createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
currentSongIndex,
|
||||
currentStream,
|
||||
getSongAtTime,
|
||||
updateCurrentSong
|
||||
} from '$lib/stores.svelte.js';
|
||||
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
const ctx = getStreamContext();
|
||||
|
||||
interface Props {
|
||||
src: any;
|
||||
@@ -42,6 +36,7 @@
|
||||
display?: boolean;
|
||||
inlineTooltip?: boolean;
|
||||
disableTooltip?: boolean;
|
||||
onended?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,7 +54,8 @@
|
||||
backgroundColor = 'white',
|
||||
display = false,
|
||||
inlineTooltip = false,
|
||||
disableTooltip = false
|
||||
disableTooltip = false,
|
||||
onended
|
||||
}: Props = $props();
|
||||
|
||||
getAudio = () => {
|
||||
@@ -75,29 +71,62 @@
|
||||
let seekTrack = $state('');
|
||||
let seeking = $state(false);
|
||||
let volumeSeeking = $state(false);
|
||||
let pendingSeekTime = $state<number | null>(null);
|
||||
let songBar = $state();
|
||||
let volumeBar = $state();
|
||||
let innerWidth = $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
|
||||
const volumeData = localStorage.getItem('volume');
|
||||
volume = volumeData ? parseFloat(volumeData) : 0.67;
|
||||
setVolume(volume);
|
||||
|
||||
// update browser metadata on track changes
|
||||
// set actions for previous/next track media buttons
|
||||
if ('mediaSession' in navigator) {
|
||||
currentSongIndex.subscribe((val) => {
|
||||
if (val != null && !paused) {
|
||||
setMediaMetadata($currentStream.tracks[val]);
|
||||
}
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', previousTrack);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
let x = event.pageX - bounds.left;
|
||||
let x = event.clientX - bounds.left;
|
||||
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({
|
||||
artist: track[1],
|
||||
title: track[2]
|
||||
@@ -120,28 +149,32 @@
|
||||
|
||||
// browsers don't like if you update this while no media is playing
|
||||
function setMediaMetadataOnPlay() {
|
||||
if ('mediaSession' in navigator) {
|
||||
setMediaMetadata($currentStream.tracks[$currentSongIndex]);
|
||||
if ('mediaSession' in navigator && ctx.current && ctx.songIndex != null) {
|
||||
setMediaMetadata(ctx.current.tracks[ctx.songIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// workaround for bug https://github.com/sveltejs/svelte/issues/5347
|
||||
// need to init duration & volume after SSR first load
|
||||
function updateAudioAttributes(audio) {
|
||||
if (audio && audio.duration) {
|
||||
// also workaround for bug https://github.com/sveltejs/svelte/issues/5914
|
||||
$effect(() => {
|
||||
if (!audio) return;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration = audio.duration;
|
||||
setVolume(volume);
|
||||
|
||||
// workaround for bug https://github.com/sveltejs/svelte/issues/5914
|
||||
audio.addEventListener('loadedmetadata', (event) => {
|
||||
paused = audio.paused;
|
||||
});
|
||||
}
|
||||
}
|
||||
setVolume(volume);
|
||||
};
|
||||
|
||||
function seekAudio(event) {
|
||||
if (audio.duration) onLoadedMetadata();
|
||||
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
return () => audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
});
|
||||
|
||||
function updateSeekVisual(event) {
|
||||
if (!songBar) return;
|
||||
audio.currentTime = seek(event, songBar.getBoundingClientRect()) * duration;
|
||||
pendingSeekTime = seek(event, songBar.getBoundingClientRect()) * duration;
|
||||
}
|
||||
|
||||
function seekVolume(event) {
|
||||
@@ -152,67 +185,66 @@
|
||||
muted = false;
|
||||
}
|
||||
|
||||
function formatSeconds(totalSeconds) {
|
||||
function formatSeconds(totalSeconds, forceHours = false) {
|
||||
if (isNaN(totalSeconds)) return 'No Data';
|
||||
totalSeconds = parseInt(totalSeconds, 10);
|
||||
var hours = Math.floor(totalSeconds / 3600);
|
||||
var minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
var seconds = totalSeconds % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return [hours, minutes, seconds]
|
||||
.map((v) => (v < 10 ? '0' + v : v))
|
||||
.filter((v, i) => v !== '00' || i > 0)
|
||||
.filter((v, i) => v !== '00' || i > 0 || forceHours)
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function seekTooltip(event) {
|
||||
if (!inlineTooltip) {
|
||||
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);
|
||||
}
|
||||
let bounds = songBar.getBoundingClientRect();
|
||||
let seekValue = ((event.pageX - bounds.left) * duration) / bounds.width;
|
||||
let trackArray = $currentStream.tracks[getSongAtTime(seekValue)];
|
||||
let seekValue = ((event.clientX - bounds.left) * duration) / bounds.width;
|
||||
updateTooltipText(seekValue);
|
||||
}
|
||||
|
||||
function updateTooltipText(seekValue: number) {
|
||||
if (!ctx.current) return;
|
||||
let trackArray = ctx.current.tracks[ctx.getSongAtTime(seekValue)];
|
||||
seekTrack = trackArray[1] + ' - ' + trackArray[2];
|
||||
seekText = formatSeconds(seekValue);
|
||||
}
|
||||
|
||||
function trackMouse(event) {
|
||||
if (seeking) seekAudio(event);
|
||||
if (seeking) updateSeekVisual(event);
|
||||
if (showTooltip && !disableTooltip) seekTooltip(event);
|
||||
if (volumeSeeking) seekVolume(event);
|
||||
}
|
||||
run(() => {
|
||||
updateCurrentSong(currentTime, $currentSongIndex);
|
||||
|
||||
$effect(() => {
|
||||
const time = currentTime;
|
||||
untrack(() => {
|
||||
if (ctx.songIndex != null) {
|
||||
ctx.updateCurrentSong(time, ctx.songIndex);
|
||||
}
|
||||
});
|
||||
run(() => {
|
||||
updateAudioAttributes(audio);
|
||||
});
|
||||
|
||||
export {
|
||||
src,
|
||||
audio,
|
||||
paused,
|
||||
duration,
|
||||
muted,
|
||||
volume,
|
||||
preload,
|
||||
iconColor,
|
||||
textColor,
|
||||
barPrimaryColor,
|
||||
barSecondaryColor,
|
||||
backgroundColor,
|
||||
display,
|
||||
inlineTooltip,
|
||||
disableTooltip
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerWidth
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -231,16 +263,15 @@
|
||||
</button>
|
||||
<progress
|
||||
bind:this={songBar}
|
||||
value={currentTime ? currentTime : 0}
|
||||
value={seeking && pendingSeekTime != null ? pendingSeekTime : (currentTime || 0)}
|
||||
max={duration}
|
||||
onmousedown={() => (seeking = true)}
|
||||
onmousedown={(e) => { seeking = true; updateSeekVisual(e); }}
|
||||
onmouseenter={() => (showTooltip = true)}
|
||||
onmouseleave={() => (showTooltip = false)}
|
||||
onclick={seekAudio}
|
||||
style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}"
|
||||
class="song-progress"
|
||||
></progress>
|
||||
<div class="control-times">{formatSeconds(currentTime)}/{formatSeconds(duration)}</div>
|
||||
<div class="control-times">{formatSeconds(currentTime, duration >= 3600)}/{formatSeconds(duration)}</div>
|
||||
<button
|
||||
style="--icon-color:{iconColor}"
|
||||
class="material-icons"
|
||||
@@ -297,11 +328,16 @@
|
||||
bind:currentTime
|
||||
{muted}
|
||||
onplay={setMediaMetadataOnPlay}
|
||||
onended={bubble('ended')}
|
||||
{onended}
|
||||
{preload}
|
||||
>
|
||||
{#if isSafari}
|
||||
<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>
|
||||
|
||||
<style>
|
||||
@@ -326,6 +362,9 @@
|
||||
.control-times {
|
||||
margin: auto;
|
||||
margin-right: 5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
<script>
|
||||
import { currentStream, currentSongIndex } from '$lib/stores.svelte.js';
|
||||
import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.js';
|
||||
import { getStreamContext } from '$lib/streamContext.svelte.ts';
|
||||
import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts';
|
||||
import { jumpToTrack } from './Player.svelte';
|
||||
import { Carta } from 'carta-md';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
const carta = new Carta({
|
||||
sanitizer: DOMPurify.sanitize
|
||||
});
|
||||
const ctx = getStreamContext();
|
||||
|
||||
let formattedStreamDate = $derived(formatDate($currentStream.stream_date));
|
||||
let formattedStreamDate = $derived(
|
||||
ctx.current ? formatDate(ctx.current.stream_date) : ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{formattedStreamDate} | apt-get's auditorium</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if ctx.current}
|
||||
<div class="stream-information">
|
||||
<div class="stream-information-flexbox">
|
||||
<h3 class="stream-id">ID: {shorthandCode($currentStream.id)}</h3>
|
||||
<h3 class="stream-id">ID: {shorthandCode(ctx.current.id)}</h3>
|
||||
<h1 class="stream-date">{formattedStreamDate}</h1>
|
||||
</div>
|
||||
<h5 class="stream-tags"><u>Tags</u>: {$currentStream.tags.join(', ')}</h5>
|
||||
<h5 class="stream-tags"><u>Tags</u>: {ctx.current.tags.join(', ')}</h5>
|
||||
</div>
|
||||
|
||||
<div class="description-bubble">
|
||||
{@html carta.renderSSR($currentStream.description || 'No description available.')}
|
||||
{@html ctx.current.descriptionHtml}
|
||||
</div>
|
||||
|
||||
<div id="table-container">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Timestamp</th><th>Artist</th><th>Title</th></tr>
|
||||
{#each $currentStream.tracks as track, i}
|
||||
<tr class:current={i == $currentSongIndex}>
|
||||
{#each ctx.current.tracks as track, i}
|
||||
<tr class:current={i == ctx.songIndex}>
|
||||
<td class="timestamp-field"
|
||||
><div class="timestamp-field-flex">
|
||||
{formatTrackTime(track[0])}
|
||||
@@ -52,6 +51,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stream-information {
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,3 @@
|
||||
//import adapter from 'svelte-adapter-bun';
|
||||
import adapter from 'svelte-adapter-bun';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user