From 11869be44393ebd1f75f5aab12894ef695c62bdb Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 17:33:50 +0200 Subject: [PATCH] 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 --- apps/desktop/src/context/CallContext.tsx | 19 +++++++-- apps/desktop/src/lib/soundboardLocalPlay.ts | 44 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/soundboardLocalPlay.ts diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index ad97c05..fcce19a 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -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) => { - 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; }, [state.kind, playSoundboard]); diff --git a/apps/desktop/src/lib/soundboardLocalPlay.ts b/apps/desktop/src/lib/soundboardLocalPlay.ts new file mode 100644 index 0000000..605ba6e --- /dev/null +++ b/apps/desktop/src/lib/soundboardLocalPlay.ts @@ -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(); + +export async function playSoundboardLocal(entry: SoundboardEntry): Promise { + 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(); +}