Disc picker
A channel picker where each channel is a spinning CD: slideshow label art under a groove-and-glint shader, held still on hover, all on one WebGL context.
Loading preview…
Show code
disc-picker.tsx
"use client";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { prefetchPrioritized } from "@/components/wordminders/disc-picker/disc-art";
import { registerDisc, type DiscHandle } from "@/components/wordminders/disc-picker/disc-gl";
import { cn } from "@/lib/utils";
// A channel picker where every channel is a spinning CD. Each disc's label is
// a slideshow of the channel's own images, cross-fading every 3.8s, under a
// groove shader with a travelling glint. Hover or focus a disc and it stops,
// the way a finger stops a record, so the art you're reading holds still.
export type DiscChannel = {
id: string;
title: string;
/** Image URLs for the label slideshow, in order. Cross-origin art needs CORS. */
slides: string[];
/** ISO date, shown as "Updated Mar. 4, 2026". */
updatedAt?: string;
};
const MONTHS = ["Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec."];
function formatUpdated(iso?: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `Updated ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}
function prefersReducedMotion() {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function Disc({
channel,
index,
active,
paused,
onSelect,
renderMeta,
}: {
channel: DiscChannel;
index: number;
active: boolean;
paused: boolean;
onSelect?: (id: string) => void;
renderMeta?: (channel: DiscChannel, active: boolean) => ReactNode;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleRef = useRef<DiscHandle | null>(null);
const pausedRef = useRef(paused);
const updated = formatUpdated(channel.updatedAt);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handle: DiscHandle = {
canvas,
title: channel.title,
slides: channel.slides,
index,
held: false,
still: pausedRef.current || prefersReducedMotion(),
visible: false,
};
handleRef.current = handle;
const unregister = registerDisc(handle);
// Offscreen discs cost nothing: the shared loop skips them.
const io = new IntersectionObserver(([entry]) => {
handle.visible = entry?.isIntersecting ?? false;
});
io.observe(canvas);
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const onMotion = () => {
handle.still = pausedRef.current || motion.matches;
};
motion.addEventListener("change", onMotion);
return () => {
io.disconnect();
motion.removeEventListener("change", onMotion);
unregister();
handleRef.current = null;
};
}, [channel.title, channel.slides, index]);
// Pausing freezes the disc where it is; it doesn't re-register or reset.
useEffect(() => {
pausedRef.current = paused;
if (handleRef.current) handleRef.current.still = paused || prefersReducedMotion();
}, [paused]);
const hold = (held: boolean) => {
if (handleRef.current) handleRef.current.held = held;
};
return (
<div className="group flex min-w-0 flex-col">
<button
type="button"
aria-label={`Open ${channel.title}`}
aria-current={active || undefined}
onPointerEnter={() => hold(true)}
onPointerLeave={() => hold(false)}
onFocus={() => hold(true)}
onBlur={() => hold(false)}
onClick={() => onSelect?.(channel.id)}
className="relative aspect-square w-full cursor-pointer touch-manipulation rounded-xl outline-none transition-transform duration-150 ease-out active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:active:scale-100"
>
<canvas ref={canvasRef} aria-hidden className="block size-full" />
</button>
<div className="flex flex-col px-2 pt-2 pb-3">
{renderMeta ? (
renderMeta(channel, active)
) : (
<>
<p className="flex min-w-0 items-center gap-1 text-sm text-foreground">
<span className="line-clamp-1 min-w-0 break-words">{channel.title}</span>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden
className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
<path d="M7 17 17 7M17 7H9M17 7v8" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</p>
{active ? (
<p className="text-sm text-muted-foreground">Now playing</p>
) : updated ? (
<p className="text-sm text-muted-foreground">{updated}</p>
) : null}
</>
)}
</div>
</div>
);
}
export function DiscPicker({
channels,
activeId,
onSelect,
title = "Select a disk",
description,
paused = false,
hideTitle = false,
renderMeta,
className,
}: {
channels: DiscChannel[];
/** The playing channel; marked with aria-current and "Now playing". */
activeId?: string;
onSelect?: (id: string) => void;
title?: string;
description?: string;
/** Freeze every disc on its current frame (e.g. while the panel is hidden). */
paused?: boolean;
/** Keep the title for screen readers only (it still names the section). */
hideTitle?: boolean;
/** Replace the title / updated line under each disc. */
renderMeta?: (channel: DiscChannel, active: boolean) => ReactNode;
className?: string;
}) {
const titleId = useId();
// Warm every disc's first slide before any second slide.
useEffect(() => {
void prefetchPrioritized(channels.map((c) => c.slides));
}, [channels]);
return (
<section
aria-labelledby={titleId}
className={cn(
"flex w-full flex-col rounded-3xl bg-background p-6 motion-safe:animate-[disc-picker-in_280ms_cubic-bezier(0.16,1,0.3,1)_both]",
className,
)}
>
<header className={hideTitle ? "sr-only" : "pb-10"}>
<h2 id={titleId} className="m-0 flex flex-col text-lg leading-snug font-semibold sm:text-xl">
<span className="text-foreground">{title}</span>
{description ? <span className="text-muted-foreground">{description}</span> : null}
</h2>
</header>
<div className="grid grid-cols-2 gap-6">
{channels.map((channel, i) => (
<Disc
key={channel.id}
channel={channel}
index={i}
active={channel.id === activeId}
paused={paused}
onSelect={onSelect}
renderMeta={renderMeta}
/>
))}
</div>
</section>
);
}disc-gl.ts
// One WebGL context for every disc on the page. Each disc owns a shader
// material and an art texture; a shared renderer draws them one at a time
// into a scissored corner of its buffer, and each disc copies that corner
// into its own 2D canvas. Ten pickers still cost one GPU context.
import * as THREE from "three";
import { artKey, createArtTexture, createScratch, paintArt, uploadScratch } from "./disc-art";
export const DISC_DEFAULTS = {
/** Resting tilt, degrees. */
rotation: 13,
/** Radians per second while not held. */
spinSpeed: 1,
glistenSpeed: 1.55,
glistenIntensity: 1.5,
rainbowAmount: 0,
highlightSharpness: 96,
};
const vertex = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Ported from the production CD shader; logo decal and dim/fade passes removed.
const fragment = /* glsl */ `
precision highp float;
varying vec2 vUv;
#define TWO_PI 6.28318530718
uniform sampler2D uArt;
uniform float uTime;
uniform float uGlistenIntensity;
uniform float uRainbowAmount;
uniform float uSharpness;
uniform float uDiskPx;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float hash12(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
void main() {
vec2 centered = vUv - 0.5;
float r = length(centered) * 2.0;
float angle = atan(centered.y, centered.x);
if (r > 1.0) discard;
vec3 art = texture2D(uArt, vUv).rgb;
// Groove ripple on the printed art, strongest across the data area.
float pr = 378.0 * r * r + 99.0 * r + 12.0 * sin(angle * 2.62 + uTime * 0.056) + uTime * 0.36;
float beatp = sin(pr) * sin(pr * 1.049 + 0.9);
float irisE = smoothstep(0.33, 0.48, r) * smoothstep(0.76, 0.58, r);
float rimE = smoothstep(0.74, 0.985, r);
float ge = irisE * (1.0 - rimE * 0.9);
art *= 1.0 + (0.5 * sin(pr)) * 0.072 * ge + beatp * 0.035 * ge;
float lum = dot(art, vec3(0.299, 0.587, 0.114));
art = mix(vec3(lum), art, 0.81);
art = pow(max(art, vec3(0.001)), vec3(0.93));
vec2 gCell = floor(vUv * vec2(1600.0));
float gn = hash12(gCell) * 0.65 + hash12(gCell + vec2(31.0, 17.0)) * 0.35;
vec3 color = clamp(art + (gn - 0.5) * 0.062, 0.0, 1.0);
// Aluminium lift at the rim and hub.
vec3 aluminum = vec3(0.86, 0.88, 0.92);
float irisMid = irisE;
float rimW = rimE;
vec3 radialMetal = mix(aluminum, vec3(0.78, 0.80, 0.86), rimW * 0.95);
color = mix(color, radialMetal, clamp(rimW * 0.34 + (1.0 - irisMid) * 0.24, 0.0, 1.0) * 0.74);
// Concentric groove grating with a slight chromatic split.
float angNorm = fract(angle / TWO_PI + 0.5);
float kR = 385.0 + 18.0 * sin(angNorm * TWO_PI * 2.0 + uTime * 0.07);
float ph = kR * r * r + 102.0 * r + 14.0 * sin(angle * 2.65 + uTime * 0.056) + uTime * 0.38;
float s1 = sin(ph);
float s2 = sin(ph * 1.051 + 2.15 + 0.35 * sin(angle * 1.9));
float h2 = 0.5 + 0.5 * sin(ph * 2.04 + 0.55);
float beat = 0.5 + 0.5 * (s1 * s2);
float visBeat = pow(max(beat, 0.0), 0.78);
float grateRegion = irisMid * (1.0 - rimW * 0.88);
float rib = s1;
float ribFine = (h2 - 0.5) * 2.0;
float gStr = grateRegion * (0.72 + 0.28 * visBeat);
color *= 1.0 + rib * 0.1 * gStr + ribFine * 0.038 * gStr;
vec3 cChrom = color;
float chrom = rib * 0.062 * gStr;
cChrom.r = clamp(cChrom.r + chrom, 0.0, 1.0);
cChrom.g = clamp(cChrom.g + chrom * 0.28, 0.0, 1.0);
cChrom.b = clamp(cChrom.b - chrom * 1.02, 0.0, 1.0);
color = mix(color, cChrom, 0.44);
color *= 0.982 + 0.055 * mix(beat, visBeat, 0.5) * grateRegion;
// Optional iridescent zones (off by default).
float cellCoord = angNorm * 6.0 + uTime * 0.0072;
float cellF = fract(cellCoord);
float dwell = pow(max(smoothstep(0.0, 0.32, cellF) * smoothstep(1.0, 0.68, cellF), 1e-4), 0.82);
float arcMask = smoothstep(0.35, 0.95, 0.55 + 0.45 * cos((angNorm - 0.5) * TWO_PI * 1.15 + uTime * 0.055));
float zi = mod(floor(cellCoord), 3.0);
float hueAnchor = mix(0.17, mix(0.48, 0.76, step(2.0, zi)), step(1.0, zi));
float phHue = fract(0.09 + ph * 0.0098 + angNorm * 0.2 + sin(ph * 0.031) * 0.11 + uTime * 0.004);
float hue = fract(mix(hueAnchor, phHue, 0.62) + 0.04 * rib * grateRegion + 0.018 * sin(cellF * TWO_PI));
float speckle = clamp(0.42 * pow(max(abs(s1), 0.08), 0.65) + 0.28 * abs(h2 - 0.5) * 2.0 + 0.35 * visBeat, 0.0, 1.1);
float zoneSat = 0.62 * speckle * dwell * arcMask * grateRegion;
float iridMix = min(1.0, zoneSat * uRainbowAmount * (0.16 + 0.84 * smoothstep(0.08, 0.55, dwell)) * uGlistenIntensity);
color = mix(color, mix(radialMetal, hsv2rgb(vec3(hue, 0.76, 1.0)), 0.58), iridMix);
// Soft bloom that drifts around the data area.
float spread = mix(0.42, 0.78, clamp(uSharpness / 128.0, 0.0, 1.0));
float bloom = (cos((angle * spread + uTime * 0.62) * 1.15) * 0.5 + 0.5) * 0.52
+ (sin(angle * 0.65 + uTime * 0.28) * 0.5 + 0.5) * 0.48;
bloom = smoothstep(0.18, 0.96, pow(max(bloom, 0.0), 1.35));
float bloomMask = (irisMid * 0.92 + 0.05 * (1.0 - rimW) * irisMid)
* smoothstep(0.1, 0.26, r) * (1.0 - smoothstep(0.65, 0.94, r));
color = clamp(color + vec3(1.0, 0.994, 0.99) * bloom * bloomMask * 0.11 * uGlistenIntensity, 0.0, 1.0);
// The specular sweep: the glint that reads as "a CD catching the light".
float specN = clamp(uSharpness * 0.5 + 40.0, 52.0, 120.0);
float ht = angle * 1.72 + uTime * 0.98;
float cs = cos(ht);
float spec = pow(max(cs, 0.0), specN) + pow(max(-cs, 0.0), specN * 0.8) * 0.42
+ pow(max(cos(ht + 0.67), 0.0), specN * 1.02) * 0.36;
float specZone = irisMid * (0.58 + 0.32 * (1.0 - rimW * 0.95)) * (1.0 - smoothstep(0.76, 0.96, r));
vec3 specTint = mix(vec3(1.0, 0.996, 0.992), vec3(0.78, 0.84, 0.96), 0.22);
color = clamp(color + specTint * spec * specZone * 0.62 * uGlistenIntensity, 0.0, 1.0);
// A 1px rim stroke, measured in screen pixels.
float dr = 2.0 / max(uDiskPx, 1.0);
float aa = max(dr * 0.55, 0.00035);
float strokeMask = smoothstep(1.0 - dr - aa, 1.0 - aa * 0.3, r) * step(r, 1.001);
vec3 strokeRgb = mix(vec3(0.0), vec3(0.17, 0.17, 0.18), clamp(vUv.x * 0.68 + vUv.y * 0.32, 0.0, 1.0));
color = mix(color, strokeRgb, strokeMask);
gl_FragColor = vec4(color, smoothstep(1.008, 0.988, r));
}
`;
export type DiscHandle = {
canvas: HTMLCanvasElement;
title: string;
slides: string[];
index: number;
/** True while hovered or focused: the disc stops, like a finger on a record. */
held: boolean;
/** Frozen: no spin, no slideshow, no glisten drift (reduced motion, paused). */
still: boolean;
visible: boolean;
};
type Entry = {
handle: DiscHandle;
material: THREE.ShaderMaterial;
art: THREE.DataTexture;
scratch: HTMLCanvasElement;
rotation: number;
time: number;
lastKey: string | null;
lastDrawKey: string | null;
};
type Shared = {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.OrthographicCamera;
mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>;
entries: Set<Entry>;
raf: number;
last: number;
bufW: number;
bufH: number;
};
/** Seconds of slideshow offset between neighbouring discs. */
const SLIDE_STAGGER_SEC = 0.12;
const MAX_DPR = 2;
let shared: Shared | null = null;
function createShared(): Shared {
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: false,
premultipliedAlpha: false,
powerPreference: "high-performance",
});
renderer.setClearColor(0x000000, 0);
renderer.setScissorTest(true);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 1, 1, 0, -10, 10);
camera.position.z = 1;
const placeholder = new THREE.ShaderMaterial();
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), placeholder);
scene.add(mesh);
return { renderer, scene, camera, mesh, entries: new Set(), raf: 0, last: 0, bufW: 0, bufH: 0 };
}
function frame(now: number) {
const s = shared;
if (!s) return;
const dt = s.last ? Math.min((now - s.last) / 1000, 0.05) : 0;
s.last = now;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
for (const e of s.entries) {
const h = e.handle;
if (!h.visible) continue;
const w = Math.max(1, h.canvas.clientWidth);
const hh = Math.max(1, h.canvas.clientHeight);
if (!h.still) {
e.time += dt;
if (!h.held) e.rotation += dt * DISC_DEFAULTS.spinSpeed;
}
const key = artKey(h.title, h.slides, e.time + h.index * SLIDE_STAGGER_SEC);
if (key !== e.lastKey) {
e.lastKey = key;
paintArt(e.scratch, h.title, h.slides, e.time + h.index * SLIDE_STAGGER_SEC);
uploadScratch(e.scratch, e.art);
}
// A still disc only redraws when its art or size changes.
const drawKey = h.still ? `${key}|${w}x${hh}|${dpr}` : null;
if (drawKey && drawKey === e.lastDrawKey) continue;
e.lastDrawKey = drawKey;
// Grow-only shared buffer, sized to the largest disc on the page.
if (w > s.bufW || hh > s.bufH || s.renderer.getPixelRatio() !== dpr) {
s.bufW = Math.max(s.bufW, w);
s.bufH = Math.max(s.bufH, hh);
s.renderer.setPixelRatio(dpr);
s.renderer.setSize(s.bufW, s.bufH, false);
}
const size = Math.min(w, hh) * 0.92;
const u = e.material.uniforms;
u.uTime.value = e.time * DISC_DEFAULTS.glistenSpeed + h.index * 1.37;
u.uDiskPx.value = size * dpr;
s.mesh.material = e.material;
s.mesh.position.set(w / 2, hh / 2, 0);
s.mesh.scale.set(size, size, 1);
s.mesh.rotation.z = (DISC_DEFAULTS.rotation * Math.PI) / 180 + e.rotation;
s.camera.right = w;
s.camera.top = hh;
s.camera.updateProjectionMatrix();
s.renderer.setViewport(0, 0, w, hh);
s.renderer.setScissor(0, 0, w, hh);
s.renderer.clear();
s.renderer.render(s.scene, s.camera);
// Copy the bottom-left corner (GL origin) into the disc's own canvas.
const pw = Math.round(w * dpr);
const ph = Math.round(hh * dpr);
if (h.canvas.width !== pw || h.canvas.height !== ph) {
h.canvas.width = pw;
h.canvas.height = ph;
}
const ctx = h.canvas.getContext("2d");
if (!ctx) continue;
const src = s.renderer.domElement;
ctx.clearRect(0, 0, pw, ph);
ctx.drawImage(src, 0, src.height - ph, pw, ph, 0, 0, pw, ph);
}
s.raf = requestAnimationFrame(frame);
}
/** Register a disc with the shared renderer; returns an unregister function. */
export function registerDisc(handle: DiscHandle): () => void {
shared ??= createShared();
const s = shared;
const art = createArtTexture();
const material = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
depthTest: false,
depthWrite: false,
uniforms: {
uArt: { value: art },
uTime: { value: 0 },
uGlistenIntensity: { value: DISC_DEFAULTS.glistenIntensity },
uRainbowAmount: { value: DISC_DEFAULTS.rainbowAmount },
uSharpness: { value: DISC_DEFAULTS.highlightSharpness },
uDiskPx: { value: 256 },
},
});
const entry: Entry = {
handle,
material,
art,
scratch: createScratch(),
rotation: 0,
time: 0,
lastKey: null,
lastDrawKey: null,
};
s.entries.add(entry);
if (!s.raf) {
s.last = 0;
s.raf = requestAnimationFrame(frame);
}
return () => {
s.entries.delete(entry);
material.dispose();
art.dispose();
if (s.entries.size === 0) {
cancelAnimationFrame(s.raf);
s.mesh.geometry.dispose();
s.renderer.dispose();
shared = null;
}
};
}disc-art.ts
// Slideshow artwork for one disc: hold on a frame, cross-fade to the next,
// painted into a 512² scratch canvas and copied into a DataTexture. Bitmaps
// load into one shared cache, first frame of every disc before the rest, so
// every disc shows art before any disc gets its second slide.
import * as THREE from "three";
export const ART_SIZE = 512;
export const SLIDE_HOLD_SEC = 2.8;
export const SLIDE_CROSS_SEC = 1.0;
export type BitmapCache = Map<string, ImageBitmap | null>;
/** One cache per page, so remounting a picker reuses warmed bitmaps. */
export const bitmapCache: BitmapCache = new Map();
/** Deterministic neutral grey for discs with no art, or while their art loads. */
export function fallbackGrey(str: string, offset = 0): string {
let h = 0;
for (let i = 0; i < str.length; i++) h = str.charCodeAt(i) + ((h << 5) - h);
return `hsl(0 0% ${22 + (((h + offset * 7) % 8) + 8) % 8}%)`;
}
/**
* Strict CORS: the scratch canvas is read back with getImageData, so a
* tainted image would throw. Cross-origin art must be served with CORS.
*/
function loadBitmap(url: string): Promise<ImageBitmap | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
createImageBitmap(img).then(resolve, () => resolve(null));
};
img.onerror = () => resolve(null);
img.src = url;
});
}
export async function prefetchBitmaps(urls: string[], cache = bitmapCache, concurrency = 16) {
const pending = [...new Set(urls)].filter((u) => !cache.has(u));
if (pending.length === 0) return;
let cursor = 0;
async function worker() {
while (cursor < pending.length) {
const url = pending[cursor++]!;
cache.set(url, await loadBitmap(url));
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
}
/** First frame of every disc, then everything else. */
export async function prefetchPrioritized(slides: string[][], cache = bitmapCache) {
const primaries = [...new Set(slides.map((s) => s[0]).filter(Boolean) as string[])];
await prefetchBitmaps(primaries, cache);
void prefetchBitmaps(slides.flat(), cache);
}
export function createArtTexture(): THREE.DataTexture {
const tex = new THREE.DataTexture(
new Uint8Array(ART_SIZE * ART_SIZE * 4),
ART_SIZE,
ART_SIZE,
THREE.RGBAFormat,
THREE.UnsignedByteType,
);
// No colour-space decode: the shader writes straight to an sRGB canvas, so
// decoding here would show every label darker than its source image.
tex.colorSpace = THREE.NoColorSpace;
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearFilter;
tex.flipY = true;
tex.generateMipmaps = false;
tex.needsUpdate = true;
return tex;
}
export function createScratch(): HTMLCanvasElement {
const c = document.createElement("canvas");
c.width = ART_SIZE;
c.height = ART_SIZE;
return c;
}
export function uploadScratch(scratch: HTMLCanvasElement, tex: THREE.DataTexture) {
const ctx = scratch.getContext("2d", { willReadFrequently: true });
const dst = tex.image.data as Uint8Array | null;
if (!ctx || !dst) return;
dst.set(ctx.getImageData(0, 0, ART_SIZE, ART_SIZE).data);
tex.needsUpdate = true;
}
function drawCover(ctx: CanvasRenderingContext2D, bmp: ImageBitmap) {
const s = Math.max(ART_SIZE / bmp.width, ART_SIZE / bmp.height);
const w = bmp.width * s;
const h = bmp.height * s;
ctx.drawImage(bmp, (ART_SIZE - w) / 2, (ART_SIZE - h) / 2, w, h);
}
/** Which slide pair is on screen at time t, and how far the cross-fade is. */
function slideAt(n: number, t: number) {
const seg = SLIDE_HOLD_SEC + SLIDE_CROSS_SEC;
const cyc = Math.max(0, t) % (n * seg);
const idx = Math.floor(cyc / seg);
const local = cyc - idx * seg;
const blend = local >= SLIDE_HOLD_SEC ? Math.min(1, (local - SLIDE_HOLD_SEC) / SLIDE_CROSS_SEC) : 0;
return { from: idx % n, to: (idx + 1) % n, blend };
}
/**
* Changes exactly when the painted pixels would: slide pair, blend, or a
* bitmap arriving. Comparing it per frame means a disc on hold re-uploads
* nothing, and art that loads late appears on the next frame.
*/
export function artKey(title: string, urls: string[], t: number, cache = bitmapCache): string {
let loaded = "";
for (const u of urls) loaded += !cache.has(u) ? "m" : cache.get(u) ? "1" : "0";
if (urls.length === 0) return `q:${title}`;
if (urls.length === 1) return `1:${loaded}`;
const { from, to, blend } = slideAt(urls.length, t);
return `${from}|${to}|${blend}|${loaded}`;
}
export function paintArt(
scratch: HTMLCanvasElement,
title: string,
urls: string[],
t: number,
cache = bitmapCache,
) {
const ctx = scratch.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const half = ART_SIZE / 2;
if (urls.length === 0) {
for (let i = 0; i < 4; i++) {
ctx.fillStyle = fallbackGrey(title, i);
ctx.fillRect(i % 2 ? half : 0, i < 2 ? 0 : half, half, half);
}
return;
}
const { from, to, blend } = urls.length === 1 ? { from: 0, to: 0, blend: 0 } : slideAt(urls.length, t);
const a = cache.get(urls[from]!) ?? null;
const b = cache.get(urls[to]!) ?? null;
ctx.globalAlpha = 1;
ctx.clearRect(0, 0, ART_SIZE, ART_SIZE);
if (a) drawCover(ctx, a);
else {
ctx.fillStyle = fallbackGrey(title, from);
ctx.fillRect(0, 0, ART_SIZE, ART_SIZE);
}
if (blend > 0 && b) {
ctx.globalAlpha = blend;
drawCover(ctx, b);
ctx.globalAlpha = 1;
}
}