Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231<script lang="ts">import { isRadio } from "../api/client.svelte.js";import { offlineManager } from "../offline/offlineManager.svelte.js";import { player, waveformBuffer, waveformUrl } from "./player.svelte.js";
const WAVEFORM_BINS = 512;
const MAX_ENTRIES = 8;
const peaksCache = new Map<string, Float32Array>();
const getPeaks = (id: string) => peaksCache.get(id);
const setPeaks = (id: string, values: Float32Array) => { peaksCache.delete(id); peaksCache.set(id, values); if (peaksCache.size > MAX_ENTRIES) { const oldest = peaksCache.keys().next(); if (!oldest.done) peaksCache.delete(oldest.value); }};
const computePeaks = (buffer: AudioBuffer): Float32Array => { const result = new Float32Array(WAVEFORM_BINS); if (buffer.length === 0) return result; const channels = Math.min(buffer.numberOfChannels, 2); const data: Float32Array[] = []; for (let c = 0; c < channels; c++) data.push(buffer.getChannelData(c)); const block = buffer.length / WAVEFORM_BINS; for (let i = 0; i < WAVEFORM_BINS; i++) { const start = Math.floor(i * block); const end = Math.min(buffer.length, Math.floor((i + 1) * block)); const stride = Math.max(1, Math.floor((end - start) / 64)); let max = 0; for (let j = start; j < end; j += stride) { for (let c = 0; c < channels; c++) { const value = Math.abs(data[c][j]); if (value > max) max = value; } } result[i] = max; } const sorted = result.slice().sort(); const ceiling = sorted[Math.floor((sorted.length - 1) * 0.95)]; if (ceiling <= 0) return result; const gain = ceiling < 1 ? 1 / ceiling : 1; for (let i = 0; i < result.length; i++) { result[i] = Math.min(1, result[i] * gain); } return result;};
let canvasEl = $state<HTMLCanvasElement | undefined>();let clientWidth = $state(0);let clientHeight = $state(0);
const dpr = globalThis.devicePixelRatio || 1;let width = $derived(Math.round(clientWidth * dpr));let height = $derived(Math.round(clientHeight * dpr));
let peaks = $state<Float32Array | null>(null);let failed = $state(false);let canvasColor = $state("");
const progress = $derived( player.duration > 0 ? Math.max(0, Math.min(1, player.time / player.duration)) : 0,);
const waveformTrack = $derived( player.track && !isRadio(player.track) ? player.track : null,);
const PEAK_FLOOR = 0.125;const PLACEHOLDER_HEIGHT = 0.25;const DECODE_SAMPLE_RATE = 8000;
$effect(() => { const track = waveformTrack; if (!track) { peaks = null; failed = false; return; } const id = track.id; const cached = getPeaks(id); if (cached) { peaks = cached; return; } const buffered = waveformBuffer(id); if (buffered) { const computed = computePeaks(buffered); setPeaks(id, computed); peaks = computed; return; } let cancelled = false; const controller = new AbortController(); peaks = null; failed = false; void (async () => { let localUrl: string | null = null; try { localUrl = await offlineManager.getAudioUrl(track); if (cancelled) return; const response = await fetch(localUrl ?? waveformUrl(id), { signal: controller.signal, }); if (!response.ok) throw new Error(`fetch failed with status ${response.status}`); const data = await response.arrayBuffer(); if (cancelled) return; const decoded = await new OfflineAudioContext( 1, 1, DECODE_SAMPLE_RATE, ).decodeAudioData(data); if (cancelled) return; const computed = computePeaks(decoded); setPeaks(id, computed); peaks = computed; } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; if (!cancelled) { failed = true; console.warn("failed to load waveform:", err); } } finally { if (localUrl) URL.revokeObjectURL(localUrl); } })(); return () => { cancelled = true; controller.abort(); };});
$effect(() => { const canvas = canvasEl; const sync = () => { canvasColor = canvas ? getComputedStyle(canvas).color : ""; }; sync(); const observer = new MutationObserver(sync); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class"], }); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", sync); return () => { observer.disconnect(); scheme.removeEventListener("change", sync); };});
$effect(() => { const canvas = canvasEl; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const current = peaks; const w = width; const h = height; if (w <= 0 || h <= 0) return; if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } ctx.clearRect(0, 0, w, h); ctx.fillStyle = canvasColor; ctx.globalAlpha = 0.25; const paintSplit = (paint: () => void) => { paint(); ctx.save(); ctx.beginPath(); ctx.rect(0, 0, progress * w, h); ctx.clip(); ctx.globalAlpha = 1; paint(); ctx.restore(); }; if (!current) { if (!failed) { const slab = () => ctx.fillRect(0, h - PLACEHOLDER_HEIGHT * h, w, PLACEHOLDER_HEIGHT * h); paintSplit(slab); } return; } const paintWave = () => { const count = current.length; const step = w / (count - 1); ctx.beginPath(); ctx.moveTo(0, h); for (let i = 0; i < count; i++) { const peakHeight = Math.max(PEAK_FLOOR, current[i]) * h; ctx.lineTo(i * step, h - peakHeight); } ctx.lineTo(w, h); ctx.closePath(); ctx.fill(); }; paintSplit(paintWave);});</script>
<canvas aria-hidden="true" class="waveform-overlay" class:dimmed={!waveformTrack} bind:this={canvasEl} bind:clientWidth bind:clientHeight></canvas>
<style>.waveform-overlay { display: block; grid-area: 1 / 1; inline-size: 100%; block-size: 0.75rem; pointer-events: none;}.dimmed { opacity: 0.5;}</style>