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.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381<script lang="ts">import { player, visualizer } from "./player.svelte.js";import { settings } from "./settings.svelte.js";
let canvasEl = $state<HTMLCanvasElement>();let clientWidth = $state(0);let clientHeight = $state(0);
const dpr = globalThis.devicePixelRatio || 1;let width = $derived(clientWidth * dpr);let height = $derived(clientHeight * dpr);
const spectrumMinHz = 30;const spectrumMaxHz = 18_000;const spectrumMinDb = -90;const spectrumMaxDb = -20;const spectrumTiltDbPerOctave = 3;
type SpectrumBand = { start: number; end: number; tilt: number;};
type SpectrumBuffers = { frequencies: Float32Array<ArrayBuffer>; values: Uint8Array<ArrayBuffer>; bands: SpectrumBand[]; powerSums: Float64Array<ArrayBuffer>;};
const createSpectrumBands = ( binCount: number, bandCount: number, sampleRate: number,) => { const binWidth = sampleRate / (binCount * 2); const frequencyRatio = Math.min(spectrumMaxHz, sampleRate / 2) / spectrumMinHz;
return Array.from({ length: bandCount }, (_, index): SpectrumBand => { const lowHz = spectrumMinHz * frequencyRatio ** (index / bandCount); const highHz = spectrumMinHz * frequencyRatio ** ((index + 1) / bandCount); const start = Math.min( binCount - 1, Math.max(1, Math.floor(lowHz / binWidth)), );
return { start, end: Math.min( binCount, Math.max(start + 1, Math.ceil(highHz / binWidth)), ), tilt: spectrumTiltDbPerOctave * Math.log2(Math.sqrt(lowHz * highHz) / 1000), }; });};
const fillSpectrum = ({ frequencies, bands, powerSums, values,}: SpectrumBuffers) => { powerSums[0] = 0; for (let index = 0; index < frequencies.length; index++) { powerSums[index + 1] = powerSums[index] + (Number.isFinite(frequencies[index]) ? 10 ** (frequencies[index] / 10) : 0); }
for (let index = 0; index < bands.length; index++) { const { start, end, tilt } = bands[index]; const averagePower = (powerSums[end] - powerSums[start]) / (end - start); const normalized = (10 * Math.log10(averagePower) + tilt - spectrumMinDb) / (spectrumMaxDb - spectrumMinDb); values[index] = Math.round(255 * Math.max(0, Math.min(1, normalized))); }};
function drawVisualizer( ctx: CanvasRenderingContext2D, w: number, h: number, data: Uint8Array | null, color: string,) { ctx.clearRect(0, 0, w, h); const barCount = settings.visualizerBins; const placement = settings.visualizerPlacement; const orientation = settings.visualizerOrientation; const size = settings.visualizerSize; const style = settings.visualizerStyle;
let centerLineY = h / 2; if (settings.visualizerCenterText) { const infoEl = ctx.canvas.parentElement?.querySelector<HTMLElement>(".track-info"); if (infoEl) { const infoRect = infoEl.getBoundingClientRect(); centerLineY = (infoRect.top - ctx.canvas.getBoundingClientRect().top + infoRect.height / 2) * dpr; } } const centerLineX = w / 2;
ctx.fillStyle = color; ctx.globalAlpha = data ? 1 : 0.4; ctx.beginPath();
if (data) { const segmentCount = Math.max(1, barCount - 1);
if (style === "oscilloscope") { if (orientation === "vertical") { const barHeight = h / segmentCount; const val0 = data[0]; let x0 = centerLineX + ((val0 - 128) / 128) * w * 0.5 * size; if (placement === "start") x0 = (val0 / 255) * w * size; else if (placement === "end") x0 = w - (val0 / 255) * w * size; ctx.moveTo(x0, 0); if (barCount === 1) ctx.lineTo(x0, h); for (let i = 1; i < barCount; i++) { const val = data[((i / segmentCount) * (data.length - 1)) | 0]; let x = centerLineX + ((val - 128) / 128) * w * 0.5 * size; if (placement === "start") x = (val / 255) * w * size; else if (placement === "end") x = w - (val / 255) * w * size; ctx.lineTo(x, i * barHeight); } } else { const barWidth = w / segmentCount; const val0 = data[0]; let y0 = centerLineY + ((val0 - 128) / 128) * h * 0.5 * size; if (placement === "start") y0 = (val0 / 255) * h * size; else if (placement === "end") y0 = h - (val0 / 255) * h * size; ctx.moveTo(0, y0); if (barCount === 1) ctx.lineTo(w, y0); for (let i = 1; i < barCount; i++) { const val = data[((i / segmentCount) * (data.length - 1)) | 0]; let y = centerLineY + ((val - 128) / 128) * h * 0.5 * size; if (placement === "start") y = (val / 255) * h * size; else if (placement === "end") y = h - (val / 255) * h * size; ctx.lineTo(i * barWidth, y); } } } else if (style === "wave") { if (orientation === "vertical") { const barHeight = h / segmentCount; const offset = barCount === 1 ? barHeight / 2 : 0; if (placement === "start" || placement === "end") { const baseline = placement === "start" ? 0 : w; ctx.moveTo(baseline, 0); for (let i = 0; i < barCount; i++) { const barWidth = Math.max(2, (data[i] / 255) * w * size); ctx.lineTo( placement === "start" ? barWidth : w - barWidth, offset + i * barHeight, ); } ctx.lineTo(baseline, h); ctx.closePath(); } else { ctx.moveTo(centerLineX, 0); for (let i = 0; i < barCount; i++) { const barWidth = Math.max(2, (data[i] / 255) * w * size); ctx.lineTo(centerLineX - barWidth / 2, offset + i * barHeight); } for (let i = barCount - 1; i >= 0; i--) { const barWidth = Math.max(2, (data[i] / 255) * w * size); ctx.lineTo(centerLineX + barWidth / 2, offset + i * barHeight); } ctx.closePath(); } } else { const barWidth = w / segmentCount; const offset = barCount === 1 ? barWidth / 2 : 0; if (placement === "center") { ctx.moveTo(0, centerLineY); for (let i = 0; i < barCount; i++) { const barHeight = Math.max(2, (data[i] / 255) * h * size); ctx.lineTo(offset + i * barWidth, centerLineY - barHeight / 2); } for (let i = barCount - 1; i >= 0; i--) { const barHeight = Math.max(2, (data[i] / 255) * h * size); ctx.lineTo(offset + i * barWidth, centerLineY + barHeight / 2); } ctx.closePath(); } else { const baseline = placement === "start" ? 0 : h; ctx.moveTo(0, baseline); for (let i = 0; i < barCount; i++) { const barHeight = Math.max(2, (data[i] / 255) * h * size); ctx.lineTo( offset + i * barWidth, placement === "start" ? barHeight : h - barHeight, ); } ctx.lineTo(w, baseline); ctx.closePath(); } } } else { if (orientation === "vertical") { const barHeight = h / barCount; const gap = barCount > 64 ? 0 : 2; for (let i = 0; i < barCount; i++) { const barWidth = Math.max(2, (data[i] / 255) * w * size);
let x = centerLineX - barWidth / 2; if (placement === "start") { x = 0; } else if (placement === "end") { x = w - barWidth; }
ctx.rect( x, i * barHeight + gap / 2, barWidth, Math.max(1, barHeight - gap), ); } } else { const barWidth = w / barCount; const gap = barCount > 64 ? 0 : 2; for (let i = 0; i < barCount; i++) { const barHeight = Math.max(2, (data[i] / 255) * h * size);
let y = h - barHeight; if (placement === "center") { y = centerLineY - barHeight / 2; } else if (placement === "start") { y = 0; }
ctx.rect( i * barWidth + gap / 2, y, Math.max(1, barWidth - gap), barHeight, ); } } } } else { if (orientation === "vertical") { let x = centerLineX - 1; if (placement === "start") { x = 0; } else if (placement === "end") { x = w - 2; } ctx.rect(x, 0, 2, h); } else { let y = h - 2; if (placement === "center") { y = centerLineY - 1; } else if (placement === "start") { y = 0; } ctx.rect(0, y, w, 2); } }
if (style === "oscilloscope") { ctx.strokeStyle = color; ctx.lineWidth = settings.visualizerStroke * dpr; ctx.stroke(); } else { ctx.fill(); } ctx.globalAlpha = 1;}
$effect(() => { if (!canvasEl) return; const canvas = canvasEl; const ctx = canvas.getContext("2d"); if (!ctx) return;
let timeData: Uint8Array<ArrayBuffer> | null = null; let spectrum: SpectrumBuffers | null = null; let frame = 0;
const getColor = () => settings.visualizerBlendMode === "normal" ? getComputedStyle(canvas).color : "#FFFFFF";
const render = () => { const analyser = visualizer.analyser; if (!analyser) return;
if (player.paused) { drawVisualizer(ctx, width, height, null, getColor()); return; }
frame = requestAnimationFrame(render); if (settings.visualizerStyle === "oscilloscope") { if (!timeData || timeData.length !== analyser.frequencyBinCount) { timeData = new Uint8Array(analyser.frequencyBinCount); } analyser.getByteTimeDomainData(timeData); drawVisualizer(ctx, width, height, timeData, getColor()); } else { if ( !spectrum || spectrum.frequencies.length !== analyser.frequencyBinCount || spectrum.values.length !== settings.visualizerBins ) { spectrum = { frequencies: new Float32Array(analyser.frequencyBinCount), values: new Uint8Array(settings.visualizerBins), bands: createSpectrumBands( analyser.frequencyBinCount, settings.visualizerBins, analyser.context.sampleRate, ), powerSums: new Float64Array(analyser.frequencyBinCount + 1), }; } analyser.getFloatFrequencyData(spectrum.frequencies); fillSpectrum(spectrum); drawVisualizer(ctx, width, height, spectrum.values, getColor()); } };
drawVisualizer(ctx, width, height, null, getColor()); if (!player.paused && visualizer.analyser) render();
return () => { if (frame) cancelAnimationFrame(frame); };});</script>
<canvas aria-hidden="true" bind:this={canvasEl} bind:clientWidth bind:clientHeight {width} {height} class:background={settings.visualizerLocation === "background"} style:opacity={player.paused ? 0 : settings.visualizerOpacity} style:z-index={settings.visualizerOverlay ? 10 : settings.visualizerBlendMode === "normal" ? 0 : settings.visualizerLocation === "background" ? 2 : settings.visualizerLocation === "now-playing" ? 1 : 0} style:mix-blend-mode={settings.visualizerBlendMode}></canvas>
<style>canvas { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; pointer-events: none; color: var(--text);
&.background { position: fixed; }}</style>