From 3418a8865d1895fde5b6d87d833276fdcf63b7ee Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 19 Oct 2026 14:40:38 -0400 Subject: [PATCH] fix(call): one-row call bar on phones; screenshare-audio mute only when useful (#265, #277) #265: in the compact layout (phones, narrow windows) the control bar stacked into four rows (~330px, 40% of a portrait phone). It is now one row: mic, deafen, camera, chat, soundboard, More and an icon-only End. Screenshare, its audio mute and fullscreen move into More; Share Screen is only offered where getDisplayMedia exists. (The self-view comes from the fork, lotus.27.) #277: "Mute Screenshare Audio" showed whenever screensharing was allowed, as a speaker icon that did nothing. It now shows while the fork reports remote screenshare audio (io.lotus.screenshare_audio_state), or while it is muted, so a hidden mute cannot silence the next share. Fixes #265 Fixes #277 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/call/CallControls.tsx | 361 +++++++++++------- src/app/plugins/call/CallControl.ts | 25 ++ src/app/plugins/call/CallEmbed.ts | 6 + .../call/screenshareAudioState.test.ts | 12 + 4 files changed, 270 insertions(+), 134 deletions(-) create mode 100644 src/app/plugins/call/screenshareAudioState.test.ts diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index bbcfcfdc2..acd150019 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { pttActiveAtom } from '../../hooks/useCallHotkeys'; +import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl'; import { CallSoundboard } from './CallSoundboard'; import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy'; import { ScreenshareConfirm } from './ScreenshareConfirm'; @@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) { const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = useCallControlState(callEmbed.control); + // [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's + // audio is playing, or while it is muted (so a hidden mute can't silently + // swallow the next share). + const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState( + callEmbed.control.remoteScreenshareAudio, + ); + useEffect(() => { + const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio); + onState(); + callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState); + return () => { + callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState); + }; + }, [callEmbed]); + const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted; const micLevel = useCallMicLevel(callEmbed); const [cords, setCords] = useState(); @@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) { }; const showScreenshare = allowScreenshare || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; + // [Gitea #265] Phones can't share their screen from a browser; don't offer it. + const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function'; const handleOpenMenu: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); }; @@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) { return () => clearTimeout(id); }, [hangupState.status, callEmbed, setCallEmbed]); + const moreMenu = ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && ( + { + setCords(undefined); + handleScreenshareToggle(); + }} + > + + {screenshare ? 'Stop Screenshare' : 'Share Screen'} + + + )} + {compact && showScreenshareAudio && ( + { + setCords(undefined); + callEmbed.control.toggleScreenshareAudio(); + }} + > + + {screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'} + + + )} + {compact && !!document.fullscreenEnabled && ( + { + setCords(undefined); + handleFullscreen(); + }} + > + + {isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + + )} + + + {spotlight ? 'Grid View' : 'Spotlight View'} + + + + + Reactions + + + + + Settings + + + + + + } + > + + + + + ); + + const hangupIcon = exiting ? ( + + ) : ( + + ); + const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); return ( @@ -204,144 +327,114 @@ export function CallControls({ callEmbed }: CallControlsProps) { }} onCancel={() => setShareConfirm(false)} /> - - - - - callEmbed.control.toggleSound()} /> - - {!compact && showVideoGroup && } - {showVideoGroup && ( + {compact ? ( + // [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio + // mute and fullscreen live in More (they rarely matter on a phone), and + // End is icon-only. The old stacked layout took four rows (~40% of a + // portrait phone). + + + callEmbed.control.toggleSound()} /> + {showCamera && } + + {soundboardEnabled && } + {moreMenu} + + + ) : ( + + - {/* Show a forbidden control while its track is still live so the - user can stop it; once stopped it hides and can't be restarted. */} - {showCamera && } - {showScreenshare && ( - <> - {screenshareMode !== 'hidden' && ( - handleScreenshareToggle()} - /> - )} - {/* Mute-screenshare-audio sits directly next to the screenshare - control since they're the same concern. */} - callEmbed.control.toggleScreenshareAudio()} - /> - - )} - {!!document.fullscreenEnabled && ( - - )} + + callEmbed.control.toggleSound()} /> - )} - - {!compact && } - - - - {soundboardEnabled && } - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - - - {spotlight ? 'Grid View' : 'Spotlight View'} - - - - - Reactions - - - - - Settings - - - - - - } - > - } + {showVideoGroup && ( + + {/* Show a forbidden control while its track is still live so the + user can stop it; once stopped it hides and can't be restarted. */} + {showCamera && } + {showScreenshare && ( + <> + {screenshareMode !== 'hidden' && ( + handleScreenshareToggle()} + /> + )} + {/* Mute-screenshare-audio sits directly next to the screenshare + control since they're the same concern. */} + {showScreenshareAudio && ( + callEmbed.control.toggleScreenshareAudio()} + /> + )} + + )} + {!!document.fullscreenEnabled && ( + + )} + + )} + + + + + + {soundboardEnabled && } + {moreMenu} + + + + - - - - - + + )} ); } diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index e73c041ca..f316e6d69 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -57,6 +57,15 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null { export const CALL_ERROR_CHANGED = 'call_error_changed'; +export const SCREENSHARE_AUDIO_STATE = 'screenshare_audio_state'; + +/** Pure: parse the fork's `io.lotus.screenshare_audio_state` (#277). */ +export function parseScreenshareAudioState(data: unknown): boolean | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const { present } = data as { present?: unknown }; + return typeof present === 'boolean' ? present : undefined; +} + export type AudioOutputOption = { id: string; label: string }; export const AUDIO_OUTPUT_STATE = 'audio_output_state'; @@ -281,6 +290,22 @@ export class CallControl extends EventEmitter implements CallControlState { this.emit(AUDIO_OUTPUT_STATE); } + // [Gitea #277] Whether a remote screenshare's audio is being received (the + // fork's `io.lotus.screenshare_audio_state`), so the call bar only offers + // "Mute Screenshare Audio" when there is something to mute. + private _remoteScreenshareAudio = false; + + public get remoteScreenshareAudio(): boolean { + return this._remoteScreenshareAudio; + } + + public onScreenshareAudioState(data: unknown) { + const present = parseScreenshareAudioState(data); + if (present === undefined || present === this._remoteScreenshareAudio) return; + this._remoteScreenshareAudio = present; + this.emit(SCREENSHARE_AUDIO_STATE); + } + private sendAudioOutput(deviceId: string): void { if (!this.joined) return; this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index cc7496acc..2fe594608 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -465,6 +465,12 @@ export class CallEmbed { this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data); }), ); + // [Gitea #277] Remote screenshare audio being received, or not. + this.disposables.push( + this.listenAction('io.lotus.screenshare_audio_state', (evt) => { + this.control.onScreenshareAudioState((evt.detail as { data?: unknown } | undefined)?.data); + }), + ); // [Gitea #43] PTT / deafen keys pressed while focus is inside the frame. this.disposables.push( this.listenAction('io.lotus.hotkey', (evt) => { diff --git a/src/app/plugins/call/screenshareAudioState.test.ts b/src/app/plugins/call/screenshareAudioState.test.ts new file mode 100644 index 000000000..2f5c0c0bf --- /dev/null +++ b/src/app/plugins/call/screenshareAudioState.test.ts @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseScreenshareAudioState } from './CallControl'; + +test('parseScreenshareAudioState: reads present, ignores anything else (#277)', () => { + assert.equal(parseScreenshareAudioState({ present: true }), true); + assert.equal(parseScreenshareAudioState({ present: false }), false); + assert.equal(parseScreenshareAudioState({ present: 'yes' }), undefined); + assert.equal(parseScreenshareAudioState({}), undefined); + assert.equal(parseScreenshareAudioState(null), undefined); + assert.equal(parseScreenshareAudioState('present'), undefined); +});