Files
Epic-The-Musical-Timeline/src/app/panel/player.ts
T
Lucas WintherandClaude Opus 5.5 2fbf3a6980 feat(app): content client and side panel
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>
2026-10-30 05:09:22 +01:00

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);
};
}