Tap to listen, drop marks, bar-timed fades and solar flares

The first visit shows a welcome screen where one tap starts a guest
session; sign-in moves below it. Guests see a "sign in" pill, are
asked once to sign in after five likes or a first follow, and need
two taps to forget themselves.

Drops are found in the waveform and marked on it; "drop" and the d
key jump a bar before the next one. Long and dj-mix fades start on a
bar once the beat is locked. Radio sheets can be shared as /?r=handle.

The audio features now follow the bar, gaps before drops and the
wobble rate, and a new flare pass draws loops round the drop on each
kick, with a setting under visuals.
This commit is contained in:
nvrl committed 2026-11-02 22:03:14 +01:00
1 parent fbcff2266b
commit 62dde885aa
12 files changed
+837 -72

No files matched your search

+25 -1
View File
@@ -6,7 +6,7 @@
// Web Audio, otherwise from the track's waveform at the current position.
import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob";
import { type FlareSetting, LiquidBlob, SPILL } from "./blob/blob";
import { lookFor } from "./blob/style";
import type { Card } from "./cards";
import { accentFor, accentForName } from "./color";
@@ -15,9 +15,11 @@ import type { Player } from "./player";
export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals";
const FLARES_KEY = "pl8inf.flares";
const SLOP = 10; // px before a press on the drop turns it
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
const IDLE_FPS = 15; // a paused drop keeps breathing, slowly and at a low frame rate
const BAR_LOCK = 2; // kick strength on the downbeat before the bar counts as known
export class Visualizer {
private raf = 0;
@@ -28,6 +30,7 @@ export class Visualizer {
private blob: LiquidBlob | null | undefined;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid";
private flareSetting: FlareSetting = (["on", "off"] as const).find((v) => v === localStorage.getItem(FLARES_KEY)) ?? "auto";
private debug: ((f: Features) => void) | null = null;
private dropDebug: ((text: string) => void) | null = null;
/// A press on the drop; `on` once it moved enough to turn it.
@@ -73,12 +76,24 @@ export class Visualizer {
this.attach(this.card, true);
}
get flares(): FlareSetting {
return this.flareSetting;
}
/// Solar flares round the drop: auto, on or off.
set flares(v: FlareSetting) {
this.flareSetting = v;
localStorage.setItem(FLARES_KEY, v);
if (this.blob) this.blob.flareSetting = v;
}
/// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works.
private liquid(): LiquidBlob | null {
if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null;
if (this.blob === undefined) {
this.blob = LiquidBlob.create(this.touch);
if (this.blob) {
this.blob.flareSetting = this.flareSetting;
// Plain art while the GPU is gone; the drop comes back with it.
this.blob.onLost = () => this.card?.el?.classList.remove("liquid");
this.blob.onRestored = () => this.attach(this.card, true);
@@ -87,6 +102,14 @@ export class Visualizer {
return this.blob?.lost ? null : this.blob;
}
/// Seconds until the next bar starts, where the analyser hears the music and the
/// bar is locked onto its kicks; null otherwise.
secondsToBar(): number | null {
const f = this.features.f;
if (!this.player.mixing || this.player.audio.paused || f.lock < BAR_LOCK) return null;
return (1 - f.bar) * 4 * f.period;
}
/// A play/pause from a tap was taken back by the second tap of a double-tap.
cancelFlash(): void {
this.blob?.settleIcon();
@@ -100,6 +123,7 @@ export class Visualizer {
this.card.el?.classList.remove("liquid", "turning");
this.grab = null;
}
if (this.card !== card) this.features.setTempo(card?.track.bpm ?? null);
this.card = card;
const blob = this.liquid();
const slot = card?.el?.querySelector<HTMLElement>(".art");