151 lines
5.0 KiB
Svelte
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>
|