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:
@@ -98,6 +98,7 @@ import {
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||
import { playEntry } from '../lib/soundboardPlayback';
|
||||
import {
|
||||
getPrefs as getSoundboardPrefs,
|
||||
@@ -2257,12 +2258,22 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||
}, []);
|
||||
|
||||
// Global soundboard hotkey registration — runs only while connected so the
|
||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
||||
// Global soundboard hotkey registration — always-on so the OS-level
|
||||
// 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(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const teardown = startSoundboardHotkeys((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;
|
||||
}, [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();
|
||||
}
|
||||
Reference in New Issue
Block a user