About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
// The Listen & lyrics player: one song's real FLAC through an <audio> element (kept in the DOM, hidden).
|
|
// Ported from 17's SongPlayer without the simulated clock: with no recording there is no player at all.
|
|
|
|
export class AudioPlayer {
|
|
t = 0;
|
|
dur = 0;
|
|
playing = false;
|
|
/** Whether the file's length is known yet. */
|
|
ready = false;
|
|
/** Set when the file fails to load. */
|
|
failed = false;
|
|
private audio: HTMLAudioElement | null = null;
|
|
private raf = 0;
|
|
|
|
constructor(private host: HTMLElement, private onChange: () => void) {}
|
|
|
|
get loaded() { return !!this.audio; }
|
|
get element() { return this.audio; }
|
|
|
|
/** Loads a recording, stopped at 0:00; null unloads. */
|
|
load(url: string | null, label = "") {
|
|
this.pause();
|
|
if (this.audio) { this.audio.removeAttribute("src"); this.audio.remove(); }
|
|
this.audio = null;
|
|
this.t = 0;
|
|
this.dur = 0;
|
|
this.ready = false;
|
|
this.failed = false;
|
|
if (url) {
|
|
const a = document.createElement("audio");
|
|
a.preload = "metadata";
|
|
a.hidden = true;
|
|
a.dataset.song = label;
|
|
a.src = url;
|
|
a.addEventListener("loadedmetadata", () => {
|
|
if (a !== this.audio || !Number.isFinite(a.duration)) return;
|
|
this.dur = a.duration;
|
|
this.ready = true;
|
|
// a seek asked for before the length was known is applied now
|
|
if (this.t > 0 && Math.abs(a.currentTime - this.t) > 0.5) a.currentTime = this.t;
|
|
this.onChange();
|
|
});
|
|
a.addEventListener("timeupdate", () => { if (a === this.audio && this.ready) { this.t = a.currentTime; this.onChange(); } });
|
|
a.addEventListener("pause", () => { if (a === this.audio && this.playing && !a.ended) { this.playing = false; this.onChange(); } });
|
|
a.addEventListener("ended", () => { if (a === this.audio) { this.playing = false; cancelAnimationFrame(this.raf); this.onChange(); } });
|
|
a.addEventListener("error", () => {
|
|
if (a !== this.audio) return;
|
|
this.audio = null;
|
|
this.failed = true;
|
|
this.playing = false;
|
|
a.remove();
|
|
this.onChange();
|
|
});
|
|
this.host.append(a);
|
|
this.audio = a;
|
|
}
|
|
this.onChange();
|
|
}
|
|
|
|
play() {
|
|
const a = this.audio;
|
|
if (!a || this.playing) return;
|
|
if (this.ready && this.t >= this.dur - 0.05) this.seek(0);
|
|
this.playing = true;
|
|
a.play()?.catch?.(() => { if (a === this.audio) { this.playing = false; this.onChange(); } });
|
|
this.raf = requestAnimationFrame(this.tick);
|
|
this.onChange();
|
|
}
|
|
|
|
pause() {
|
|
if (!this.playing) return;
|
|
this.playing = false;
|
|
this.audio?.pause();
|
|
cancelAnimationFrame(this.raf);
|
|
this.onChange();
|
|
}
|
|
|
|
toggle() { if (this.playing) this.pause(); else this.play(); }
|
|
|
|
seek(t: number) {
|
|
if (!this.audio) return;
|
|
// before the length is known, any time is allowed; loadedmetadata applies it
|
|
this.t = Math.max(0, this.ready ? Math.min(this.dur, t) : t);
|
|
this.audio.currentTime = this.t;
|
|
this.onChange();
|
|
}
|
|
|
|
destroy() { this.load(null); }
|
|
|
|
// read the element's clock every frame so the lyric highlight is smooth (timeupdate is ~4 Hz)
|
|
private tick = () => {
|
|
if (!this.playing || !this.audio) return;
|
|
if (this.ready) this.t = this.audio.currentTime;
|
|
this.onChange();
|
|
this.raf = requestAnimationFrame(this.tick);
|
|
};
|
|
}
|