feat(soundboard): hotkeys fire outside calls with local-only playback

Lifts the `state.kind === 'connected'` guard from the soundboard hotkey
useEffect so OS-level shortcuts are always registered. Inside a call the
existing `playSoundboard` path routes audio into the LiveKit pipeline so
peers hear; outside a call the new `playSoundboardLocal` helper fetches
the blob via `getSoundBlob`, creates a short-lived object URL, and plays
through a fresh HTMLAudioElement on the system default output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-17 17:33:50 +02:00
parent 3fa8b6dbc1
commit 11869be443
2 changed files with 59 additions and 4 deletions
+15 -4
View File
@@ -98,6 +98,7 @@ import {
subscribeScreenShareVolumes, subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes'; } from '../lib/screenShareVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys'; import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
import { playEntry } from '../lib/soundboardPlayback'; import { playEntry } from '../lib/soundboardPlayback';
import { import {
getPrefs as getSoundboardPrefs, getPrefs as getSoundboardPrefs,
@@ -2257,12 +2258,22 @@ export function CallProvider({ children }: { children: ReactNode }) {
pipelineRef.current?.setMonitorGain(prefs.monitorGain); pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []); }, []);
// Global soundboard hotkey registration — runs only while connected so the // Global soundboard hotkey registration — always-on so the OS-level
// OS-level shortcuts don't fire when the user is outside of a call. // shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
// a call we route through `playSoundboard` so peers hear; outside a call
// we fall back to `playSoundboardLocal` which plays through the system
// default output only.
useEffect(() => { useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => { const teardown = startSoundboardHotkeys((id) => {
void playSoundboard(id); if (state.kind === 'connected') {
void playSoundboard(id);
} else {
void (async () => {
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (entry) await playSoundboardLocal(entry);
})();
}
}); });
return teardown; return teardown;
}, [state.kind, playSoundboard]); }, [state.kind, playSoundboard]);
@@ -0,0 +1,44 @@
// Plays a soundboard entry to the local default audio output. Used when no
// call pipeline is active (the in-call path routes via the LiveKit
// publishing pipeline so peers hear; this path is local-only). Fetches the
// blob via getSoundBlob and creates a short-lived object URL for the audio
// element.
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
const activeAudios = new Set<HTMLAudioElement>();
export async function playSoundboardLocal(entry: SoundboardEntry): Promise<void> {
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const src = URL.createObjectURL(blob);
const el = new Audio(src);
// Use the per-entry gain as local volume. SoundboardEntry exposes `gain`
// (0..1) which mirrors the value used in the in-call pipeline.
el.volume = Math.max(0, Math.min(1, entry.gain));
activeAudios.add(el);
const cleanup = () => {
activeAudios.delete(el);
URL.revokeObjectURL(src);
};
el.addEventListener('ended', cleanup);
el.addEventListener('error', cleanup);
try {
await el.play();
} catch (err) {
cleanup();
console.warn('soundboardLocalPlay failed', err);
}
}
export function stopSoundboardLocal(): void {
for (const el of activeAudios) {
try {
el.pause();
el.currentTime = 0;
} catch {
/* ignore */
}
}
activeAudios.clear();
}