feat: shared player with 6s cap, progress bar, stop button
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m43s

This commit is contained in:
2026-06-15 02:17:46 +03:00
parent 2dd604192f
commit 0c35693598
5 changed files with 167 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { ResourceBrowser, DragData } from "./ResourceBrowser"; import { ResourceBrowser, DragData } from "./ResourceBrowser";
import { playSoundOnce } from "@/lib/sounds"; import { playSoundOnce } from "@/lib/sounds";
import { SoundPlayerBar } from "./SoundPlayerBar";
type Item = { type Item = {
id: string; id: string;
@@ -222,7 +223,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
if (!item) return; if (!item) return;
if (data.type === "sound") { if (data.type === "sound") {
playSoundOnce(data.url); playSoundOnce(data.url, data.originalName);
await updateItem({ ...item, soundCategory: "custom", soundUrl: data.url }); await updateItem({ ...item, soundCategory: "custom", soundUrl: data.url });
} else if (data.type === "image") { } else if (data.type === "image") {
await updateItem({ ...item, imageUrl: data.url }); await updateItem({ ...item, imageUrl: data.url });
@@ -317,7 +318,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
{editSoundUrl && ( {editSoundUrl && (
<div className="flex items-center gap-1 text-[10px] font-mono text-cyan-400"> <div className="flex items-center gap-1 text-[10px] font-mono text-cyan-400">
<span> sound loaded</span> <span> sound loaded</span>
<button type="button" onClick={() => playSoundOnce(editSoundUrl)} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-slate-600/50 text-xs cursor-pointer"></button> <button type="button" onClick={() => playSoundOnce(editSoundUrl!, "Preview")} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-slate-600/50 text-xs cursor-pointer"></button>
<button type="button" onClick={() => setEditSoundUrl(null)} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-red-800/50 text-xs cursor-pointer"></button> <button type="button" onClick={() => setEditSoundUrl(null)} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-red-800/50 text-xs cursor-pointer"></button>
</div> </div>
)} )}
@@ -374,7 +375,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
{item.soundUrl && ( {item.soundUrl && (
<button <button
type="button" type="button"
onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!); }} onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!, item.text); }}
className="text-[10px] text-cyan-500/60 hover:text-cyan-300 cursor-pointer relative z-10" className="text-[10px] text-cyan-500/60 hover:text-cyan-300 cursor-pointer relative z-10"
> >
🔊 🔊
@@ -396,6 +397,9 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
{/* Resource Browser dock */} {/* Resource Browser dock */}
<ResourceBrowser visible={showBrowser} onClose={() => setShowBrowser(false)} /> <ResourceBrowser visible={showBrowser} onClose={() => setShowBrowser(false)} />
{/* Sound playback bar */}
<SoundPlayerBar />
{/* All Items list */} {/* All Items list */}
<div className="space-y-1"> <div className="space-y-1">
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4> <h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>

View File

@@ -198,7 +198,7 @@ function SoundsTab() {
label={s.originalName} label={s.originalName}
duration={s.duration} duration={s.duration}
url={s.url} url={s.url}
onPlay={() => playSoundOnce(s.url)} onPlay={() => playSoundOnce(s.url, s.originalName)}
onDelete={() => deleteSound(filename)} onDelete={() => deleteSound(filename)}
/> />
); );
@@ -231,7 +231,7 @@ function SoundsTab() {
key={r.id} key={r.id}
name={r.name} name={r.name}
duration={r.duration} duration={r.duration}
onPreview={() => r.previewUrl && playSoundOnce(r.previewUrl)} onPreview={() => r.previewUrl && playSoundOnce(r.previewUrl, r.name)}
onImport={() => importSound(r)} onImport={() => importSound(r)}
/> />
))} ))}

View File

@@ -0,0 +1,42 @@
"use client";
import { useState, useEffect } from "react";
import { subscribe, getPlaybackState, stopCurrent, PlaybackState } from "@/lib/player";
export function SoundPlayerBar() {
const [s, setS] = useState<PlaybackState>(getPlaybackState());
useEffect(() => {
const unsub = subscribe(setS);
return unsub;
}, []);
if (!s.isPlaying) return null;
const pct = Math.min((s.elapsed / s.duration) * 100, 100);
return (
<div className="border-t border-slate-700/30 bg-slate-950/95 px-3 py-2">
<div className="flex items-center gap-2 text-[10px] font-mono max-w-2xl mx-auto">
<span className="text-cyan-400 shrink-0">🔊</span>
<span className="text-slate-300 truncate max-w-[200px]">{s.name}</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden min-w-[80px]">
<div
className="h-full bg-cyan-500 transition-all duration-100 rounded-full"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-slate-500 shrink-0 w-16 text-right tabular-nums">
{s.elapsed.toFixed(1)}s / {s.duration.toFixed(0)}s
</span>
<button
type="button"
onClick={stopCurrent}
className="px-2 py-0.5 rounded bg-slate-800 hover:bg-red-800/60 text-slate-400 hover:text-red-300 cursor-pointer shrink-0"
>
</button>
</div>
</div>
);
}

102
lib/player.ts Normal file
View 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();
}

View File

@@ -1,4 +1,4 @@
import { Howl } from "howler"; import { playUrl, stopCurrent } from "@/lib/player";
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
@@ -109,57 +109,28 @@ export function playChaosRiser() {
}); });
} }
// ─── Howl-based URL playback ────────────────────────────────────── // ─── URL playback via shared player ──────────────────────────────
const howlCache = new Map<string, Howl>();
/** /**
* Play a sound file URL (MP3/OGG) using howler.js. * Play a sound file URL (MP3/OGG). Goes through the shared player,
* Howler handles autoplay policy, cross-browser codec support, * which caps at 6s with fadeout and shows playback UI.
* and AudioContext resume better than raw new Audio().
*/ */
export function playSoundUrl(url: string) { export function playSoundUrl(url: string, name?: string) {
let h = howlCache.get(url); playUrl(url, name);
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();
} }
/** /**
* One-shot play of a sound URL. Same as playSoundUrl but always creates a fresh instance. * One-shot play with a display name. Same underlying player.
* Useful for preview/editor buttons where you want it to play every click.
*/ */
export function playSoundOnce(url: string) { export function playSoundOnce(url: string, name?: string) {
const h = new Howl({ playUrl(url, name);
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();
} }
/**
* Stop current playback immediately.
*/
export { stopCurrent, getPlaybackState, subscribe } from "@/lib/player";
export function playSound(category: string, soundUrl?: string | null) { export function playSound(category: string, soundUrl?: string | null) {
if (soundUrl) { if (soundUrl) {
playSoundUrl(soundUrl); playSoundUrl(soundUrl);