Files
pl8list-rewind/web/src/pager.ts
T
nvrl b4470850bf Rebuild front end in TypeScript with a pager, crossfades and reactive visuals
The front end moves to web/ (Vite, TypeScript, no framework) and is built
into web/dist, which the server embeds. hls.js is bundled from npm and
loaded on demand instead of vendored.

The feed is a custom pager instead of CSS scroll snapping: drag and flick
with a spring that keeps the finger's speed, a rubber band at the ends,
one page per wheel or trackpad gesture, and only three cards in the DOM.

Playback uses two decks. The next track is buffered shortly after the
current one starts and tracks crossfade through a Web Audio graph; on iOS,
where that would break lock-screen playback, they switch with a cut. An
analyser drives a bass pulse of the artwork and its glow, and each card
takes its accent colour from the artist image. Styles start from design
tokens and respect reduced motion.

Docker gets a Node build stage; legal pages use their own small stylesheet.
2026-11-01 12:57:17 +01:00

233 lines
8.1 KiB
TypeScript

// Full-screen vertical pager. One card fills the screen; drag, fling, wheel and keys
// move between cards. Only the current card and its two neighbours are in the DOM,
// placed with transforms and settled by a spring, so long sessions stay light and
// the motion follows the finger exactly.
export interface PagerSource {
/// How many cards exist right now.
count(): number;
/// The element for card `i`; the owner builds and caches it.
element(i: number): HTMLElement;
/// Card `i` became the current one. Called as soon as a move is decided, before
/// the animation ends, so audio can start right away.
onChange(i: number): void;
/// The user is at (or pulling past) the last card.
onNeedMore(): void;
}
const STIFFNESS = 380; // 1/s², spring towards the resting position
const DAMPING = 34; // 1/s, a little under critical (39): settles with a hint of bounce
const FLING = 0.45; // px/ms; a flick faster than this turns the page
const DISTANCE = 0.22; // or dragging further than this share of the height
const SLOP = 8; // px before a touch counts as a drag
const WHEEL_STEP = 40; // px of wheel delta per page
const WHEEL_IDLE = 180; // ms of quiet that ends one wheel gesture (trackpad inertia)
interface Drag {
id: number;
x0: number;
y0: number;
base: number;
active: boolean;
samples: [number, number][];
}
export class Pager {
index = 0;
/// True right after a drag, so the click that follows it is not a tap.
suppressClick = false;
private offset = 0; // px; positive while pulled down (towards the previous card)
private velocity = 0; // px/s
private height = 1;
private mounted = new Map<number, HTMLElement>();
private raf = 0;
private last = 0;
private drag: Drag | null = null;
private wheelAcc = 0;
private wheelLocked = false;
private wheelTimer = 0;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
constructor(
private root: HTMLElement,
private src: PagerSource,
) {
this.height = root.clientHeight || innerHeight;
root.addEventListener("pointerdown", (e) => this.down(e));
root.addEventListener("pointermove", (e) => this.move(e));
root.addEventListener("pointerup", (e) => this.up(e));
root.addEventListener("pointercancel", (e) => this.up(e, true));
root.addEventListener("wheel", (e) => this.wheel(e), { passive: false });
new ResizeObserver(() => {
this.height = root.clientHeight || innerHeight;
this.render();
}).observe(root);
}
/// Move to card `i`. Returns false when there is no such card (yet).
go(i: number, { instant = false } = {}): boolean {
if (i < 0) return false;
if (i >= this.src.count()) {
this.src.onNeedMore();
return false;
}
if (i === this.index) return true;
this.offset += (i - this.index) * this.height;
this.index = i;
this.refresh();
this.src.onChange(i);
if (instant || this.reduced.matches || document.hidden) this.settleNow();
else this.animate();
if (i >= this.src.count() - 1) this.src.onNeedMore();
return true;
}
/// Re-read the cards around the current one (after inserting or removing cards).
refresh(): void {
const want = new Map<number, HTMLElement>();
for (let i = this.index - 1; i <= this.index + 1; i++) {
if (i >= 0 && i < this.src.count()) want.set(i, this.src.element(i));
}
for (const [i, el] of this.mounted) {
if (want.get(i) !== el && ![...want.values()].includes(el)) el.remove();
}
for (const el of want.values()) {
if (el.parentElement !== this.root) this.root.append(el);
}
this.mounted = want;
this.render();
}
private render(): void {
const h = this.height;
for (const [i, el] of this.mounted) {
const p = i - this.index + this.offset / h;
el.style.transform = `translate3d(0, ${(p * h).toFixed(2)}px, 0)`;
// 0 when resting in place, 1 one full page away; cards use it for depth effects.
el.style.setProperty("--away", Math.min(1, Math.abs(p)).toFixed(3));
}
}
private settleNow(): void {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.offset = 0;
this.velocity = 0;
this.render();
}
private animate(): void {
if (this.raf) return;
this.last = performance.now();
const step = (now: number) => {
// Small fixed steps keep the spring stable on slow frames.
let dt = Math.min(0.05, (now - this.last) / 1000);
this.last = now;
while (dt > 0) {
const h = Math.min(dt, 1 / 240);
this.velocity += (-STIFFNESS * this.offset - DAMPING * this.velocity) * h;
this.offset += this.velocity * h;
dt -= h;
}
if (Math.abs(this.offset) < 0.5 && Math.abs(this.velocity) < 20) {
this.raf = 0;
this.settleNow();
return;
}
this.render();
this.raf = requestAnimationFrame(step);
};
this.raf = requestAnimationFrame(step);
}
// Pointer ---------------------------------------------------------------
private down(e: PointerEvent): void {
if (this.drag || (e.pointerType === "mouse" && e.button !== 0)) return;
if ((e.target as Element).closest("button, a, input, textarea, select, .wave-wrap")) return;
this.drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: this.offset, active: false, samples: [] };
}
private move(e: PointerEvent): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
const dx = e.clientX - d.x0;
const dy = e.clientY - d.y0;
if (!d.active) {
if (Math.abs(dy) < SLOP || Math.abs(dy) < Math.abs(dx)) return;
d.active = true;
this.root.setPointerCapture(e.pointerId);
// Catch a card that is still settling where it is.
cancelAnimationFrame(this.raf);
this.raf = 0;
d.base = this.offset;
d.y0 = e.clientY;
}
let off = d.base + (e.clientY - d.y0);
const atStart = this.index === 0 && off > 0;
const atEnd = this.index >= this.src.count() - 1 && off < 0;
if (atStart || atEnd) off = Math.sign(off) * this.rubber(Math.abs(off));
if (atEnd) this.src.onNeedMore();
this.offset = off;
const now = performance.now();
d.samples.push([now, e.clientY]);
while (d.samples.length > 2 && now - d.samples[0][0] > 100) d.samples.shift();
this.render();
}
/// Resistance past the first and last card.
private rubber(x: number): number {
const h = this.height;
return (1 - 1 / ((x * 0.55) / h + 1)) * h;
}
private up(e: PointerEvent, cancelled = false): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
this.drag = null;
if (!d.active) return;
this.suppressClick = true;
setTimeout(() => (this.suppressClick = false), 350);
let v = 0; // px/ms
if (!cancelled && d.samples.length > 1) {
const [t0, y0] = d.samples[0];
const [t1, y1] = d.samples[d.samples.length - 1];
if (t1 > t0) v = (y1 - y0) / (t1 - t0);
}
const h = this.height;
let target = this.index;
if ((this.offset < -h * DISTANCE || v < -FLING) && this.index + 1 < this.src.count()) target++;
else if ((this.offset > h * DISTANCE || v > FLING) && this.index > 0) target--;
this.velocity = v * 1000;
if (target !== this.index) {
this.offset += (target - this.index) * h;
this.index = target;
this.refresh();
this.src.onChange(target);
if (target >= this.src.count() - 1) this.src.onNeedMore();
}
if (this.reduced.matches) this.settleNow();
else this.animate();
}
// Wheel and trackpad -------------------------------------------------------
private wheel(e: WheelEvent): void {
if (e.ctrlKey) return; // pinch zoom
e.preventDefault();
clearTimeout(this.wheelTimer);
this.wheelTimer = window.setTimeout(() => {
this.wheelLocked = false;
this.wheelAcc = 0;
}, WHEEL_IDLE);
if (this.wheelLocked) return;
this.wheelAcc += e.deltaY * (e.deltaMode === 1 ? 30 : e.deltaMode === 2 ? this.height : 1);
if (Math.abs(this.wheelAcc) >= WHEEL_STEP) {
// One page per gesture: the rest of a trackpad swipe's inertia is ignored.
this.wheelLocked = true;
this.go(this.index + Math.sign(this.wheelAcc));
this.wheelAcc = 0;
}
}
}