Discord calls: a person's volume is in their right-click menu

As in Discord: right-clicking somebody's tile on the call stage opens a
menu with User Volume (0-200%) and Copy User ID. The slider stays open
while it is dragged. Context menus gain a slider entry for it.

The hover slider is gone. A volume moved off 100% still shows on the tile,
read-only, so nobody is left turned down without noticing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-01 14:39:42 -04:00
co-authored by Claude Opus 5.5
parent f7007136ea
commit b72633f3f0
4 changed files with 106 additions and 54 deletions
+19 -20
View File
@@ -7398,41 +7398,40 @@ video.lightbox-media {
padding: 0 var(--spacing-s) 2px 30px;
}
/* One person's volume, on their tile. Shown on hover; the percentage stays
once it has been moved off 100, so nobody is left turned down unawares. */
/* A volume set away from 100%, on that person's tile. Read-only: it is
changed from the tile's right-click menu. */
.stage-tile-volume {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
display: none;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 14px;
padding: 2px 7px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
font-variant-numeric: tabular-nums;
cursor: default;
pointer-events: none;
}
.stage-tile-volume input {
width: 90px;
accent-color: #fff;
/* A slider in a context menu: label and value above, the track below. */
.context-menu-slider {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
min-width: 200px;
}
.stage-tile:hover .stage-tile-volume,
.stage-tile-volume:focus-within {
display: inline-flex;
.context-menu-slider-label {
display: flex;
justify-content: space-between;
font-variant-numeric: tabular-nums;
}
.stage-tile-volume.changed {
display: inline-flex;
}
.stage-tile:not(:hover) .stage-tile-volume.changed:not(:focus-within) input {
display: none;
.context-menu-slider input {
width: 100%;
accent-color: var(--primary);
}
.call-volume {
+28 -7
View File
@@ -162,14 +162,29 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
: camera
? () => <FeedPicture feedKey={cameraKey(accountId, m.userId)} />
: undefined,
// Everybody but you can be turned up or down, and it holds across
// calls, as Discord's own does.
volume: m.isSelf
// Everybody but you can be turned up or down from their own
// right-click menu, as in Discord, and it holds across calls.
menu: m.isSelf
? undefined
: {
value: voicePrefs.userVolumes?.[m.userId] ?? 1,
onChange: (v) => store.setVoiceVolume(v, m.userId)
}
: [
{
slider: true,
label: 'User Volume',
value: Math.round((voicePrefs.userVolumes?.[m.userId] ?? 1) * 100),
min: 0,
max: 200,
step: 5,
format: (v) => `${v}%`,
onChange: (v) => store.setVoiceVolume(v / 100, m.userId)
},
{ separator: true },
{
label: 'Copy User ID',
icon: 'badge',
onClick: () => void navigator.clipboard.writeText(m.userId).catch(() => {})
}
],
volumeBadge: volumeBadge(voicePrefs.userVolumes?.[m.userId])
})
// A shared screen is a tile of its own, the way Discord shows it: dark
// until somebody chooses to watch, because watching opens a connection.
@@ -422,3 +437,9 @@ function GoLiveChooser({
</div>
)
}
/** A volume worth showing on a tile: anything but 100%. */
function volumeBadge(volume: number | undefined): string | undefined {
if (volume === undefined || Math.abs(volume - 1) < 0.005) return undefined
return `${Math.round(volume * 100)}%`
}
+34 -1
View File
@@ -15,7 +15,24 @@ export interface MenuSeparator {
separator: true
}
export type MenuEntry = MenuItem | MenuSeparator
/**
* A value set by dragging, which stays open while it is dragged - the way
* Discord puts "User Volume" in the menu of the person it is for.
*/
export interface MenuSlider {
slider: true
separator?: false
label: string
value: number
min: number
max: number
step: number
/** How the value reads beside the label. */
format: (value: number) => string
onChange: (value: number) => void
}
export type MenuEntry = MenuItem | MenuSeparator | MenuSlider
interface Props {
x: number
@@ -78,6 +95,22 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
{entries.map((entry, i) =>
entry.separator ? (
<div key={i} className="context-menu-separator" />
) : 'slider' in entry ? (
<label key={i} className="context-menu-slider">
<span className="context-menu-slider-label">
<span>{entry.label}</span>
<span className="muted">{entry.format(entry.value)}</span>
</span>
<input
type="range"
min={entry.min}
max={entry.max}
step={entry.step}
value={entry.value}
aria-label={entry.label}
onChange={(e) => entry.onChange(Number(e.target.value))}
/>
</label>
) : (
<button
key={i}
+25 -26
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type R
import { createPortal } from 'react-dom'
import { Icon } from '../Icon'
import { Avatar } from '../Avatar'
import { ContextMenu, type MenuEntry } from '../ContextMenu'
import { usePref } from '../../state/hooks'
import { classes, nickColor } from '../../lib/util'
@@ -43,10 +44,15 @@ export interface StageTile {
/** A small button in the corner of a tile with one: "Stop watching". */
dismiss?: { label: string; onClick: () => void }
/**
* How loud this person is, 1 as they arrive, up to 2 - where the call can
* turn one person up or down. Absent for yourself.
* What right-clicking the tile offers - a person's volume, as in Discord.
* Absent, the tile has no menu.
*/
volume?: { value: number; onChange: (value: number) => void }
menu?: MenuEntry[]
/**
* A volume somebody set away from 100%, shown on the tile so nobody is
* left turned down without noticing. Set in the menu, not here.
*/
volumeBadge?: string
}
export interface StageButton {
@@ -129,6 +135,10 @@ export function Stage({
const root = useRef<HTMLDivElement>(null)
const area = useRef<HTMLDivElement>(null)
const [focused, setFocused] = useState<string | null>(null)
/** A tile's right-click menu, while open. Looked up by id on every draw, so
* a slider in it shows the value it has just been dragged to. */
const [menu, setMenu] = useState<{ x: number; y: number; tileId: string } | null>(null)
const menuEntries = menu ? tiles.find((t) => t.id === menu.tileId)?.menu : undefined
const [room, setRoom] = useState({ width: 640, height: 300 })
const [collapsed, setCollapsed] = usePref<boolean>('ui.stageCollapsed', false)
/**
@@ -213,6 +223,14 @@ export function Stage({
}}
title={stage?.id === tile.id ? 'Back to everybody' : `Focus on ${tile.name}`}
onClick={() => setFocused(stage?.id === tile.id ? null : tile.id)}
onContextMenu={
tile.menu
? (e) => {
e.preventDefault()
setMenu({ x: e.clientX, y: e.clientY, tileId: tile.id })
}
: undefined
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
@@ -254,29 +272,7 @@ export function Stage({
<Icon name="close" size={16} />
</button>
)}
{tile.volume && slot === 'main' && (
// A slider on the tile rather than in a menu: the person who is too
// loud is the one being looked at. Clicks stay here, or every drag
// would also focus the tile.
<label
className={classes('stage-tile-volume', Math.abs(tile.volume.value - 1) > 0.005 && 'changed')}
title={`${tile.name}'s volume`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Icon name={tile.volume.value === 0 ? 'volume_off' : tile.volume.value < 1 ? 'volume_down' : 'volume_up'} size={15} />
<input
type="range"
min={0}
max={200}
step={5}
value={Math.round(tile.volume.value * 100)}
aria-label={`${tile.name}'s volume`}
onChange={(e) => tile.volume?.onChange(Number(e.target.value) / 100)}
/>
<span className="stage-tile-volume-value">{Math.round(tile.volume.value * 100)}%</span>
</label>
)}
{tile.volumeBadge && slot === 'main' && <span className="stage-tile-volume">{tile.volumeBadge}</span>}
<span className="stage-tile-name">
{tile.deafened ? (
<Icon name="headset_off" size={14} className="stage-tile-state" />
@@ -368,6 +364,9 @@ export function Stage({
<ControlButton key={b.label} button={b} />
))}
</div>
{menu && menuEntries && (
<ContextMenu x={menu.x} y={menu.y} entries={menuEntries} onClose={() => setMenu(null)} />
)}
{where === 'inline' && !fullscreen && (
<div
className="stage-resize"