Discord calls: per-account camera and share, sticker grid at sticker size

A window can be in two accounts' calls. The camera and the screen share are
now each held against the account they belong to, the stage's buttons act
on the stage's own account, and a share is stopped through the conversation
it was started in. Previously the share button took the first Discord call
in the window, whichever account that was.

Stickers were drawn at emoji size in an emoji-width grid; the cell now
lifts the emoji cap and a grid of stickers gets 64px columns. The Go Live
chooser's segments sit at their own width.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-01 09:40:22 -04:00
co-authored by Claude Opus 5.5
parent fb5724afa6
commit 414730f979
4 changed files with 63 additions and 25 deletions
+1 -1
Submodule nobilis updated: ac6ef9cbb5...7b6441497f
+15
View File
@@ -2890,6 +2890,12 @@ img.smilie {
gap: 2px;
}
/* Stickers are bigger than emoji, so their columns are too. Chosen by what
the grid holds rather than by a prop, so every caller gets it. */
.emoji-grid:has(> .sticker-cell) {
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
}
/* Holds a section's full height while only the rows near the viewport are
built inside it, so the sections below keep their place and the scrollbar
does not twitch as rows come and go. The grid it wraps is shifted down to
@@ -3877,6 +3883,9 @@ img.smilie {
.emoji-cell.sticker-cell img {
width: 100%;
height: 100%;
/* An emoji's cap would draw a sticker at emoji size in a sticker's cell. */
max-width: none;
max-height: none;
object-fit: contain;
}
@@ -7346,8 +7355,14 @@ video.lightbox-media {
margin: 0;
}
.go-live > .button.primary {
justify-content: center;
text-align: center;
}
.segmented {
display: inline-flex;
align-self: flex-start;
flex-wrap: wrap;
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
+13 -4
View File
@@ -77,8 +77,10 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
const store = useStore()
const buffers = useChat((s) => s.buffers)
const cameras = useChat((s) => s.discordCameras)
const sharing = useChat((s) => s.discordSharing)
const ownCamera = useChat((s) => s.discordCamera)
const sharing = useChat((s) => s.discordSharing) === session.accountId
const myCamera = useChat((s) => s.discordCamera)
// 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 watching = useChat((s) => s.discordWatching)
const voicePrefs = useChat((s) => s.voicePrefs)
@@ -176,6 +178,13 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
}
}
// From this call's own account. The window can be in two accounts' calls,
// and asking the store for "the" Discord call picks whichever came first.
const share = (quality?: VideoQuality): Promise<void> =>
session.bufferId
? store.toggleDiscordScreenShare(accountId, session.bufferId, quality)
: store.toggleScreenShare(quality)
const buttons: StageButton[] = [
{
icon: voicePrefs.micMuted ? 'mic_off' : 'mic',
@@ -201,14 +210,14 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
active: sharing,
// Stopping needs no questions; starting asks how good a stream, the
// way Discord's own client does, beside the button that asked.
onClick: () => (sharing ? void store.toggleScreenShare() : setChoosing(!choosing)),
onClick: () => (sharing ? void share() : setChoosing(!choosing)),
popover:
choosing && !sharing ? (
<GoLiveChooser
accountId={accountId}
onStart={(quality) => {
setChoosing(false)
void store.toggleScreenShare(quality)
void share(quality)
}}
onClose={() => setChoosing(false)}
/>
+34 -20
View File
@@ -538,13 +538,17 @@ export interface ChatState {
discordModal: DiscordModal | null
/** The screens and windows on offer, while somebody is choosing one. */
screenSources: { id: string; name: string; thumbnail: string }[] | null
/** Whether this window is sharing a screen into a Discord call. */
discordSharing: boolean
/**
* Which account this window is sharing a screen from into a Discord call,
* if any. An account rather than a flag: one window can be in two
* accounts' calls, and only one of them is sharing.
*/
discordSharing: string | 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.
*/
discordCamera: MediaStream | null
discordCamera: { accountId: string; stream: MediaStream } | null
/**
* Who is streaming right now, by Discord user id, and the key their stream
* is named by.
@@ -672,7 +676,7 @@ const INITIAL: ChatState = {
screenSources: null,
discordModal: null,
activeCall: null,
discordSharing: false,
discordSharing: null,
discordCamera: null,
discordStreams: {},
discordCameras: {},
@@ -937,6 +941,8 @@ export class ChatStore {
* `discordSharing` beside it.
*/
private discordShare: { stop: () => void; stream: MediaStream } | null = null
/** The conversation the share was started against, which is what stops it. */
private discordShareBuffer: string | null = null
/** The Discord camera's encoder, held for the same reason. */
private discordCameraSend: { stop: () => void; stream: MediaStream } | null = null
/**
@@ -1775,11 +1781,11 @@ export class ChatStore {
// What this end was sending goes with it. Left running, the camera light
// would stay on and the encoder would go on handing frames to a call
// that has ended.
this.stopDiscordCamera()
if (this.discordShare) {
if (this.state.discordCamera?.accountId === accountId) this.stopDiscordCamera(accountId)
if (this.discordShare && this.state.discordSharing === accountId) {
this.discordShare.stop()
this.discordShare = null
this.set({ discordSharing: false })
this.set({ discordSharing: null })
}
try {
await window.moho.rpc('leaveVoiceChannel', { accountId })
@@ -2559,12 +2565,16 @@ export class ChatStore {
*/
async toggleDiscordScreenShare(accountId: string, bufferId: string, quality: VideoQuality = DEFAULT_QUALITY): Promise<void> {
if (this.discordShare) {
const was = this.state.discordSharing
this.discordShare.stop()
this.discordShare = null
this.set({ discordSharing: false })
await window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
return
this.set({ discordSharing: 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.
if (was === accountId) return
}
this.discordShareBuffer = bufferId
try {
// The screen first, then Discord. The person chooses while the click
@@ -2600,10 +2610,10 @@ export class ChatStore {
this.discordShare = await shareScreen(accountId, stream, settings, (message) => {
this.toast('error', message)
this.discordShare = null
this.set({ discordSharing: false })
this.set({ discordSharing: null })
void window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
})
this.set({ discordSharing: true })
this.set({ discordSharing: accountId })
} catch (e) {
this.toast('error', `Couldn't share the screen: ${(e as Error).message}`)
await window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
@@ -2620,9 +2630,12 @@ export class ChatStore {
* can read.
*/
async toggleDiscordCamera(accountId: string): Promise<void> {
if (this.state.discordCamera) {
this.stopDiscordCamera(accountId)
return
const current = this.state.discordCamera
if (current) {
this.stopDiscordCamera(current.accountId)
// One camera, one call: pressing it in a second account's call moves
// it there rather than only turning it off.
if (current.accountId === accountId) return
}
let stream: MediaStream
try {
@@ -2660,7 +2673,7 @@ export class ChatStore {
this.set({ discordCamera: null })
giveUp(message)
})
this.set({ discordCamera: stream })
this.set({ discordCamera: { accountId, stream } })
} catch (e) {
giveUp(`Couldn't turn the camera on: ${(e as Error).message}`)
}
@@ -2671,10 +2684,11 @@ export class ChatStore {
const sending = this.discordCameraSend
this.discordCameraSend = null
sending?.stop()
const stream = this.state.discordCamera
if (stream) for (const track of stream.getTracks()) track.stop()
if (stream || sending) this.set({ discordCamera: null })
if (accountId) void window.moho.rpc('setDiscordCamera', { accountId, on: false }).catch(() => {})
const camera = this.state.discordCamera
if (camera) for (const track of camera.stream.getTracks()) track.stop()
if (camera || sending) this.set({ discordCamera: null })
const whose = accountId ?? camera?.accountId
if (whose) void window.moho.rpc('setDiscordCamera', { accountId: whose, on: false }).catch(() => {})
}
/** Waits for the daemon to say the stream connection is up. */