Files
SoulStream/src/mini/bits/Lane.svelte
T

151 lines
5.0 KiB
Svelte

<script>
// The playbar's scrubber lane, miniaturised: same waveLevels shaping,
// same BAR_PITCH/BAR_W geometry, same colors (unplayed --text-muted,
// played the accent, boundary bar cross-faded by its fractional fill).
// The playbar's hover marker and loading motions stay where they are —
// this lane is for a window a tenth the size.
import { BAR_PITCH, BAR_W, waveLevels } from "../../lib/waveshape.js";
import { mini, seekTo, fmt } from "../mini.svelte.js";
let { slim = false, showTimes = true } = $props();
let canvas = $state(null);
let wrap = $state(null);
let levels = [];
let levelsFor = { wf: null, count: 0 };
const UNPLAYED = "#7a7a7a"; // --text-muted, the lanes' shared grey
const MUTED_RGB = [122, 122, 122];
function accentRgb() {
// --accent is a registered @property (<color>) in global.css, so the
// COMPUTED value comes back as "rgb(r, g, b)" — not the authored hex.
// Parse both forms; fall back to the muted grey, never to black.
const v = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
if (v[0] === "#") {
const n = parseInt(v.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = v.match(/[\d.]+/g);
return m && m.length >= 3 ? [+m[0], +m[1], +m[2]] : MUTED_RGB;
}
export function draw() {
if (!canvas || !wrap) return;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
if (!w) return;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== Math.round(w * dpr)) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const count = Math.max(8, Math.floor((w + (BAR_PITCH - BAR_W)) / BAR_PITCH));
if (levelsFor.wf !== mini.waveform || levelsFor.count !== count) {
levels = waveLevels(mini.waveform, count);
levelsFor = { wf: mini.waveform, count };
}
const mid = h / 2;
const dur = mini.duration || 1;
const exact = (mini.position / dur) * count;
const full = Math.floor(exact);
const acc = accentRgb();
for (let i = 0; i < count; i++) {
const amp = levels.length ? levels[i] : 0.09; // resting dots
const bh = Math.max(2, amp * h);
let color;
if (i < full) color = `rgb(${acc.join(",")})`;
else if (i === full) {
const f = exact - full;
const m = MUTED_RGB.map((c, k) => Math.round(c + (acc[k] - c) * f));
color = `rgb(${m.join(",")})`;
} else color = UNPLAYED;
ctx.beginPath();
ctx.roundRect(i * BAR_PITCH, mid - bh / 2, BAR_W, bh, 1);
ctx.fillStyle = color;
ctx.fill();
}
}
// Redraw on every mirrored change + rAF for the smoothed position.
$effect(() => {
void mini.waveform;
void mini.duration;
let alive = true;
const loop = () => {
if (!alive) return;
draw();
requestAnimationFrame(loop);
};
loop();
return () => (alive = false);
});
function seekAt(e) {
const r = wrap.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const t = f * (mini.duration || 0);
mini.position = t; // optimistic, the tick confirms
seekTo(t);
}
function onDown(e) {
seekAt(e);
const mv = (ev) => seekAt(ev);
const up = () => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
</script>
<div class="lane" class:slim>
{#if showTimes}<span class="time">{fmt(mini.position)}</span>{/if}
<div class="wave-wrap" bind:this={wrap} onpointerdown={onDown}>
<canvas bind:this={canvas}></canvas>
</div>
{#if showTimes}<span class="time">{fmt(mini.duration)}</span>{/if}
</div>
<style>
.lane {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* The playbar's own numbers: 11px secondary, min-width 32, centred. */
.time {
font-size: 11px;
color: var(--text-secondary);
min-width: 32px;
text-align: center;
font-variant-numeric: tabular-nums;
flex: none;
}
.wave-wrap {
position: relative;
flex: 1;
min-width: 0;
height: 24px;
cursor: pointer;
touch-action: none;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.lane.slim .wave-wrap {
height: 16px;
}
.lane.slim .time {
font-size: 9.5px;
min-width: 26px;
}
</style>