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:
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
42
components/SoundPlayerBar.tsx
Normal file
42
components/SoundPlayerBar.tsx
Normal 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
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;
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user