mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 00:46:59 +01:00
Feed modes move into tabs in the bar; likes, history, filters and settings, and account open as one kind of sheet: a drawer on wide screens, a bottom sheet with a drag handle on phones, with a scrim, Esc, a focus trap and focus restore. Cards get an artist avatar, follow and radio pills, a "new" chip, counts under round action buttons, an up-next pill and a redrawn waveform with rounded bars, a reflection and the buffered range. The start overlay names the track behind a big round play button. New: undo on dislike, a searchable likes list that plays as a queue (in order or shuffled), a local history of what played, deep links to a track (?t=handle/slug), a sleep timer, adjustable crossfade, bass boost, wheel up (w or a long press on the drop) and more keys.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
// 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), then a
|
|
// master gain for the volume, so the visuals look the same at any volume. A
|
|
// low shelf between the two boosts the bass when asked, also after the analyser,
|
|
// so the visuals do not change with it. 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, unless set otherwise
|
|
const PRELOAD_BUFFER = 6; // seconds buffered of the track after the current one
|
|
const PLAY_BUFFER = 30;
|
|
const VOLUME_KEY = "pl8inf.volume";
|
|
const CROSSFADE_KEY = "pl8inf.crossfade";
|
|
const BASS_KEY = "pl8inf.bass";
|
|
const BASS_DB = 6; // gain of the bass boost's low shelf …
|
|
const BASS_HZ = 120; // … below this
|
|
const TAPE_STOP = 0.45; // s the rewind's tape stop takes
|
|
|
|
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` over `length`
|
|
/// seconds. 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, length: 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, length);
|
|
} 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 master: GainNode | null = null;
|
|
private shelf: BiquadFilterNode | null = null;
|
|
private bass = localStorage.getItem(BASS_KEY) === "1";
|
|
/// Seconds two tracks overlap; 0 cuts.
|
|
private fadeFor = Number(localStorage.getItem(CROSSFADE_KEY) ?? CROSSFADE);
|
|
/// The sleep timer's fade, 1 down to 0, on top of the volume.
|
|
private dim = 1;
|
|
private vol = Number(localStorage.getItem(VOLUME_KEY) ?? 1);
|
|
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()];
|
|
if (!(this.vol >= 0 && this.vol <= 1)) this.vol = 1;
|
|
if (!(this.fadeFor >= 0 && this.fadeFor <= 12)) this.fadeFor = CROSSFADE;
|
|
this.volume = this.vol;
|
|
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 = 2048;
|
|
analyser.smoothingTimeConstant = 0.2; // features.ts smooths per feature; this only takes the edge off the FFT
|
|
const master = ctx.createGain();
|
|
const shelf = ctx.createBiquadFilter();
|
|
shelf.type = "lowshelf";
|
|
shelf.frequency.value = BASS_HZ;
|
|
shelf.gain.value = this.bass ? BASS_DB : 0;
|
|
analyser.connect(shelf).connect(master).connect(ctx.destination);
|
|
for (const d of this.decks) {
|
|
d.el.volume = 1; // the master gain takes over
|
|
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;
|
|
this.master = master;
|
|
this.shelf = shelf;
|
|
this.volume = this.vol;
|
|
}
|
|
|
|
/// 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.fadeFor > 0 && 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;
|
|
const length = this.fadeFor;
|
|
fade(to.gain.gain, 1, t, length);
|
|
fade(from.gain.gain, 0, t, length);
|
|
this.fadeEnd = performance.now() + length * 1000;
|
|
const gen = from.gen;
|
|
setTimeout(() => {
|
|
if (from.gen === gen && from !== this.decks[this.cur]) this.unload(from);
|
|
}, length * 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();
|
|
}
|
|
|
|
/// The soundsystem rewind: the record is pulled back, the music slows down and
|
|
/// drops in pitch, and the tune starts again from the top.
|
|
async wheelUp(): Promise<void> {
|
|
const a = this.audio;
|
|
if (a.paused) {
|
|
this.seek(0);
|
|
this.resume();
|
|
return;
|
|
}
|
|
const pitch = a.preservesPitch;
|
|
a.preservesPitch = false;
|
|
const t0 = performance.now();
|
|
await new Promise<void>((done) => {
|
|
const step = () => {
|
|
const k = Math.min(1, (performance.now() - t0) / 1000 / TAPE_STOP);
|
|
a.playbackRate = Math.max(0.1, 1 - 0.9 * k * k);
|
|
if (k < 1) requestAnimationFrame(step);
|
|
else done();
|
|
};
|
|
requestAnimationFrame(step);
|
|
});
|
|
a.playbackRate = 1;
|
|
a.preservesPitch = pitch;
|
|
this.seek(0);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// 0 to 1, as the slider shows it. Squared on the way out, so it sounds even.
|
|
/// Without a graph the element volumes carry it; on iOS they are read-only.
|
|
get volume(): number {
|
|
return this.vol;
|
|
}
|
|
|
|
set volume(v: number) {
|
|
this.vol = Math.max(0, Math.min(1, v));
|
|
localStorage.setItem(VOLUME_KEY, String(this.vol));
|
|
this.applyVolume();
|
|
}
|
|
|
|
private applyVolume(): void {
|
|
const out = this.vol * this.vol * this.dim;
|
|
if (this.master) this.master.gain.value = out;
|
|
else for (const d of this.decks) d.el.volume = out;
|
|
}
|
|
|
|
/// The sleep timer's fade, 0..1. Where the volume is read-only (iOS) it does nothing.
|
|
set dimmed(k: number) {
|
|
this.dim = Math.max(0, Math.min(1, k));
|
|
this.applyVolume();
|
|
}
|
|
|
|
get crossfade(): number {
|
|
return this.fadeFor;
|
|
}
|
|
|
|
set crossfade(s: number) {
|
|
this.fadeFor = s;
|
|
localStorage.setItem(CROSSFADE_KEY, String(s));
|
|
}
|
|
|
|
get bassBoost(): boolean {
|
|
return this.bass;
|
|
}
|
|
|
|
set bassBoost(on: boolean) {
|
|
this.bass = on;
|
|
localStorage.setItem(BASS_KEY, on ? "1" : "0");
|
|
if (this.shelf && this.ctx) this.shelf.gain.setTargetAtTime(on ? BASS_DB : 0, this.ctx.currentTime, 0.05);
|
|
}
|
|
}
|