feat: shared player with 6s cap, progress bar, stop button
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m43s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m43s
This commit is contained in:
102
lib/player.ts
Normal file
102
lib/player.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Howl } from "howler";
|
||||
|
||||
export type PlaybackState = {
|
||||
isPlaying: boolean;
|
||||
url: string | null;
|
||||
name: string | null;
|
||||
elapsed: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type Listener = (s: PlaybackState) => void;
|
||||
|
||||
const MAX_SECONDS = 6;
|
||||
const FADE_MS = 500;
|
||||
|
||||
let state: PlaybackState = {
|
||||
isPlaying: false,
|
||||
url: null,
|
||||
name: null,
|
||||
elapsed: 0,
|
||||
duration: MAX_SECONDS,
|
||||
};
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let howlInstance: Howl | null = null;
|
||||
let soundId: number | null = null;
|
||||
let stopTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(fn => fn(state));
|
||||
}
|
||||
|
||||
function resetTimers() {
|
||||
if (stopTimer) clearTimeout(stopTimer);
|
||||
if (progressTimer) clearInterval(progressTimer);
|
||||
stopTimer = null;
|
||||
progressTimer = null;
|
||||
}
|
||||
|
||||
function stopSound() {
|
||||
if (howlInstance && soundId !== null) {
|
||||
howlInstance.stop(soundId);
|
||||
}
|
||||
howlInstance = null;
|
||||
soundId = null;
|
||||
}
|
||||
|
||||
export function getPlaybackState(): PlaybackState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribe(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function playUrl(url: string, name?: string) {
|
||||
stopCurrent();
|
||||
|
||||
const displayName = name || url.split("/").pop() || url;
|
||||
|
||||
howlInstance = new Howl({
|
||||
src: [url],
|
||||
format: url.endsWith(".mp3") ? ["mp3"] : url.endsWith(".ogg") ? ["ogg"] : ["mp3", "ogg"],
|
||||
volume: 0.4,
|
||||
});
|
||||
|
||||
soundId = howlInstance.play();
|
||||
|
||||
state = { isPlaying: true, url, name: displayName, elapsed: 0, duration: MAX_SECONDS };
|
||||
emit();
|
||||
|
||||
// Progress tick
|
||||
progressTimer = setInterval(() => {
|
||||
if (howlInstance && soundId !== null) {
|
||||
const seek = howlInstance.seek(soundId) as number;
|
||||
state = { ...state, elapsed: seek };
|
||||
emit();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Cap at MAX_SECONDS with fadeout
|
||||
const fadeStart = (MAX_SECONDS - FADE_MS / 1000) * 1000;
|
||||
stopTimer = setTimeout(() => {
|
||||
if (howlInstance && soundId !== null) {
|
||||
try { howlInstance.fade(0.4, 0, FADE_MS, soundId); } catch {}
|
||||
setTimeout(() => stopCurrent(), FADE_MS);
|
||||
}
|
||||
}, fadeStart);
|
||||
|
||||
// Natural end
|
||||
howlInstance.on("end", () => stopCurrent());
|
||||
}
|
||||
|
||||
export function stopCurrent() {
|
||||
stopSound();
|
||||
resetTimers();
|
||||
state = { isPlaying: false, url: null, name: null, elapsed: 0, duration: MAX_SECONDS };
|
||||
emit();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Howl } from "howler";
|
||||
import { playUrl, stopCurrent } from "@/lib/player";
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
@@ -109,57 +109,28 @@ export function playChaosRiser() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Howl-based URL playback ──────────────────────────────────────
|
||||
|
||||
const howlCache = new Map<string, Howl>();
|
||||
// ─── URL playback via shared player ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Play a sound file URL (MP3/OGG) using howler.js.
|
||||
* Howler handles autoplay policy, cross-browser codec support,
|
||||
* and AudioContext resume better than raw new Audio().
|
||||
* Play a sound file URL (MP3/OGG). Goes through the shared player,
|
||||
* which caps at 6s with fadeout and shows playback UI.
|
||||
*/
|
||||
export function playSoundUrl(url: string) {
|
||||
let h = howlCache.get(url);
|
||||
if (!h) {
|
||||
const newH = new Howl({
|
||||
src: [url],
|
||||
format: url.endsWith(".mp3") ? ["mp3"] : url.endsWith(".ogg") ? ["ogg"] : ["mp3", "ogg"],
|
||||
volume: 0.4,
|
||||
onloaderror: (_id: number, err: unknown) => {
|
||||
console.error("[BaraBingo] Howl load error:", url, err);
|
||||
},
|
||||
onplayerror: (_id: number, err: unknown) => {
|
||||
console.error("[BaraBingo] Howl play error:", url, err);
|
||||
newH.once("unlock", () => newH.play());
|
||||
},
|
||||
});
|
||||
howlCache.set(url, newH);
|
||||
h = newH;
|
||||
}
|
||||
h.play();
|
||||
export function playSoundUrl(url: string, name?: string) {
|
||||
playUrl(url, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot play of a sound URL. Same as playSoundUrl but always creates a fresh instance.
|
||||
* Useful for preview/editor buttons where you want it to play every click.
|
||||
* One-shot play with a display name. Same underlying player.
|
||||
*/
|
||||
export function playSoundOnce(url: string) {
|
||||
const h = new Howl({
|
||||
src: [url],
|
||||
format: url.endsWith(".mp3") ? ["mp3"] : url.endsWith(".ogg") ? ["ogg"] : ["mp3", "ogg"],
|
||||
volume: 0.4,
|
||||
onloaderror: (_id: number, err: unknown) => {
|
||||
console.error("[BaraBingo] Howl load error:", url, err);
|
||||
},
|
||||
onplayerror: (_id: number, err: unknown) => {
|
||||
console.error("[BaraBingo] Howl play error:", url, err);
|
||||
// On play error (e.g. AudioContext suspended), unlock and retry
|
||||
h.once("unlock", () => h.play());
|
||||
},
|
||||
});
|
||||
h.play();
|
||||
export function playSoundOnce(url: string, name?: string) {
|
||||
playUrl(url, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current playback immediately.
|
||||
*/
|
||||
export { stopCurrent, getPlaybackState, subscribe } from "@/lib/player";
|
||||
|
||||
export function playSound(category: string, soundUrl?: string | null) {
|
||||
if (soundUrl) {
|
||||
playSoundUrl(soundUrl);
|
||||
|
||||
Reference in New Issue
Block a user