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 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 00d4f50ce9...72ef1b51aa
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' ? (
|
||||
<StagePanel onClose={() => setPanel(null)} label="Call volume">
|
||||
<div className="call-volume-panel">
|
||||
<span className="small muted">Call volume</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
step={5}
|
||||
value={Math.round((voicePrefs.outputVolume ?? 1) * 100)}
|
||||
aria-label="Call volume"
|
||||
onChange={(e) => store.setVoiceVolume(Number(e.target.value) / 100)}
|
||||
/>
|
||||
<span className="call-volume-value">{Math.round((voicePrefs.outputVolume ?? 1) * 100)}%</span>
|
||||
</div>
|
||||
</StagePanel>
|
||||
) : 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' ? (
|
||||
<StagePanel onClose={() => setPanel(null)} label="Soundboard">
|
||||
<Soundboard accountId={accountId} />
|
||||
</StagePanel>
|
||||
) : 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<HTMLDivElement>(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 (
|
||||
<div className="stage-panel" ref={box} role="dialog" aria-label={label}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<SoundboardSound[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void window.moho
|
||||
.rpc<SoundboardSound[]>('listSoundboard', { accountId })
|
||||
.then(setSounds)
|
||||
.catch(() => setSounds([]))
|
||||
}, [accountId])
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const groups = new Map<string, SoundboardSound[]>()
|
||||
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 (
|
||||
<div className="soundboard">
|
||||
<div className="emoji-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input autoFocus placeholder="Find a sound" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="soundboard-list">
|
||||
{sounds === null && <p className="small muted">Loading…</p>}
|
||||
{sounds !== null && groups.size === 0 && <p className="small muted">{q ? 'No matches.' : 'No sounds here.'}</p>}
|
||||
{[...groups.entries()].map(([group, list]) => (
|
||||
<div key={group}>
|
||||
<div className="emoji-section small muted">{group}</div>
|
||||
<div className="soundboard-grid">
|
||||
{list.map((s) => (
|
||||
<button
|
||||
key={`${group}:${s.id}`}
|
||||
type="button"
|
||||
className="soundboard-sound"
|
||||
disabled={s.locked}
|
||||
title={s.locked ? `${s.name} - needs Nitro outside its own server` : s.name}
|
||||
onClick={() => play(s)}
|
||||
>
|
||||
{s.emojiId ? (
|
||||
<img src={`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`} alt="" />
|
||||
) : s.emojiName ? (
|
||||
<span className="soundboard-emoji">{s.emojiName}</span>
|
||||
) : (
|
||||
<Icon name="music_note" size={16} />
|
||||
)}
|
||||
<span className="ellipsis">{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
>
|
||||
<ChoiceSetting
|
||||
@@ -301,30 +301,36 @@ function VoiceSettings(): JSX.Element {
|
||||
onChange={(id) => 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. */}
|
||||
<div className="setting-row">
|
||||
{/* 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. */}
|
||||
<label className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Call volume</div>
|
||||
<div>Echo cancellation</div>
|
||||
<div className="small muted">
|
||||
Everybody in a Discord call together. One person is set on their own tile, from the
|
||||
slider that appears over it.
|
||||
Takes what your speakers play back out of your microphone, so a call on speakers is not
|
||||
sent back into itself. Off only makes sense with headphones.
|
||||
</div>
|
||||
</div>
|
||||
<span className="call-volume">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
step={5}
|
||||
value={Math.round((voice.outputVolume ?? 1) * 100)}
|
||||
aria-label="Call volume"
|
||||
onChange={(e) => store.setVoiceVolume(Number(e.target.value) / 100)}
|
||||
disabled={none}
|
||||
/>
|
||||
<span className="small muted">{Math.round((voice.outputVolume ?? 1) * 100)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="setting-toggle"
|
||||
checked={voice.echoCancellation ?? true}
|
||||
onChange={(e) => void store.setVoiceProcessing({ echoCancellation: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Noise suppression</div>
|
||||
<div className="small muted">Takes steady background noise - a fan, a hum - out of your microphone.</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="setting-toggle"
|
||||
checked={voice.noiseSuppression ?? true}
|
||||
onChange={(e) => void store.setVoiceProcessing({ noiseSuppression: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
try {
|
||||
this.set({ voicePrefs: await window.moho.rpc<VoicePrefs>('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<void> {
|
||||
try {
|
||||
|
||||
@@ -198,6 +198,10 @@ export interface VoicePrefs {
|
||||
outputVolume?: number
|
||||
/** People turned up or down, by Discord user id. Absent is 1. */
|
||||
userVolumes?: Record<string, number>
|
||||
/** 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. */
|
||||
|
||||
Reference in New Issue
Block a user