diff --git a/components/ItemEditor.tsx b/components/ItemEditor.tsx index a08aeea..15ef662 100644 --- a/components/ItemEditor.tsx +++ b/components/ItemEditor.tsx @@ -9,6 +9,7 @@ import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data"; import { Badge } from "./ui/badge"; import { ResourceBrowser, DragData } from "./ResourceBrowser"; import { playSoundOnce } from "@/lib/sounds"; +import { SoundPlayerBar } from "./SoundPlayerBar"; type Item = { id: string; @@ -222,7 +223,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { if (!item) return; if (data.type === "sound") { - playSoundOnce(data.url); + playSoundOnce(data.url, data.originalName); await updateItem({ ...item, soundCategory: "custom", soundUrl: data.url }); } else if (data.type === "image") { await updateItem({ ...item, imageUrl: data.url }); @@ -317,7 +318,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { {editSoundUrl && (
✓ sound loaded - +
)} @@ -374,7 +375,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { {item.soundUrl && ( + + + ); +} diff --git a/lib/player.ts b/lib/player.ts new file mode 100644 index 0000000..800e452 --- /dev/null +++ b/lib/player.ts @@ -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(); + +let howlInstance: Howl | null = null; +let soundId: number | null = null; +let stopTimer: ReturnType | null = null; +let progressTimer: ReturnType | 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(); +} diff --git a/lib/sounds.ts b/lib/sounds.ts index 14cde51..180b7ce 100644 --- a/lib/sounds.ts +++ b/lib/sounds.ts @@ -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(); +// ─── 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);