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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423<script lang="ts">import { settings } from "../app/settings.svelte.js";import { player, visualizer } from "./player.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 tracePath( ctx: CanvasRenderingContext2D, points: Array<[number, number]>, smooth: boolean, anchored: boolean,) { if (!smooth) { ctx.moveTo(points[0][0], points[0][1]); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i][0], points[i][1]); } ctx.closePath(); return; } const midPoint = ( a: [number, number], b: [number, number], ): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; if (anchored) { const last = points.length - 1; ctx.moveTo(points[0][0], points[0][1]); for (let i = 1; i < last; i++) { const current = points[i]; const next = midPoint(current, points[i + 1]); ctx.quadraticCurveTo(current[0], current[1], next[0], next[1]); } ctx.lineTo(points[last][0], points[last][1]); ctx.closePath(); return; } const start = midPoint(points[points.length - 1], points[0]); ctx.moveTo(start[0], start[1]); for (let i = 0; i < points.length; i++) { const current = points[i]; const next = midPoint(current, points[(i + 1) % points.length]); ctx.quadraticCurveTo(current[0], current[1], next[0], next[1]); } ctx.closePath();}
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") { const points: Array<[number, number]> = []; 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; points.push([baseline, 0]); for (let i = 0; i < barCount; i++) { const barWidth = Math.max(2, (data[i] / 255) * w * size); points.push([ placement === "start" ? barWidth : w - barWidth, offset + i * barHeight, ]); } points.push([baseline, h]); } else { points.push([centerLineX, 0]); for (let i = 0; i < barCount; i++) { const barWidth = Math.max(2, (data[i] / 255) * w * size); points.push([centerLineX - barWidth / 2, offset + i * barHeight]); } for (let i = barCount - 1; i >= 0; i--) { const barWidth = Math.max(2, (data[i] / 255) * w * size); points.push([centerLineX + barWidth / 2, offset + i * barHeight]); } } } else { const barWidth = w / segmentCount; const offset = barCount === 1 ? barWidth / 2 : 0; if (placement === "center") { points.push([0, centerLineY]); for (let i = 0; i < barCount; i++) { const barHeight = Math.max(2, (data[i] / 255) * h * size); points.push([offset + i * barWidth, centerLineY - barHeight / 2]); } for (let i = barCount - 1; i >= 0; i--) { const barHeight = Math.max(2, (data[i] / 255) * h * size); points.push([offset + i * barWidth, centerLineY + barHeight / 2]); } } else { const baseline = placement === "start" ? 0 : h; points.push([0, baseline]); for (let i = 0; i < barCount; i++) { const barHeight = Math.max(2, (data[i] / 255) * h * size); points.push([ offset + i * barWidth, placement === "start" ? barHeight : h - barHeight, ]); } points.push([w, baseline]); } } tracePath( ctx, points, settings.visualizerRounding, placement === "start" || placement === "end", ); } else { const gap = barCount > 64 ? 0 : 2; if (orientation === "vertical") { const barHeight = h / barCount; 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; 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: inherit;
&.background { position: fixed; }}</style>