Discord calls: your own share on screen, volume sliders, stages

#233: while sharing, your screen's tile shows the capture itself, so the
right window and a moving picture can be seen without asking anybody.

#235: every other person's tile has a volume slider on hover, 0-200%; once
moved off 100% the percentage stays on the tile. Settings > Voice has the
whole call's volume. Changes show at once and are sent 150ms later.

#181: stages show in the voice list with their own icon and live topic,
speakers first and the audience marked listening, raised hands shown. In
the call, only speakers get tiles and the audience is counted; the
controls are Ask to speak / Lower your hand, Step onto the stage once a
hand is up, and Move to the audience when on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-01 14:31:59 -04:00
co-authored by Claude Opus 5.5
parent c5c87edd84
commit f7007136ea
8 changed files with 266 additions and 15 deletions
+1 -1
Submodule nobilis updated: 15007d67be...4a5a31c232
+53
View File
@@ -7392,3 +7392,56 @@ video.lightbox-media {
.stage-picture.mirrored {
transform: scaleX(-1);
}
/* A live stage's topic, under its name in the voice list. */
.voice-stage-topic {
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. */
.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;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
font-variant-numeric: tabular-nums;
cursor: default;
}
.stage-tile-volume input {
width: 90px;
accent-color: #fff;
}
.stage-tile:hover .stage-tile-volume,
.stage-tile-volume:focus-within {
display: inline-flex;
}
.stage-tile-volume.changed {
display: inline-flex;
}
.stage-tile:not(:hover) .stage-tile-volume.changed:not(:focus-within) input {
display: none;
}
.call-volume {
display: inline-flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.call-volume input {
width: 160px;
}
+73 -7
View File
@@ -82,6 +82,7 @@ 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)
const shareStream = useChat((s) => s.discordShareStream)
const watching = useChat((s) => s.discordWatching)
const voicePrefs = useChat((s) => s.voicePrefs)
const [members, setMembers] = useState<VoiceMember[]>([])
@@ -135,8 +136,16 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
}
}, [accountId])
// A stage is listened to, mostly. Only the speakers get tiles - an
// audience can be hundreds, and a grid of them would bury the people
// talking - and the audience is counted instead.
const stage = !!session.stage
const me = members.find((m) => m.isSelf)
const onStage = stage ? members.filter((m) => !m.suppressed) : members
const audience = stage ? members.length - onStage.length : 0
const tiles: StageTile[] = []
for (const m of members) {
for (const m of onStage) {
const camera = cameras[`${accountId}|${m.userId}`]
tiles.push({
id: m.userId,
@@ -152,7 +161,15 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
? () => <LocalPicture stream={ownCamera} />
: camera
? () => <FeedPicture feedKey={cameraKey(accountId, m.userId)} />
: undefined
: undefined,
// Everybody but you can be turned up or down, and it holds across
// calls, as Discord's own does.
volume: m.isSelf
? undefined
: {
value: voicePrefs.userVolumes?.[m.userId] ?? 1,
onChange: (v) => store.setVoiceVolume(v, 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.
@@ -163,7 +180,15 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
name: m.isSelf ? 'Your screen' : `${m.nick}’s screen`,
avatarUrl: m.avatarUrl,
live: true,
picture: watched ? () => <FeedPicture feedKey={streamKey(accountId, watching.streamKey)} /> : undefined,
// Your own share drawn from the capture itself: the one way to see
// the right window was picked and that it is still moving, without
// asking somebody watching.
picture:
m.isSelf && sharing && shareStream
? () => <LocalPicture stream={shareStream} mirrored={false} />
: watched
? () => <FeedPicture feedKey={streamKey(accountId, watching.streamKey)} />
: undefined,
// This account can be signed in here and in Discord's own client at
// once, and then the stream is this account's and there is nothing
// here to open.
@@ -223,6 +248,38 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
/>
) : 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,
// and says so.
...(stage && me && session.guildId
? me.suppressed
? [
{
icon: 'front_hand',
label: me.handRaised ? 'Lower your hand' : 'Ask to speak',
active: !!me.handRaised,
onClick: () => void store.setStageHand(accountId, session.guildId!, channelId, !me.handRaised)
},
...(me.handRaised
? [
{
icon: 'podium',
label: 'Step onto the stage',
onClick: () => void store.setStageSpeaker(accountId, session.guildId!, channelId, true)
}
]
: [])
]
: [
{
icon: 'podium',
label: 'Move to the audience',
active: true,
onClick: () => void store.setStageSpeaker(accountId, session.guildId!, channelId, false)
}
]
: []),
{ icon: 'call_end', label: 'Disconnect', danger: true, onClick: () => void store.leaveVoice(accountId) }
]
@@ -231,7 +288,13 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
return (
<Stage
title={title}
subtitle={members.length > 0 ? `${members.length} in the call` : 'Connecting…'}
subtitle={
members.length === 0
? 'Connecting…'
: stage
? `${onStage.length} on stage · ${audience} listening${me?.suppressed ? ' · you are in the audience' : ''}`
: `${members.length} in the call`
}
tiles={tiles}
buttons={buttons}
where={where}
@@ -241,14 +304,17 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
)
}
/** This end's own camera, from the capture. Mirrored, as a mirror is. */
function LocalPicture({ stream }: { stream: MediaStream }): JSX.Element {
/**
* Something this end is sending, from the capture: a camera mirrored, as a
* mirror is, and a screen as it is.
*/
function LocalPicture({ stream, mirrored = true }: { stream: MediaStream; mirrored?: boolean }): JSX.Element {
return (
<video
ref={(el) => {
if (el && el.srcObject !== stream) el.srcObject = stream
}}
className="stage-picture mirrored"
className={mirrored ? 'stage-picture mirrored' : 'stage-picture'}
autoPlay
playsInline
muted
+15 -3
View File
@@ -38,16 +38,20 @@ export function VoiceChannels({ group }: { group: RailGroup }): JSX.Element | nu
{channels.map((c) => {
const here = session?.channelId === c.id
const members = c.members ?? []
// On a stage the speakers come first and the audience after, which
// is the order anybody deciding whether to listen reads it in.
const members = c.stage
? [...(c.members ?? [])].sort((a, b) => Number(!!a.suppressed) - Number(!!b.suppressed))
: (c.members ?? [])
return (
<div key={c.id} className={`voice-channel${here ? ' active' : ''}`}>
<button
type="button"
className="voice-channel-row"
title={here ? `Connected to ${c.name}` : `Join ${c.name}`}
title={here ? `Connected to ${c.name}` : c.stage ? `Listen to ${c.name}` : `Join ${c.name}`}
onClick={() => (here ? void store.leaveVoice(group.accountId) : void store.joinVoice(group.accountId, guild, c.id))}
>
<Icon name="volume_up" size={16} />
<Icon name={c.stage ? 'podium' : 'volume_up'} size={16} />
<span className="ellipsis">{c.name}</span>
{/* A limit only means something once it is close to being hit. */}
{c.userLimit > 0 && members.length >= c.userLimit - 1 && (
@@ -57,12 +61,20 @@ export function VoiceChannels({ group }: { group: RailGroup }): JSX.Element | nu
)}
{here && <IconButton name="call_end" size={16} title="Disconnect" onClick={() => void store.leaveVoice(group.accountId)} />}
</button>
{/* What a live stage is about. A stage with no topic is not on. */}
{c.stage && c.topic && <div className="voice-stage-topic small muted ellipsis">{c.topic}</div>}
{members.map((m) => (
<div key={m.userId} className="voice-member small">
<span className="voice-member-dot" style={{ background: nickColor(m.nick) }} />
<span className="ellipsis">{m.nick}</span>
{m.isSelf && <span className="muted voice-you">you</span>}
{c.stage && m.suppressed && !m.handRaised && <span className="muted voice-you">listening</span>}
{c.stage && m.handRaised && (
<span className="muted" title={`${m.nick} has asked to speak`}>
<Icon name="front_hand" size={13} />
</span>
)}
{/* Here as well as in the call view, because this is the list
you read to decide whether to join at all - and somebody
sharing a screen is the commonest reason to. */}
@@ -301,6 +301,30 @@ 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">
<div className="setting-text">
<div>Call volume</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.
</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>
</SettingsSection>
)
}
@@ -42,6 +42,11 @@ export interface StageTile {
action?: { label: string; onClick: () => void }
/** 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.
*/
volume?: { value: number; onChange: (value: number) => void }
}
export interface StageButton {
@@ -249,6 +254,29 @@ 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>
)}
<span className="stage-tile-name">
{tile.deafened ? (
<Icon name="headset_off" size={14} className="stage-tile-state" />
+58 -4
View File
@@ -544,6 +544,8 @@ export interface ChatState {
* accounts' calls, and only one of them is sharing.
*/
discordSharing: string | null
/** What is being shared, so the person sharing can see it. */
discordShareStream: MediaStream | null
/**
* This end's camera in a Discord call, while it is on: the capture, so the
* call can show you what everybody else sees.
@@ -677,6 +679,7 @@ const INITIAL: ChatState = {
discordModal: null,
activeCall: null,
discordSharing: null,
discordShareStream: null,
discordCamera: null,
discordStreams: {},
discordCameras: {},
@@ -1348,6 +1351,57 @@ export class ChatStore {
}
}
/** Volume changes not yet sent, by who they are for ('' is the whole call). */
private pendingVolumes = new Map<string, number>()
private volumeTimer: ReturnType<typeof setTimeout> | null = null
/**
* Sets how loud a call is - overall, or one person in it.
*
* Shown at once and sent a moment later: a slider being dragged reports
* every step, and each one sent would rewrite the preferences file.
*/
setVoiceVolume(volume: number, userId?: string): void {
const prefs = this.state.voicePrefs
if (userId) {
this.set({ voicePrefs: { ...prefs, userVolumes: { ...(prefs.userVolumes ?? {}), [userId]: volume } } })
} else {
this.set({ voicePrefs: { ...prefs, outputVolume: volume } })
}
this.pendingVolumes.set(userId ?? '', volume)
if (this.volumeTimer) return
this.volumeTimer = setTimeout(() => {
this.volumeTimer = null
const changes = [...this.pendingVolumes.entries()]
this.pendingVolumes.clear()
for (const [user, value] of changes) {
void window.moho
.rpc<VoicePrefs>('setVoiceVolume', user ? { userId: user, volume: value } : { volume: value })
.catch((e: Error) => this.toast('error', `Couldn't change the volume: ${e.message}`))
}
}, 150)
}
/** On a stage: asks to speak, or takes the request back. */
async setStageHand(accountId: string, guildId: string, channelId: string, on: boolean): Promise<void> {
try {
await window.moho.rpc('setStageHand', { accountId, guildId, channelId, on })
await this.refreshVoiceChannels(accountId, guildId)
} catch (e) {
this.toast('error', (e as Error).message)
}
}
/** On a stage: steps up to the speakers, or back into the audience. */
async setStageSpeaker(accountId: string, guildId: string, channelId: string, on: boolean): Promise<void> {
try {
await window.moho.rpc('setStageSpeaker', { accountId, guildId, channelId, on })
await this.refreshVoiceChannels(accountId, guildId)
} catch (e) {
this.toast('error', (e as Error).message)
}
}
/**
* The voice channels of whichever guild is on screen.
*
@@ -1785,7 +1839,7 @@ export class ChatStore {
if (this.discordShare && this.state.discordSharing === accountId) {
this.discordShare.stop()
this.discordShare = null
this.set({ discordSharing: null })
this.set({ discordSharing: null, discordShareStream: null })
}
try {
await window.moho.rpc('leaveVoiceChannel', { accountId })
@@ -2568,7 +2622,7 @@ export class ChatStore {
const was = this.state.discordSharing
this.discordShare.stop()
this.discordShare = null
this.set({ discordSharing: null })
this.set({ discordSharing: null, discordShareStream: null })
await window.moho.rpc('stopDiscordScreenShare', { bufferId: this.discordShareBuffer ?? bufferId }).catch(() => {})
// One screen, one stream: pressing share in another account's call
// moves it there rather than only stopping it.
@@ -2610,10 +2664,10 @@ export class ChatStore {
this.discordShare = await shareScreen(accountId, stream, settings, (message) => {
this.toast('error', message)
this.discordShare = null
this.set({ discordSharing: null })
this.set({ discordSharing: null, discordShareStream: null })
void window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
})
this.set({ discordSharing: accountId })
this.set({ discordSharing: accountId, discordShareStream: stream })
} catch (e) {
this.toast('error', `Couldn't share the screen: ${(e as Error).message}`)
await window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
+14
View File
@@ -194,6 +194,10 @@ export interface VoicePrefs {
output?: string
micMuted: boolean
deafened: boolean
/** A call's overall loudness, 1 as it arrives, up to 2. */
outputVolume?: number
/** People turned up or down, by Discord user id. Absent is 1. */
userVolumes?: Record<string, number>
}
/** Someone sitting in a voice channel. */
@@ -223,6 +227,10 @@ export interface VoiceMember {
video?: boolean
muted?: boolean
deafened?: boolean
/** On a stage: in the audience rather than among the speakers. */
suppressed?: boolean
/** On a stage: has asked to speak, or been invited up. */
handRaised?: boolean
}
/**
@@ -306,6 +314,10 @@ export interface VoiceChannel {
* before an upgrade.
*/
members?: VoiceMember[]
/** A stage: speakers and an audience rather than a room everybody talks in. */
stage?: boolean
/** What a stage is about, while it is live. */
topic?: string
}
/**
@@ -332,6 +344,8 @@ export interface VoiceSession {
isDirect: boolean
/** The conversation the call is in, where one is known. */
bufferId?: string
/** A stage channel: speakers and an audience. */
stage?: boolean
}
export interface ReplyPreview {