From ccd9936757ed818e8aad3d89d3c7df376d08c830 Mon Sep 17 00:00:00 2001 From: Salastil Date: Thu, 1 Oct 2026 15:19:48 -0400 Subject: [PATCH] Discord calls: call volume and soundboard on the stage, voice processing switches The call's volume moves from Settings onto the stage, as a button with its slider above it, where it can be changed without leaving the call. A soundboard button in a guild's voice channel opens the board: this server's sounds, Discord's defaults, then other servers' (needing Nitro), searchable. Settings > Voice gains Echo cancellation and Noise suppression. Co-Authored-By: Claude Opus 5.5 --- nobilis | 2 +- src/renderer/src/app.css | 83 ++++++++++ src/renderer/src/components/CallView.tsx | 156 ++++++++++++++++++ .../src/components/settings/SettingsPanel.tsx | 48 +++--- src/renderer/src/state/store.ts | 9 + src/shared/wire.ts | 4 + 6 files changed, 280 insertions(+), 22 deletions(-) diff --git a/nobilis b/nobilis index 00d4f50..72ef1b5 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 00d4f50ce9dd8591f0c14c45f8fb135bf1f59c57 +Subproject commit 72ef1b51aa100e73865855ae07ed0bb1d4df061d diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index b92d216..1191ed5 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -7604,3 +7604,86 @@ video.lightbox-media { min-width: 0; font-weight: 600; } + +/* A small panel above a call button: the call's volume, the soundboard. */ +.stage-panel { + padding: var(--spacing-m); + border: 1px solid var(--outline-medium); + border-radius: var(--corner-radius); + background: var(--surface-container-high); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +.call-volume-panel { + display: flex; + align-items: center; + gap: 10px; + font-variant-numeric: tabular-nums; +} + +.call-volume-panel input { + width: 180px; + accent-color: var(--primary); +} + +.call-volume-value { + min-width: 3.5em; + text-align: right; +} + +.soundboard { + display: flex; + flex-direction: column; + gap: var(--spacing-s); + width: min(380px, 90vw); +} + +.soundboard .emoji-search { + margin: 0; +} + +.soundboard-list { + max-height: 320px; + overflow-y: auto; +} + +.soundboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 6px; +} + +.soundboard-sound { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 8px 10px; + border: 0; + border-radius: var(--corner-radius-small); + background: var(--surface-container-highest); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.soundboard-sound:hover:not(:disabled) { + background: var(--primary-hover-light, var(--surface-container-highest)); + outline: 1px solid var(--primary); +} + +.soundboard-sound:disabled { + opacity: 0.4; + cursor: default; +} + +.soundboard-sound img { + width: 18px; + height: 18px; +} + +.soundboard-emoji { + font-size: 16px; + line-height: 1; +} diff --git a/src/renderer/src/components/CallView.tsx b/src/renderer/src/components/CallView.tsx index 570d39a..15f61ea 100644 --- a/src/renderer/src/components/CallView.tsx +++ b/src/renderer/src/components/CallView.tsx @@ -82,6 +82,8 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: // Only this call's account: one window can be in two accounts' calls. const ownCamera = myCamera?.accountId === session.accountId ? myCamera.stream : null const [choosing, setChoosing] = useState(false) + /** Which of the stage's two small panels is open: the call's volume, or the soundboard. */ + const [panel, setPanel] = useState<'volume' | 'soundboard' | null>(null) const shareStream = useChat((s) => s.discordShareStream) const watching = useChat((s) => s.discordWatching) const voicePrefs = useChat((s) => s.voicePrefs) @@ -263,6 +265,51 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: /> ) : null }, + // The whole call's loudness, on the call rather than in Settings: it is + // changed while listening, by somebody who should not have to leave the + // call to do it. + { + icon: (voicePrefs.outputVolume ?? 1) === 0 ? 'volume_off' : (voicePrefs.outputVolume ?? 1) < 1 ? 'volume_down' : 'volume_up', + label: 'Call volume', + active: panel === 'volume', + onClick: () => setPanel(panel === 'volume' ? null : 'volume'), + popover: + panel === 'volume' ? ( + setPanel(null)} label="Call volume"> +
+ Call volume + store.setVoiceVolume(Number(e.target.value) / 100)} + /> + {Math.round((voicePrefs.outputVolume ?? 1) * 100)}% +
+
+ ) : null + }, + // Sounds anybody in a guild's voice channel can set off. Not in a one-to- + // one call, which Discord gives no soundboard. + ...(session.guildId + ? [ + { + icon: 'graphic_eq', + label: 'Soundboard', + active: panel === 'soundboard', + onClick: () => setPanel(panel === 'soundboard' ? null : 'soundboard'), + popover: + panel === 'soundboard' ? ( + setPanel(null)} label="Soundboard"> + + + ) : null + } + ] + : []), // A stage's own two controls. In the audience: put a hand up, and once // it is up - yours, or a moderator inviting you - step onto the stage. // On the stage: step back down. Discord refuses a step up nobody allowed, @@ -443,3 +490,112 @@ function volumeBadge(volume: number | undefined): string | undefined { if (volume === undefined || Math.abs(volume - 1) < 0.005) return undefined return `${Math.round(volume * 100)}%` } + +/** + * A small panel above a stage button, closed by Escape or a press anywhere + * else - except on the stage's buttons, which open and close it themselves. + */ +function StagePanel({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }): JSX.Element { + const box = useRef(null) + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + const onDown = (e: MouseEvent): void => { + const target = e.target as HTMLElement + if (box.current?.contains(target) || target.closest('.stage-button')) return + onClose() + } + window.addEventListener('keydown', onKey) + window.addEventListener('mousedown', onDown) + return () => { + window.removeEventListener('keydown', onKey) + window.removeEventListener('mousedown', onDown) + } + }, [onClose]) + return ( +
+ {children} +
+ ) +} + +interface SoundboardSound { + id: string + name: string + group: string + guildId?: string + emojiName?: string + emojiId?: string + locked: boolean +} + +/** + * The soundboard: everything this account can play here, grouped as Discord + * groups it - this server's own, Discord's defaults, then the others, which + * need Nitro. Pressing one plays it to the whole channel, this end included. + */ +function Soundboard({ accountId }: { accountId: string }): JSX.Element { + const store = useStore() + const [sounds, setSounds] = useState(null) + const [query, setQuery] = useState('') + + useEffect(() => { + void window.moho + .rpc('listSoundboard', { accountId }) + .then(setSounds) + .catch(() => setSounds([])) + }, [accountId]) + + const q = query.trim().toLowerCase() + const groups = new Map() + for (const s of sounds ?? []) { + if (q && !s.name.toLowerCase().includes(q)) continue + groups.set(s.group, [...(groups.get(s.group) ?? []), s]) + } + + const play = (s: SoundboardSound): void => { + void window.moho + .rpc('playSoundboard', { accountId, soundId: s.id, guildId: s.guildId }) + .catch((e: Error) => store.toast('error', `Couldn't play ${s.name}: ${e.message}`)) + } + + return ( +
+
+ + setQuery(e.target.value)} /> +
+
+ {sounds === null &&

Loading…

} + {sounds !== null && groups.size === 0 &&

{q ? 'No matches.' : 'No sounds here.'}

} + {[...groups.entries()].map(([group, list]) => ( +
+
{group}
+
+ {list.map((s) => ( + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index 3921bb6..7487dd9 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -283,7 +283,7 @@ function VoiceSettings(): JSX.Element { description={ none ? "No sound devices were found. Voice needs a running sound server; everything else works without one." - : 'Used for voice calls. A change takes effect on a call already in progress. Muting is on the status plaque at the foot of the channel list, where it stays reachable during one.' + : 'Used for voice calls. A change takes effect on a call already in progress. Muting is on the status plaque at the foot of the channel list, and the call\'s volume on the call itself, where both stay reachable during one.' } > void store.setVoiceDevice('output', id)} disabled={none} /> - {/* The whole call at once, for one that is simply too loud. One - person is turned up or down on their own tile in the call. */} -
+ {/* What a Discord call's microphone goes through before it is sent - + the processing a Matrix call gets from the browser. The call's + volume is on the call itself, not here. */} +
+ void store.setVoiceProcessing({ echoCancellation: e.target.checked })} + /> + + ) } diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index fc7102c..3506971 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1382,6 +1382,15 @@ export class ChatStore { }, 150) } + /** Echo cancellation and noise suppression on a Discord call's microphone. */ + async setVoiceProcessing(change: { echoCancellation?: boolean; noiseSuppression?: boolean }): Promise { + try { + this.set({ voicePrefs: await window.moho.rpc('setVoiceProcessing', change) }) + } catch (e) { + this.toast('error', `Couldn't change that: ${(e as Error).message}`) + } + } + /** On a stage: asks to speak, or takes the request back. */ async setStageHand(accountId: string, guildId: string, channelId: string, on: boolean): Promise { try { diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 0524925..e6eaa9f 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -198,6 +198,10 @@ export interface VoicePrefs { outputVolume?: number /** People turned up or down, by Discord user id. Absent is 1. */ userVolumes?: Record + /** Echo cancellation on a Discord call's microphone. Absent is on. */ + echoCancellation?: boolean + /** Noise suppression on it. Absent is on. */ + noiseSuppression?: boolean } /** Someone sitting in a voice channel. */