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:
+19
-20
@@ -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 {
|
||||
|
||||
@@ -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)}%`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user