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.
This commit is contained in:
nvrl committed 2026-11-01 12:57:17 +01:00
1 parent 27225332fb
commit b4470850bf
33 files changed
+3115 -899

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
// Calls to our own server. The browser never talks to pl8list directly.
export interface Track {
id: number;
handle: string;
slug: string;
title: string;
description: string | null;
artist_name: string;
artist_img: string | null;
artist_verified: boolean;
bpm: number | null;
key: string | null;
duration: number | null;
price: number;
created_at: string | null;
saves: number;
downloads: number;
purchases: number;
comments: number;
preview: boolean;
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
}
export interface Stats {
tracks: number;
artists: number;
artists_crawled: number;
served: number;
liked: number;
}
export interface User {
handle: string;
name: string | null;
}
export interface Pl8Status {
connected: boolean;
synced_at: number | null;
}
export interface Me {
user: User;
pl8list: Pl8Status;
}
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
export class HttpError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
async function parse<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(body.error || `request failed (${res.status})`, res.status);
return body as T;
}
export async function getJSON<T>(url: string): Promise<T> {
return parse<T>(await fetch(url));
}
export async function postJSON<T = { ok: boolean }>(url: string, data?: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data ?? {}),
});
return parse<T>(res);
}
/// Fire and forget; survives the page closing.
export function sendEvent(track: Track, kind: EventKind, seconds?: number): void {
const body = JSON.stringify({ track_id: track.id, kind, seconds });
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
}
}
export const feed = (filters: Filters) =>
getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + new URLSearchParams({ n: "10", ...filters }));
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
export const me = () => getJSON<Me>("/local/me");
export const PL8 = "https://pl8list.com";
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
// Images come through our server, so pl8list never sees the visitor's address.
export const artistImg = (t: Track) => (t.artist_img ? `/img/${encodeURIComponent(t.artist_img)}` : null);
export function streamUrl(t: Track): string {
const p = new URLSearchParams();
if (t.preview) p.set("preview", "true");
if (t.upload_id) p.set("v", t.upload_id);
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
}
+249
View File
@@ -0,0 +1,249 @@
// One card per track. Elements are built when a card first comes near the screen
// and dropped again once it is far behind, so a long session stays light.
import { type Track, artistImg, artistUrl, trackUrl } from "./api";
import { accentFor } from "./color";
export interface CardActions {
like(card: Card): void;
dislike(card: Card): void;
share(card: Card): void;
next(card: Card): void;
follow(card: Card): void;
/// Seeking only works on the playing card.
canSeek(card: Card): boolean;
duration(card: Card): number;
seek(card: Card, seconds: number): void;
}
// Uploads are often titled after their file name.
export const cleanTitle = (t: string) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
export const fmtTime = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const tpl = document.getElementById("card-tpl") as HTMLTemplateElement;
export class Card {
el: HTMLElement | null = null;
liked: boolean;
private canvas: HTMLCanvasElement | null = null;
private drawn = "";
private dragging = false;
private accent: string | null = null;
constructor(
readonly track: Track,
private actions: CardActions,
{ liked = false } = {},
) {
this.liked = liked;
}
element(): HTMLElement {
return this.el ?? this.build();
}
/// Forget the element; it is built again if the card comes back.
release(): void {
this.el?.remove();
this.el = this.canvas = null;
this.drawn = "";
}
private build(): HTMLElement {
const t = this.track;
const el = tpl.content.firstElementChild!.cloneNode(true) as HTMLElement;
const $ = <T extends HTMLElement>(sel: string) => el.querySelector(sel) as T;
const img = artistImg(t);
if (img) {
$(".bg").style.backgroundImage = `url("${img}")`;
$(".art").style.backgroundImage = `url("${img}")`;
if (this.accent) el.style.setProperty("--card-accent", this.accent);
else accentFor(img).then((c) => {
this.accent = c;
if (c && this.el) {
this.el.style.setProperty("--card-accent", c);
this.drawWave(this.lastProgress, true);
}
});
} else {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
}
$(".title").textContent = cleanTitle(t.title);
const artist = $<HTMLAnchorElement>(".artist");
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
artist.href = artistUrl(t);
$<HTMLAnchorElement>(".open").href = trackUrl(t);
$(".desc").textContent = t.description || "";
$(".dur").textContent = t.duration ? fmtTime(t.duration) : "";
const chips = $(".chips");
const chip = (text: string, cls = "") => {
const s = document.createElement("span");
s.textContent = text;
if (cls) s.className = cls;
chips.append(s);
};
if (t.bpm) chip(`${Math.round(t.bpm)} bpm`);
if (t.key) chip(t.key);
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
if (t.preview) chip("preview", "preview");
$(".counts").textContent =
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
(t.purchases ? ` · ${t.purchases} sold` : "");
$(".like").addEventListener("click", () => this.actions.like(this));
$(".dislike").addEventListener("click", () => this.actions.dislike(this));
$(".share").addEventListener("click", () => this.actions.share(this));
$(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this));
this.el = el;
this.canvas = $<HTMLCanvasElement>(".wave");
this.bindWaveform();
this.showLike();
this.showFollow();
requestAnimationFrame(() => this.drawWave(this.lastProgress, true));
return el;
}
showLike(pop = false): void {
const btn = this.el?.querySelector(".like");
if (!btn) return;
btn.classList.toggle("on", this.liked);
if (pop && this.liked) {
btn.classList.remove("pop");
void (btn as HTMLElement).offsetWidth; // restart the animation
btn.classList.add("pop");
}
}
showFollow(): void {
const btn = this.el?.querySelector(".follow");
if (!btn) return;
btn.classList.toggle("on", !!this.track.followed);
btn.textContent = this.track.followed ? "following" : "follow";
}
setTime(seconds: number): void {
const cur = this.el?.querySelector(".cur");
if (cur) cur.textContent = fmtTime(seconds);
}
private lastProgress = 0;
/// Draw the waveform with `progress` (0..1) played. Cheap to call every frame:
/// it only redraws when a bar changes colour or the size changed.
drawWave(progress: number, force = false): void {
if (this.dragging && !force) return;
this.lastProgress = progress;
const canvas = this.canvas;
if (!canvas) return;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w) return;
const bars = Math.max(1, Math.floor(w / 3));
const played = Math.round(progress * bars);
const key = `${w}x${h}:${played}`;
if (!force && key === this.drawn) return;
this.drawn = key;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== w * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = this.track.waveform || [];
const max = Math.max(1, ...data);
const accent = getComputedStyle(canvas).getPropertyValue("--card-accent").trim() || "#ff4d6d";
for (let i = 0; i < bars; i++) {
const v = data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
const bh = Math.max(2, v * h);
ctx.fillStyle = i < played ? accent : "rgba(255,255,255,0.35)";
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
}
}
/// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser
/// where there is no Web Audio graph.
loudnessAt(progress: number): number {
const data = this.track.waveform;
if (!data?.length) return 0;
this.peak ??= Math.max(1, ...data);
return data[Math.min(data.length - 1, Math.floor(progress * data.length))] / this.peak;
}
private peak: number | undefined;
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
private bindWaveform(): void {
const canvas = this.canvas!;
const wrap = canvas.parentElement!;
const tip = wrap.querySelector(".wave-tip") as HTMLElement;
const fraction = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
};
const showTip = (f: number) => {
tip.textContent = fmtTime(f * this.actions.duration(this));
tip.style.left = `${f * 100}%`;
};
canvas.addEventListener("pointerdown", (e) => {
if (!this.actions.canSeek(this)) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
this.dragging = true;
wrap.classList.add("hover");
showTip(fraction(e));
this.drawWave(fraction(e), true);
});
canvas.addEventListener("pointermove", (e) => {
if (this.dragging) this.drawWave(fraction(e), true);
if (this.dragging || e.pointerType === "mouse") {
wrap.classList.add("hover");
showTip(fraction(e));
}
});
const finish = (e: PointerEvent) => {
if (!this.dragging) return;
this.dragging = false;
if (e.type === "pointerup") this.actions.seek(this, fraction(e) * this.actions.duration(this));
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
};
canvas.addEventListener("pointerup", finish);
canvas.addEventListener("pointercancel", finish);
canvas.addEventListener("pointerleave", () => {
if (!this.dragging) wrap.classList.remove("hover");
});
}
/// Play/pause icon that pops up and fades.
flash(playing: boolean): void {
if (!this.el) return;
const f = document.createElement("div");
f.className = "flash";
f.innerHTML = `<svg><use href="#i-${playing ? "play" : "pause"}"/></svg>`;
this.el.append(f);
f.addEventListener("animationend", () => f.remove());
}
clearFlash(): void {
this.el?.querySelectorAll(".flash").forEach((f) => f.remove());
}
/// Heart burst where a double-tap landed.
burst(x: number, y: number): void {
if (!this.el) return;
const r = this.el.getBoundingClientRect();
const b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
b.classList.add("burst");
b.innerHTML = '<use href="#i-heart"/>';
b.style.left = `${x - r.left}px`;
b.style.top = `${y - r.top}px`;
b.style.setProperty("--tilt", `${Math.round(Math.random() * 30 - 15)}deg`);
this.el.append(b);
b.addEventListener("animationend", () => b.remove());
}
}
+55
View File
@@ -0,0 +1,55 @@
// Accent colour from an artist image: the most vivid hue, at a lightness that reads
// well on the dark background. Images are same-origin (proxied), so the canvas can
// read them.
const cache = new Map<string, Promise<string | null>>();
export function accentFor(src: string): Promise<string | null> {
let p = cache.get(src);
if (!p) {
p = compute(src).catch(() => null);
cache.set(src, p);
}
return p;
}
async function compute(src: string): Promise<string | null> {
const img = new Image();
img.src = src;
await img.decode();
const size = 24;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
const px = ctx.getImageData(0, 0, size, size).data;
// Average hue as a vector, weighted by how vivid each pixel is.
let x = 0, y = 0, weight = 0, sat = 0;
for (let i = 0; i < px.length; i += 4) {
const [h, s, l] = hsl(px[i], px[i + 1], px[i + 2]);
const w = s * (1 - Math.abs(l - 0.5) * 2);
x += Math.cos(h * 2 * Math.PI) * w;
y += Math.sin(h * 2 * Math.PI) * w;
sat += s * w;
weight += w;
}
// Black-and-white artwork: keep the default accent.
if (weight < size * size * 0.04) return null;
const hue = ((Math.atan2(y, x) / (2 * Math.PI) + 1) % 1) * 360;
const s = Math.min(90, Math.max(60, (sat / weight) * 100));
return `hsl(${hue.toFixed(0)} ${s.toFixed(0)}% 62%)`;
}
function hsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [h / 6, s, l];
}
+6
View File
@@ -0,0 +1,6 @@
// hls.js ships types for its full build only; the light build has the same API
// minus subtitles, alternate audio and DRM, none of which we use.
declare module "hls.js/light" {
export * from "hls.js";
export { default } from "hls.js";
}
+686
View File
@@ -0,0 +1,686 @@
import "./style.css";
import * as api from "./api";
import type { Filters, Pl8Status, Stats, Track, User } from "./api";
import { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager";
import { Player } from "./player";
import { Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
const COMPLETE_RATIO = 0.85;
const DOUBLE_TAP_MS = 300;
const PRELOAD_AFTER_MS = 2500; // start buffering the next track once this one has played a bit
const KEEP_BUILT = 3; // cards further than this from the current one drop their elements
const FILTERS_KEY = "pl8inf.filters";
const HINT_KEY = "pl8inf.hintShown";
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const feedEl = $("feed");
const feedStatus = $("feed-status");
const statsEl = $("stats");
const filtersForm = $<HTMLFormElement>("filters");
const filtersBtn = $("filters-btn");
const likesPanel = $("likes");
const likesBtn = $("likes-btn");
const accountPanel = $("account");
const accountBtn = $("account-btn");
const startEl = $("start");
const helpEl = $("help");
const toastEl = $("toast");
const loginEl = $("login");
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
const player = new Player();
const visual = new Visualizer(player);
const cards: Card[] = [];
let active = -1;
let loading = false;
let started = false;
let listened = 0; // seconds actually heard on the active card
let lastTime = 0;
let reported = false; // final event already sent for the active card
let advanced = false; // already mixed into the next track
let preloadTimer = 0;
let filters: Filters = loadFilters();
let stats: Stats | null = null;
const current = () => cards[active] as Card | undefined;
// Feed ------------------------------------------------------------------
const pager = new Pager(feedEl, {
count: () => cards.length,
element: (i) => cards[i].element(),
onChange: (i) => activate(i),
onNeedMore: () => loadMore(),
});
function setStatus(text: string): void {
feedStatus.textContent = text;
feedStatus.hidden = !text;
}
async function loadMore(): Promise<void> {
if (loading) return;
loading = true;
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
try {
const data = await api.feed(filters);
showStats(data.stats);
if (!data.tracks.length) {
setStatus(Object.keys(filters).length
? "nothing new matches your filters right now. loosen them, or check back in a bit."
: "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { loading = false; }, 8000);
return;
}
setStatus("");
cards.push(...data.tracks.map((t) => new Card(t, actions)));
pager.refresh();
if (active < 0) activate(0);
} catch (err) {
if (err instanceof api.HttpError && err.status === 401) {
loading = false;
showLogin();
return;
}
setStatus(`failed to load: ${(err as Error).message}`);
setTimeout(() => setStatus(""), 5000);
}
loading = false;
}
function showStats(s?: Stats): void {
if (s) stats = s;
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
}
let toastTimer = 0;
function toast(text: string, ms = 2200): void {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toastEl.classList.remove("show"), ms);
}
function finishActive(): void {
const card = current();
if (!card || reported) return;
reported = true;
// Flicking past a card before any audio played says nothing about the track.
if (listened < 1) return;
const dur = player.audio.duration || card.track.duration || 0;
if (dur && listened / dur >= COMPLETE_RATIO) api.sendEvent(card.track, "complete", listened);
else if (listened < SKIP_SECONDS) api.sendEvent(card.track, "skip", listened);
else api.sendEvent(card.track, "play", listened);
}
function activate(idx: number): void {
if (idx === active || !cards[idx]) return;
finishActive();
current()?.el?.classList.remove("paused", "buffering", "current");
active = idx;
listened = 0;
lastTime = 0;
reported = false;
advanced = false;
const card = cards[idx];
if (pager.index !== idx) pager.go(idx, { instant: true });
card.element().classList.add("current");
visual.attach(card);
if (started) play(card.track);
clearTimeout(preloadTimer);
preloadTimer = window.setTimeout(() => {
const next = cards[active + 1];
if (next && started) player.preload(next.track);
}, PRELOAD_AFTER_MS);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
}
function go(idx: number): void {
if (!pager.go(idx, { instant: document.hidden }) && idx >= cards.length) loadMore();
}
function play(track: Track): void {
player.play(track);
updateMediaSession(track);
}
function playbackFailed(): void {
toast("couldn't play this one, skipping");
go(active + 1);
}
// Card actions ------------------------------------------------------------
const actions: CardActions = {
like: (c) => setLike(c, !c.liked),
dislike: (c) => dislike(c),
share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed),
canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s),
};
function setLike(card: Card, liked: boolean): void {
if (card.liked === liked) return;
card.liked = liked;
card.showLike(true);
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
api.sendEvent(card.track, liked ? "like" : "unlike");
}
function dislike(card: Card): void {
api.sendEvent(card.track, "dislike");
if (card === current()) reported = true; // a dislike says more than a skip
if (card.liked) setLike(card, false);
toast("got it, less like this");
go(cards.indexOf(card) + 1);
}
async function setFollow(card: Card, follow: boolean): Promise<void> {
const { handle, artist_name } = card.track;
// Every card by this artist changes together.
const update = (on: boolean) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
c.track.followed = on;
c.showFollow();
});
update(follow);
try {
await api.postJSON("/local/follow", { handle, follow });
toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`);
} catch (err) {
update(!follow);
toast((err as Error).message, 4000);
}
}
async function share(card: Card): Promise<void> {
const { track } = card;
const url = api.trackUrl(track);
if (navigator.share && isTouch) {
try {
await navigator.share({ title: cleanTitle(track.title), text: `${cleanTitle(track.title)} by ${track.artist_name}`, url });
} catch { /* dismissed */ }
return;
}
try {
await navigator.clipboard.writeText(url);
toast("link copied");
} catch {
toast(url, 4000);
}
}
// Player events -----------------------------------------------------------
player.on("timeupdate", () => {
const a = player.audio;
const t = a.currentTime;
const d = t - lastTime;
if (d > 0 && d < 1.5) listened += d; // ignore seeks
lastTime = t;
current()?.setTime(t);
updatePosition();
// Where tracks can be mixed, start the next one just before this one ends.
if (player.mixing && !advanced && !document.hidden && a.duration && a.duration - t < 1 && cards[active + 1]) {
advanced = true;
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
go(active + 1);
}
});
player.on("ended", () => {
const card = current();
if (card && !reported) {
api.sendEvent(card.track, "complete", listened);
reported = true;
}
go(active + 1);
});
player.on("play", () => {
current()?.el?.classList.remove("paused");
visual.start();
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
});
player.on("pause", () => {
if (!player.audio.ended) current()?.el?.classList.add("paused");
current()?.el?.classList.remove("buffering");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
});
player.on("waiting", () => current()?.el?.classList.add("buffering"));
player.on("playing", () => current()?.el?.classList.remove("buffering"));
player.on("failed", () => { if (started) playbackFailed(); });
// Lock screen, notification and headphone controls ------------------------
function updateMediaSession(track: Track): void {
if (!("mediaSession" in navigator)) return;
const artwork: MediaImage[] = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
const img = api.artistImg(track);
if (img) artwork.unshift({ src: img, sizes: "512x512" });
navigator.mediaSession.metadata = new MediaMetadata({
title: cleanTitle(track.title),
artist: track.artist_name,
album: "pl8list ∞",
artwork,
});
}
let lastPositionUpdate = 0;
function updatePosition(): void {
if (!("mediaSession" in navigator) || !navigator.mediaSession.setPositionState) return;
const a = player.audio;
const now = performance.now();
if (now - lastPositionUpdate < 1000 || !Number.isFinite(a.duration)) return;
lastPositionUpdate = now;
try {
navigator.mediaSession.setPositionState({
duration: a.duration,
position: Math.min(a.currentTime, a.duration),
playbackRate: a.playbackRate || 1,
});
} catch { /* duration not known yet */ }
}
if ("mediaSession" in navigator) {
const a = () => player.audio;
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => player.resume()],
["pause", () => player.pause()],
["nexttrack", () => go(active + 1)],
["previoustrack", () => (a().currentTime > 5 ? player.seek(0) : go(active - 1))],
["seekto", (d) => player.seek(d.seekTime ?? 0)],
["seekforward", (d) => player.seek(a().currentTime + (d.seekOffset || 10))],
["seekbackward", (d) => player.seek(a().currentTime - (d.seekOffset || 10))],
];
for (const [action, fn] of handlers) {
try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ }
}
}
// Tap to play/pause, double-tap to like ------------------------------------
let lastTap: { at: number; card: Card | null; toggled: boolean } = { at: 0, card: null, toggled: false };
let swallowClick = false; // the tap that only closed a panel
feedEl.addEventListener("click", (e) => {
if (swallowClick || pager.suppressClick) { swallowClick = false; return; }
const target = e.target as Element;
if (target.closest("button, a, input, .wave-wrap")) return;
const el = target.closest(".card");
const card = el && cards.find((c) => c.el === el);
if (!card || !started) return;
const now = performance.now();
if (lastTap.card === card && now - lastTap.at < DOUBLE_TAP_MS) {
// Second tap: undo the play/pause from the first one and like instead.
if (lastTap.toggled) {
player.toggle();
card.clearFlash();
}
card.burst(e.clientX, e.clientY);
setLike(card, true);
lastTap = { at: 0, card: null, toggled: false };
return;
}
const toggled = card === current();
if (toggled) {
player.toggle();
card.flash(!player.audio.paused);
}
lastTap = { at: now, card, toggled };
});
// Any gesture may set up the audio graph; browsers only allow it from one.
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, () => { if (started) player.unlock(); }, true);
// Panels and overlays -----------------------------------------------------
const panels: [HTMLElement, HTMLElement][] = [[filtersForm, filtersBtn], [likesPanel, likesBtn], [accountPanel, accountBtn]];
function closePanels(): void {
for (const [panel, btn] of panels) {
panel.hidden = true;
btn.setAttribute("aria-expanded", "false");
}
}
function togglePanel(panel: HTMLElement): boolean {
const open = panel.hidden !== false;
closePanels();
if (open) {
panel.hidden = false;
panels.find(([p]) => p === panel)![1].setAttribute("aria-expanded", "true");
}
return open;
}
document.addEventListener("pointerdown", (e) => {
const open = panels.find(([p]) => !p.hidden);
const target = e.target as Element;
if (!open || open[0].contains(target) || target.closest("#bar")) return;
closePanels();
swallowClick = true;
setTimeout(() => { swallowClick = false; }, 500);
}, true);
const showHelp = () => { helpEl.hidden = false; };
const hideHelp = () => { helpEl.hidden = true; };
$("help-btn").addEventListener("click", showHelp);
$("help-close").addEventListener("click", hideHelp);
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
function start(): void {
if (started) return;
started = true;
startEl.hidden = true;
player.unlock();
const card = current();
if (card) activateAgain(card);
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, "1");
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
: "click to pause · double-click to like · ? for shortcuts", 5000);
}
}
startEl.addEventListener("click", start);
/// Start playing the current card after `start()`; activate() did not, as nothing played yet.
function activateAgain(card: Card): void {
play(card.track);
clearTimeout(preloadTimer);
preloadTimer = window.setTimeout(() => {
const next = cards[active + 1];
if (next) player.preload(next.track);
}, PRELOAD_AFTER_MS);
}
// Likes -----------------------------------------------------------------
likesBtn.addEventListener("click", async () => {
if (!togglePanel(likesPanel)) return;
const list = $("likes-list");
const { tracks } = await api.likes();
list.replaceChildren(...tracks.map((t) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "play-now";
btn.title = "play now";
btn.innerHTML = '<svg><use href="#i-play"/></svg>';
btn.addEventListener("click", () => {
closePanels();
if (!started) start();
cards.splice(active + 1, 0, new Card(t, actions, { liked: true }));
pager.refresh();
go(active + 1);
});
const meta = document.createElement("div");
meta.className = "meta";
const a = document.createElement("a");
a.href = api.trackUrl(t);
a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = t.artist_name + (t.followed ? " · following" : "");
meta.append(a, by);
li.append(btn, meta);
return li;
}));
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
});
// Filters ---------------------------------------------------------------
function loadFilters(): Filters {
try {
return JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") || {};
} catch {
return {};
}
}
function showFilters(): void {
const f = filtersForm.elements as unknown as Record<string, HTMLInputElement>;
f.free.checked = filters.free === "1";
f.bpm_min.value = filters.bpm_min || "";
f.bpm_max.value = filters.bpm_max || "";
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
}
function applyFilters(): void {
const f = new FormData(filtersForm);
filters = {};
if (f.get("free")) filters.free = "1";
if (f.get("bpm_min")) filters.bpm_min = String(f.get("bpm_min"));
if (f.get("bpm_max")) filters.bpm_max = String(f.get("bpm_max"));
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
// Drop cards after the current one so the new filter applies right away.
for (const c of cards.splice(active + 1)) c.release();
pager.refresh();
loadMore();
}
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
showFilters();
// Keyboard --------------------------------------------------------------
document.addEventListener("keydown", (e) => {
if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") {
closePanels();
hideHelp();
return;
}
if (!loginEl.hidden) return;
if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
return;
}
const card = current();
const a = player.audio;
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
else if (k === " ") { e.preventDefault(); player.toggle(); card?.flash(!a.paused); }
else if (k === "ArrowRight") player.seek(a.currentTime + 10);
else if (k === "ArrowLeft") player.seek(a.currentTime - 10);
else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
});
addEventListener("resize", () => current()?.drawWave(player.audio.duration ? player.audio.currentTime / player.audio.duration : 0, true));
addEventListener("pagehide", finishActive);
// Sign-in -----------------------------------------------------------------
const emailForm = $<HTMLFormElement>("email-form");
const codeForm = $<HTMLFormElement>("code-form");
const codeInput = $<HTMLInputElement>("code");
const connectInput = $<HTMLInputElement>("connect");
const loginError = $("login-error");
const loginCancel = $("login-cancel");
let signedInUser: User | null = null;
// `resync`: already signed in, signing in again only to sync with pl8list.
function showLogin({ resync = false } = {}): void {
closePanels();
$("bar").hidden = true;
startEl.hidden = true;
loginEl.hidden = false;
emailForm.hidden = false;
codeForm.hidden = true;
loginCancel.hidden = !resync;
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
$("email").focus();
}
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); });
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; });
loginError.textContent = "";
try {
await fn();
} catch (err) {
loginError.textContent = (err as Error).message;
}
buttons.forEach((b) => { b.disabled = false; });
}
emailForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(emailForm, async () => {
const { email } = await api.postJSON<{ email: string }>("/auth/start", { email: $<HTMLInputElement>("email").value });
$("sent-to").textContent = email;
emailForm.hidden = true;
codeForm.hidden = false;
codeInput.value = "";
codeInput.focus();
});
});
codeForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(codeForm, async () => {
const { user, connected } = await api.postJSON<{ user: User; connected: boolean }>("/auth/verify", {
code: codeInput.value,
connect: connectInput.checked,
});
signedIn(user, { connected, synced_at: Date.now() / 1000 });
toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
});
});
codeInput.addEventListener("input", () => {
if (/^\d{6}$/.test(codeInput.value)) codeForm.requestSubmit();
});
$("resend").addEventListener("click", () => {
withForm(codeForm, async () => {
await api.postJSON("/auth/resend");
loginError.textContent = "new code sent";
});
});
$("restart").addEventListener("click", () => showLogin());
// Account -----------------------------------------------------------------
const deleteForm = $<HTMLFormElement>("delete-form");
const deleteConfirm = $<HTMLInputElement>("delete-confirm");
const syncStatus = $("sync-status");
const disconnectBtn = $("disconnect-btn");
function ago(ts: number): string {
const m = Math.round((Date.now() / 1000 - ts) / 60);
if (m < 1) return "just now";
if (m < 60) return `${m} min ago`;
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
return `${Math.round(m / 1440)} days ago`;
}
function showSync(s: Pl8Status | undefined): void {
const connected = !!s?.connected;
syncStatus.classList.toggle("on", connected);
syncStatus.textContent = connected
? `connected on this device${s?.synced_at ? `, last sync ${ago(s.synced_at)}` : ""}. ` +
"Likes and follows are saved to your pl8list library."
: "not connected. Resync signs you in with pl8list once to read your feed and library.";
disconnectBtn.hidden = !connected;
}
accountBtn.addEventListener("click", async () => {
if (!togglePanel(accountPanel)) return;
deleteForm.hidden = true;
try { showSync((await api.me()).pl8list); } catch { /* keep what is shown */ }
});
$("sync-btn").addEventListener("click", async () => {
try {
await api.postJSON("/account/pl8list/sync");
toast("syncing with pl8list…");
showSync({ connected: true, synced_at: Date.now() / 1000 });
} catch (err) {
if (err instanceof api.HttpError && err.status === 409) showLogin({ resync: true });
else toast((err as Error).message, 4000);
}
});
disconnectBtn.addEventListener("click", async () => {
try {
await api.postJSON("/account/pl8list/disconnect");
showSync({ connected: false, synced_at: null });
toast("disconnected from pl8list");
} catch (err) {
toast((err as Error).message, 4000);
}
});
$("logout-btn").addEventListener("click", async () => {
await api.postJSON("/auth/logout");
location.reload();
});
$("logout-all-btn").addEventListener("click", async () => {
await api.postJSON("/account/logout-all");
location.reload();
});
$("delete-open").addEventListener("click", () => {
deleteForm.hidden = false;
deleteConfirm.value = "";
deleteConfirm.focus();
});
deleteForm.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
localStorage.removeItem(FILTERS_KEY);
localStorage.removeItem(HINT_KEY);
location.reload();
} catch (err) {
toast((err as Error).message, 4000);
}
});
function signedIn(user: User, pl8list?: Pl8Status): void {
signedInUser = user;
if (pl8list) showSync(pl8list);
loginEl.hidden = true;
$("bar").hidden = false;
accountBtn.textContent = `@${user.handle}`;
$("account-handle").textContent = `@${user.handle}`;
$("delete-handle").textContent = user.handle;
// Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start();
else if (!started) startEl.hidden = false;
if (!cards.length) loadMore();
}
api.me().then(
(me) => signedIn(me.user, me.pl8list),
() => showLogin(),
);
+232
View File
@@ -0,0 +1,232 @@
// 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;
}
}
}
+285
View File
@@ -0,0 +1,285 @@
// Audio playback with two decks, so the next track can be preloaded and tracks
// crossfade into each other.
//
// Where the browser allows it, both <audio> elements run through a Web Audio graph:
// a gain per deck (for the crossfade) into one analyser (for the visuals). iOS is
// left out: its element volume is read-only and Web Audio stops when the screen
// locks, which would break lock-screen playback. There tracks switch with a cut.
import type HlsType from "hls.js/light";
import { type Track, streamUrl } from "./api";
const CROSSFADE = 0.9; // seconds
const PRELOAD_BUFFER = 6; // seconds buffered of the track after the current one
const PLAY_BUFFER = 30;
type Hls = HlsType;
type HlsClass = typeof HlsType;
let hlsClass: Promise<HlsClass | null> | null = null;
/// Set once the module has loaded, so later loads can attach synchronously and
/// play() still runs inside the user's tap (iOS insists on that).
let hlsLoaded: HlsClass | null | undefined;
/// hls.js where it works (MSE), otherwise null and the browser plays HLS itself (Safari).
function loadHls(): Promise<HlsClass | null> {
hlsClass ??= import("hls.js/light")
.then((m) => (m.default.isSupported() ? m.default : null))
.catch(() => null)
.then((h) => (hlsLoaded = h));
return hlsClass;
}
interface Deck {
el: HTMLAudioElement;
hls: Hls | null;
gain: GainNode | null;
track: Track | null;
/// Should be playing as soon as its source is attached.
wantPlay: boolean;
/// Bumped on every load/unload, so late callbacks of an old load do nothing.
gen: number;
}
export type PlayerEvent = "timeupdate" | "ended" | "play" | "pause" | "waiting" | "playing" | "failed";
const FORWARDED: PlayerEvent[] = ["timeupdate", "ended", "play", "pause", "waiting", "playing"];
/// Equal-power fade of `param` from its current value to `to`. A fade can start
/// while the previous one still runs (fast flicking), so it starts where that one is.
function fade(param: AudioParam, to: number, at: number): void {
const from = param.value;
param.cancelScheduledValues(0);
const up = to > from;
const curve = Float32Array.from({ length: 32 }, (_, i) => {
const x = (i / 31) * (Math.PI / 2);
return up ? from + (to - from) * Math.sin(x) : to + (from - to) * Math.cos(x);
});
try {
param.setValueCurveAtTime(curve, at, CROSSFADE);
} catch {
param.value = to;
}
}
export class Player {
private decks: [Deck, Deck];
private cur = 0;
private ctx: AudioContext | null = null;
private unlocking: Promise<void> | null = null;
private fadeEnd = 0;
private listeners = new Map<PlayerEvent, Set<() => void>>();
analyser: AnalyserNode | null = null;
/// False on iOS, see the top of this file.
readonly canMix: boolean;
constructor() {
const probe = document.createElement("audio");
probe.volume = 0.5;
this.canMix = probe.volume === 0.5 && "AudioContext" in window;
const deck = (): Deck => {
const el = document.createElement("audio");
el.preload = "auto";
document.body.append(el);
return { el, hls: null, gain: null, track: null, wantPlay: false, gen: 0 };
};
this.decks = [deck(), deck()];
for (const d of this.decks) {
for (const ev of FORWARDED) {
d.el.addEventListener(ev, () => {
if (d === this.decks[this.cur]) this.emit(ev);
});
}
// hls.js reports its own errors; this is for native HLS.
d.el.addEventListener("error", () => {
if (d === this.decks[this.cur] && !d.hls && d.track) this.emit("failed");
});
}
loadHls(); // start fetching the module early
}
on(ev: PlayerEvent, fn: () => void): void {
if (!this.listeners.has(ev)) this.listeners.set(ev, new Set());
this.listeners.get(ev)!.add(fn);
}
private emit(ev: PlayerEvent): void {
this.listeners.get(ev)?.forEach((fn) => fn());
}
/// The element of the current deck, for time, duration and pause state.
get audio(): HTMLAudioElement {
return this.decks[this.cur].el;
}
get track(): Track | null {
return this.decks[this.cur].track;
}
/// True while two tracks are mixed and crossfades work.
get mixing(): boolean {
return this.ctx?.state === "running";
}
/// Call from a user gesture. Sets up the Web Audio graph where the browser can mix.
unlock(): Promise<void> {
if (this.ctx) {
if (this.ctx.state === "suspended") this.ctx.resume().catch(() => {});
return Promise.resolve();
}
if (!this.canMix) return Promise.resolve();
this.unlocking ??= this.setUpGraph();
return this.unlocking;
}
private async setUpGraph(): Promise<void> {
let ctx: AudioContext;
try {
ctx = new AudioContext();
await ctx.resume();
} catch {
return;
}
// Routing an element through the graph can't be undone, and a suspended graph
// is silent, so only route once it actually runs.
if (ctx.state !== "running") {
ctx.close();
this.unlocking = null;
return;
}
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.7;
analyser.connect(ctx.destination);
for (const d of this.decks) {
const gain = ctx.createGain();
gain.gain.value = d === this.decks[this.cur] ? 1 : 0;
ctx.createMediaElementSource(d.el).connect(gain).connect(analyser);
d.gain = gain;
}
this.ctx = ctx;
this.analyser = analyser;
}
/// Play a track, crossfading from the current one when possible.
async play(track: Track): Promise<void> {
const from = this.decks[this.cur];
if (from.track?.id === track.id) {
from.wantPlay = true;
from.el.play().catch(() => {});
return;
}
const toIdx = 1 - this.cur;
const to = this.decks[toIdx];
if (to.track?.id === track.id && performance.now() >= this.fadeEnd) {
if (to.hls) to.hls.config.maxBufferLength = PLAY_BUFFER;
} else {
this.load(to, track, PLAY_BUFFER);
}
const crossfade = this.mixing && !document.hidden && !from.el.paused && from.track !== null;
this.cur = toIdx;
to.wantPlay = true;
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
if (crossfade && to.gain && from.gain) {
const t = this.ctx!.currentTime;
fade(to.gain.gain, 1, t);
fade(from.gain.gain, 0, t);
this.fadeEnd = performance.now() + CROSSFADE * 1000;
const gen = from.gen;
setTimeout(() => {
if (from.gen === gen && from !== this.decks[this.cur]) this.unload(from);
}, CROSSFADE * 1000 + 50);
} else {
if (to.gain) {
to.gain.gain.cancelScheduledValues(0);
to.gain.gain.value = 1;
}
this.unload(from);
}
if (to.hls || to.el.hasAttribute("src")) to.el.play().catch(() => {});
}
/// Buffer the start of the track that is likely to come next.
preload(track: Track): void {
const wait = this.fadeEnd - performance.now();
if (wait > 0) {
setTimeout(() => this.preload(track), wait + 100);
return;
}
const idle = this.decks[1 - this.cur];
if (idle.track?.id !== track.id) this.load(idle, track, PRELOAD_BUFFER);
}
private load(d: Deck, track: Track, buffer: number): void {
this.unload(d);
const gen = d.gen;
d.track = track;
const url = streamUrl(track);
const attach = (Hls: HlsClass | null) => {
if (d.gen !== gen) return;
if (Hls) {
const hls = new Hls({ maxBufferLength: buffer, maxMaxBufferLength: 60 });
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal || d.gen !== gen) return;
console.warn("hls fatal", data.details);
if (d === this.decks[this.cur]) this.emit("failed");
else this.unload(d);
});
hls.loadSource(url);
hls.attachMedia(d.el);
d.hls = hls;
} else {
d.el.src = url;
}
if (d.wantPlay) d.el.play().catch(() => {});
};
if (hlsLoaded !== undefined) attach(hlsLoaded);
else loadHls().then(attach);
}
private unload(d: Deck): void {
d.gen++;
d.wantPlay = false;
d.hls?.destroy();
d.hls = null;
d.track = null;
d.el.pause();
if (d.el.hasAttribute("src")) {
d.el.removeAttribute("src");
d.el.load();
}
if (d.gain) {
d.gain.gain.cancelScheduledValues(0);
d.gain.gain.value = 0;
}
}
toggle(): void {
if (this.audio.paused) this.resume();
else this.pause();
}
resume(): void {
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
this.decks[this.cur].wantPlay = true;
this.audio.play().catch(() => {});
}
pause(): void {
this.decks[this.cur].wantPlay = false;
this.audio.pause();
}
seek(seconds: number): void {
const a = this.audio;
a.currentTime = Math.max(0, Math.min(a.duration || 0, seconds));
}
get muted(): boolean {
return this.decks[0].el.muted;
}
set muted(m: boolean) {
for (const d of this.decks) d.el.muted = m;
}
}
+527
View File
@@ -0,0 +1,527 @@
/* Design tokens ---------------------------------------------------------- */
:root {
color-scheme: dark;
--bg: #000;
--fg: #f2f2f2;
--muted: #9a9a9a;
--faint: #666;
--accent: #ff4d6d;
--panel: rgba(18, 18, 20, 0.94);
--line: #ffffff1f;
--glass: rgba(255, 255, 255, 0.1);
--glass-strong: rgba(255, 255, 255, 0.2);
--radius-s: 8px;
--radius-m: 14px;
--radius-l: 18px;
--shadow: 0 16px 40px #000a;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font);
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--fast: 0.15s;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font: 15px/1.4 var(--font);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; }
button, input {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
padding: 0.35em 0.9em;
}
[hidden] { display: none !important; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* Header and panels ------------------------------------------------------ */
#bar {
position: fixed;
inset: 0 0 auto 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
padding-top: max(0.6rem, env(safe-area-inset-top));
background: linear-gradient(#000c, #0000);
}
.logo { font-weight: 600; margin-right: auto; }
.logo b { color: var(--accent); }
#stats { color: var(--muted); font-size: 0.8rem; }
#help-btn { width: 2.2em; padding: 0.35em 0; }
#account-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filters-btn.active::after {
content: "";
display: inline-block;
width: 0.45em;
height: 0.45em;
margin-left: 0.4em;
border-radius: 50%;
background: var(--accent);
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
.panel {
position: fixed;
z-index: 11;
top: calc(3rem + env(safe-area-inset-top));
right: 1rem;
background: var(--panel);
border: 1px solid #333;
border-radius: 12px;
padding: 0.8rem 1rem;
box-shadow: 0 16px 40px #000a;
}
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters input[type=number] {
width: 4.5em;
background: #222;
border: 1px solid #444;
border-radius: 6px;
padding: 0.2em 0.4em;
}
#likes { width: min(26rem, calc(100vw - 2rem)); max-height: 70vh; overflow: auto; }
#likes h2 { margin: 0 0 0.5rem; font-size: 1rem; }
#likes ol { margin: 0; padding: 0; list-style: none; }
#likes li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
border-bottom: 1px solid #ffffff10;
}
#likes li:last-child { border-bottom: none; }
#likes li .meta { min-width: 0; flex: 1; }
#likes li .meta a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
#likes li .meta a:hover { text-decoration: underline; }
#likes li .meta span { color: var(--muted); font-size: 0.8rem; }
#likes li .play-now {
flex: none;
width: 2rem;
height: 2rem;
padding: 0;
display: grid;
place-items: center;
}
#likes li .play-now svg { width: 0.9rem; height: 0.9rem; }
#account { width: min(20rem, calc(100vw - 2rem)); display: flex; flex-direction: column; align-items: flex-start; gap: 0.7rem; }
#account h2 { margin: 0; font-size: 1rem; }
#account a { font-size: 0.85rem; color: var(--muted); }
#sync { width: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem;
padding-bottom: 0.7rem; border-bottom: 1px solid #ffffff14; }
#sync h3 { margin: 0; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
#sync-status { margin: 0; font-size: 0.85rem; color: var(--muted); }
#sync-status.on::before { content: "● "; color: #3ddc84; }
#delete-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
#delete-form label { color: var(--muted); font-size: 0.85rem; }
#delete-form input { padding: 0.4em 0.6em; border-radius: 8px; border: 1px solid #444; background: #111; }
.link.danger { color: var(--accent); }
button.danger:not(.link) { background: var(--accent); border: none; color: #fff; font-weight: 600; }
.legal-links a { color: inherit; }
/* Feed ------------------------------------------------------------------- */
/* The pager (pager.ts) places cards with transforms and sets --away (0 in place,
1 a full page away); the visualizer sets --energy (0..1, bass level) on the
playing card; --card-accent comes from the artist image. */
#feed {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: none;
}
.card {
--card-accent: var(--accent);
--energy: 0;
--away: 0;
position: absolute;
inset: 0;
will-change: transform;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
}
.bg {
position: absolute;
inset: -40px;
background: #111 center / cover no-repeat;
filter: blur(40px) brightness(0.45) saturate(1.3);
}
.glow {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%);
opacity: calc(0.18 + var(--energy) * 0.55);
}
.content {
position: relative;
/* Cards shrink and dim a little as they leave, for depth while swiping. */
transform: scale(calc(1 - var(--away) * 0.08));
opacity: calc(1 - var(--away) * 0.55);
width: min(34rem, calc(100vw - 5.5rem));
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
}
.art {
position: relative;
width: min(16rem, 42vh, 70vw);
aspect-ratio: 1;
border-radius: 14px;
background: #222 center / cover no-repeat;
box-shadow:
0 20px 60px #000a,
0 0 calc(10px + var(--energy) * 70px) color-mix(in oklab, var(--card-accent) calc(15% + var(--energy) * 45%), transparent);
display: grid;
place-items: center;
margin-bottom: 0.6rem;
/* Pulse on the beat through `scale`; `transform` is left to the pause animation. */
scale: calc(1 + var(--energy) * 0.045);
transition: transform 0.25s var(--ease-out), filter 0.25s ease;
}
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
.paused-badge {
position: absolute;
inset: 0;
margin: auto;
width: 4.5rem;
height: 4.5rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.paused-badge svg { width: 2rem; height: 2rem; margin-left: 0.2rem; }
.card.paused .paused-badge { opacity: 1; }
.spinner {
position: absolute;
inset: 0;
margin: auto;
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 3px solid #ffffff33;
border-top-color: #fff;
opacity: 0;
pointer-events: none;
animation: spin 0.8s linear infinite;
}
.card.buffering .spinner { opacity: 1; }
.card.buffering .paused-badge { opacity: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.title {
font: 800 clamp(1.3rem, 4.2vw, 1.9rem)/1.1 var(--font-display);
letter-spacing: -0.01em;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
}
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
.follow {
font-size: 0.75rem;
padding: 0.15em 0.75em;
border-radius: 999px;
border: 1px solid #ffffff40;
background: transparent;
}
.follow.on { background: #ffffff1f; border-color: transparent; color: var(--muted); }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; justify-content: center; }
.chips span {
font-size: 0.75rem;
padding: 0.1em 0.6em;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.chips .free { background: #1f7a4d; }
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
.chips .preview { background: #6a4b16; }
.desc {
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.85rem;
}
.wave-wrap { position: relative; width: 100%; margin-top: 0.6rem; }
.wave { display: block; width: 100%; height: 64px; cursor: pointer; touch-action: none; }
.wave-tip {
position: absolute;
bottom: calc(100% + 4px);
transform: translateX(-50%);
padding: 0.1em 0.45em;
border-radius: 6px;
background: #000c;
font-size: 0.75rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
}
.wave-wrap.hover .wave-tip { opacity: 1; }
.time { width: 100%; display: flex; justify-content: space-between; color: var(--muted); font-size: 0.75rem; }
.counts { color: var(--muted); font-size: 0.8rem; }
.actions {
position: absolute;
right: 0.8rem;
bottom: 18vh;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.actions > * {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: none;
text-decoration: none;
padding: 0;
transition: background 0.15s, transform 0.15s;
}
.actions > *:hover { background: rgba(255, 255, 255, 0.2); }
.actions > *:active { transform: scale(0.9); }
.actions svg { width: 1.3rem; height: 1.3rem; }
.actions .like.on { color: var(--card-accent); background: color-mix(in oklab, var(--card-accent) 22%, transparent); }
.actions .like.pop svg { animation: pop 0.35s ease; }
@keyframes pop { 50% { transform: scale(1.4); } }
/* Feedback overlays ------------------------------------------------------ */
.flash {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 5.5rem;
height: 5.5rem;
margin: -2.75rem 0 0 -2.75rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
pointer-events: none;
animation: flash 0.6s ease forwards;
}
.flash svg { width: 2.4rem; height: 2.4rem; }
@keyframes flash {
from { opacity: 1; transform: scale(0.7); }
to { opacity: 0; transform: scale(1.3); }
}
.burst {
position: absolute;
z-index: 3;
width: 6rem;
height: 6rem;
margin: -3rem 0 0 -3rem;
color: var(--card-accent);
pointer-events: none;
filter: drop-shadow(0 6px 20px color-mix(in oklab, var(--card-accent) 55%, transparent));
animation: burst 0.8s cubic-bezier(.2, .9, .3, 1.3) forwards;
}
@keyframes burst {
0% { opacity: 0; transform: scale(0.3) rotate(var(--tilt)); }
25% { opacity: 1; transform: scale(1.15) rotate(var(--tilt)); }
60% { opacity: 1; transform: scale(1) rotate(var(--tilt)); }
100% { opacity: 0; transform: translateY(-3rem) scale(0.9) rotate(var(--tilt)); }
}
#toast {
position: fixed;
z-index: 40;
left: 50%;
bottom: max(2rem, env(safe-area-inset-bottom));
transform: translate(-50%, 1rem);
padding: 0.6em 1.1em;
border-radius: 999px;
background: #222e;
border: 1px solid #ffffff22;
font-size: 0.9rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
white-space: nowrap;
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }
#feed-status {
position: fixed;
z-index: 5;
left: 50%;
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
transform: translateX(-50%);
width: min(26rem, calc(100vw - 2rem));
margin: 0;
color: var(--muted);
text-align: center;
pointer-events: none;
}
#feed:empty + #feed-status { bottom: 50%; }
#start {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #000b;
cursor: pointer;
}
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
#help {
position: fixed;
inset: 0;
z-index: 25;
display: grid;
place-items: center;
padding: 1rem;
background: #000b;
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } }
.help-box {
width: min(22rem, 100%);
background: var(--panel);
border: 1px solid #333;
border-radius: 16px;
padding: 1.2rem 1.4rem;
}
.help-box h2 { margin: 0 0 0.8rem; font-size: 1.1rem; }
.help-box dl { display: grid; grid-template-columns: auto 1fr; gap: 0.45rem 1rem; margin: 0; }
.help-box dt { font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--accent); }
.help-box dd { margin: 0; }
.help-box p { color: var(--muted); font-size: 0.85rem; margin: 1rem 0; }
.help-box button { width: 100%; background: var(--accent); border: none; color: #fff; padding: 0.6em; font-weight: 600; }
@media (hover: hover) and (pointer: fine) { .touch-only { display: none !important; } }
@media not ((hover: hover) and (pointer: fine)) { .pointer-only { display: none !important; } }
/* Sign-in ---------------------------------------------------------------- */
#login {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 1rem;
background:
radial-gradient(60rem 40rem at 20% 10%, #ff4d6d33, transparent 60%),
radial-gradient(50rem 40rem at 90% 90%, #4d6dff2e, transparent 60%),
#050507;
}
.login-box {
width: min(24rem, 100%);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.login-box h1 { margin: 0; font-size: 2.4rem; }
.login-box h1 b { color: var(--accent); }
.tagline { margin: 0 0 0.8rem; color: var(--muted); }
.login-box form { display: flex; flex-direction: column; gap: 0.6rem; }
.login-box label { color: var(--muted); font-size: 0.9rem; }
.login-box input {
padding: 0.7em 0.9em;
border-radius: 10px;
border: 1px solid #333;
background: #111;
}
#code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; }
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
.login-box button[disabled] { opacity: 0.6; cursor: wait; }
.login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }
.check input { margin-top: 0.2rem; accent-color: var(--accent); }
.check small { display: block; margin-top: 0.2rem; color: #777; font-size: 0.75rem; line-height: 1.4; }
#login-cancel { align-self: center; }
.link { background: none; border: none; padding: 0; color: var(--muted); text-decoration: underline; font-size: 0.85rem; }
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
/* Phones ----------------------------------------------------------------- */
@media (max-width: 640px) {
#stats { display: none; }
#bar { gap: 0.4rem; padding-left: 0.7rem; padding-right: 0.7rem; }
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
#help-btn { display: none; }
#account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions {
right: auto;
left: 50%;
bottom: max(1.2rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
flex-direction: row;
gap: 0.9rem;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+69
View File
@@ -0,0 +1,69 @@
// Per-frame visuals of the playing card: waveform progress, and a bass level that
// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1).
// The level comes from the analyser where audio runs through Web Audio, otherwise
// from the track's waveform at the current position.
import type { Card } from "./cards";
import type { Player } from "./player";
export class Visualizer {
private raf = 0;
private bins: Uint8Array<ArrayBuffer> | null = null;
private level = 0;
private peak = 0.3;
private card: Card | null = null;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
constructor(private player: Player) {
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
}
/// Follow `card` from now on.
attach(card: Card | null): void {
if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0");
this.card = card;
this.start();
}
start(): void {
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame);
}
stop(): void {
cancelAnimationFrame(this.raf);
this.raf = 0;
}
private frame = () => {
this.raf = 0;
const card = this.card;
const a = this.player.audio;
if (!card) return;
const progress = a.duration ? a.currentTime / a.duration : 0;
card.drawWave(progress);
if (!this.reduced.matches) {
const raw = a.paused ? 0 : this.bass(card, progress);
// Fast attack, slow release, against a slowly falling peak: kicks stand out
// the same whether a track is mastered loud or quiet.
this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12);
this.peak = Math.max(this.peak * 0.997, this.level, 0.2);
const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65);
card.el?.style.setProperty("--energy", energy.toFixed(3));
}
// Keep running while playing; one last frame after a pause lets the glow fade.
if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame);
};
private bass(card: Card, progress: number): number {
const an = this.player.analyser;
if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8;
if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount);
an.getByteFrequencyData(this.bins);
// Bins up to ~150 Hz: kick and sub.
const hz = an.context.sampleRate / an.fftSize;
const top = Math.max(2, Math.round(150 / hz));
let sum = 0;
for (let i = 1; i <= top; i++) sum += this.bins[i];
return sum / top / 255;
}
}