From c4ba320d733bbffa508475166447f0d22a52a217 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sun, 13 Sep 2026 05:58:43 -0400 Subject: [PATCH] A camera that can be turned on after the call started The camera was a decision made once, at the door: "Join with video" or not, and nothing afterwards. That is the moment nobody knows yet whether they want to be seen - the answer usually arrives a minute later, and there was nowhere to say it. So the toggle exists, beside the microphone and the screen, in the order every client puts them in. The plumbing was already there: livekit could enable a camera, the call already collects and shows remote video tracks, and screen share has worked for a while - what was missing was the one control. Offered only on a call held on a media server, which the call now says it is. A one-to-one Matrix call is genuinely voice-only here (#189), and a camera button that cannot turn a camera on is worse than no button at all. Not verified in a live call: it takes two people, and the working account's rooms all have strangers in them. What is verified is the state - a call joined with video reports its camera on, one joined without reports it off, and the toggle is only drawn where it can act. Refs #189. Co-Authored-By: Claude Opus 5 --- src/renderer/src/components/CallStage.tsx | 13 +++++++ src/renderer/src/lib/livekit.ts | 21 +++++++++++ src/renderer/src/state/store.ts | 44 +++++++++++++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/CallStage.tsx b/src/renderer/src/components/CallStage.tsx index f3e7dd9..5eb05ce 100644 --- a/src/renderer/src/components/CallStage.tsx +++ b/src/renderer/src/components/CallStage.tsx @@ -214,6 +214,19 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX className={call.muted ? 'calling' : undefined} onClick={() => store.toggleCallMute()} /> + {/* Between the microphone and the screen, which is the order every + client puts them in - and the order of how often they are + pressed. Offered only where it can work: a one-to-one Matrix + call is voice here, and a camera button that cannot turn a + camera on is worse than no button. */} + {call.onServer && ( + void store.toggleCamera()} + /> + )} { + const on = this.room.localParticipant.isCameraEnabled + await this.room.localParticipant.setCameraEnabled(!on) + // The local tile shows what this end is sending, so it has to be rebuilt + // when that changes - the same reason toggleScreen does it. + this.local = this.ownStream() + return !on + } + + /** Whether this end is sending a picture at all. */ + get cameraOn(): boolean { + return this.room.localParticipant.isCameraEnabled + } + /** Shares a screen, or stops. Returns whether one is being shared. */ async toggleScreen(): Promise { const on = this.room.localParticipant.isScreenShareEnabled diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 591d466..d243c2d 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -515,6 +515,13 @@ export interface ChatState { video: boolean muted: boolean sharingScreen: boolean + /** Whether this end is sending a picture. */ + cameraOn: boolean + /** + * Whether the call is held on a media server rather than between two + * people. It decides what can be offered: a camera needs the server. + */ + onServer: boolean } | null /** Who each Matrix account has asked never to hear from. */ ignoredByAccount: Record @@ -900,7 +907,11 @@ export class ChatStore { phase, video: group.video, muted: this.state.activeCall?.muted ?? false, - sharingScreen: false + sharingScreen: false, + // A call joined with video starts sending one; otherwise the + // camera is off until somebody turns it on. + cameraOn: group.video, + onServer: true } }) return @@ -917,7 +928,9 @@ export class ChatStore { phase, video: call.video, muted: this.state.activeCall?.muted ?? false, - sharingScreen: call.call.sharingScreen + sharingScreen: call.call.sharingScreen, + cameraOn: false, + onServer: false } }) }, @@ -2049,7 +2062,11 @@ export class ChatStore { phase: 'connecting', video, muted: false, - sharingScreen: false + sharingScreen: false, + // Joined with video means the camera is already on; joined without + // means it is off until somebody asks for it. + cameraOn: video, + onServer: true } }) await this.matrixCalls.joinGroup(account.id, bufferId, video) @@ -2212,6 +2229,27 @@ export class ChatStore { this.set({ activeCall: { ...this.state.activeCall, muted } }) } + /** + * Turns the camera on, or off, in a call already running. + * + * Only on a media server. A one-to-one Matrix call is voice here - see #189 - + * and offering a camera button that cannot do anything would be worse than + * not offering one. + */ + async toggleCamera(): Promise { + if (!this.state.activeCall) return + const group = this.matrixCalls.currentGroup + if (!group?.sfu) { + this.toast('info', 'A camera needs a call held on a media server.') + return + } + const on = await group.sfu.toggleCamera().catch((e: Error) => { + this.toast('error', e.message) + return false + }) + this.set({ activeCall: { ...this.state.activeCall, cameraOn: on } }) + } + /** * Shares a screen or a window into the call. *