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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-19 17:24:38 -04:00
committed by jared
co-authored by Claude Opus 5.5
parent 5d2d7ca63c
commit 3418a8865d
4 changed files with 270 additions and 134 deletions
+170 -77
View File
@@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys'; import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
import { CallSoundboard } from './CallSoundboard'; import { CallSoundboard } from './CallSoundboard';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy'; import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from './ScreenshareConfirm'; import { ScreenshareConfirm } from './ScreenshareConfirm';
@@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control); 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 micLevel = useCallMicLevel(callEmbed);
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
@@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}; };
const showScreenshare = allowScreenshare || screenshare; const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; 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<HTMLButtonElement> = (evt) => { const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
return () => clearTimeout(id); return () => clearTimeout(id);
}, [hangupState.status, callEmbed, setCallEmbed]); }, [hangupState.status, callEmbed, setCallEmbed]);
const moreMenu = (
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleScreenshareToggle();
}}
>
<Text size="B300" truncate>
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
</Text>
</MenuItem>
)}
{compact && showScreenshareAudio && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
callEmbed.control.toggleScreenshareAudio();
}}
>
<Text size="B300" truncate>
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
</Text>
</MenuItem>
)}
{compact && !!document.fullscreenEnabled && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleFullscreen();
}}
>
<Text size="B300" truncate>
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</Text>
</MenuItem>
)}
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-label="More options"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
const hangupIcon = exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
);
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return ( return (
@@ -204,6 +327,44 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}} }}
onCancel={() => setShareConfirm(false)} onCancel={() => setShareConfirm(false)}
/> />
{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).
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="200"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="NoWrap"
>
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
{moreMenu}
<Button
variant="Critical"
fill="Solid"
radii="400"
size="400"
onClick={hangup}
disabled={exiting}
aria-label="End Call"
style={{ paddingInline: config.space.S300 }}
>
{hangupIcon}
</Button>
</SequenceCard>
) : (
<SequenceCard <SequenceCard
className={css.ControlCard} className={css.ControlCard}
variant="SurfaceVariant" variant="SurfaceVariant"
@@ -213,7 +374,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
wrap="Wrap" wrap="Wrap"
> >
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton <MicrophoneButton
enabled={microphone} enabled={microphone}
@@ -222,7 +383,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
/> />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} /> <SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box> </Box>
{!compact && showVideoGroup && <ControlDivider />} {showVideoGroup && <ControlDivider />}
{showVideoGroup && ( {showVideoGroup && (
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the {/* Show a forbidden control while its track is still live so the
@@ -238,10 +399,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
)} )}
{/* Mute-screenshare-audio sits directly next to the screenshare {/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */} control since they're the same concern. */}
{showScreenshareAudio && (
<ScreenshareAudioButton <ScreenshareAudioButton
muted={screenshareAudioMuted} muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()} onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/> />
)}
</> </>
)} )}
{!!document.fullscreenEnabled && ( {!!document.fullscreenEnabled && (
@@ -250,77 +413,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
</Box> </Box>
)} )}
</Box> </Box>
{!compact && <ControlDivider />} <ControlDivider />
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton /> <ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />} {soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
<PopOut {moreMenu}
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-label="More options"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</Box> </Box>
<Box shrink="No" direction="Column"> <Box shrink="No" direction="Column">
<Button <Button
@@ -328,13 +426,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
variant="Critical" variant="Critical"
fill="Solid" fill="Solid"
onClick={hangup} onClick={hangup}
before={ before={hangupIcon}
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting} disabled={exiting}
> >
<Text size="B400">End</Text> <Text size="B400">End</Text>
@@ -342,6 +434,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
</Box> </Box>
</Box> </Box>
</SequenceCard> </SequenceCard>
)}
</Box> </Box>
); );
} }
+25
View File
@@ -57,6 +57,15 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
export const CALL_ERROR_CHANGED = 'call_error_changed'; 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 type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state'; export const AUDIO_OUTPUT_STATE = 'audio_output_state';
@@ -281,6 +290,22 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emit(AUDIO_OUTPUT_STATE); 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 { private sendAudioOutput(deviceId: string): void {
if (!this.joined) return; if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined); this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
+6
View File
@@ -465,6 +465,12 @@ export class CallEmbed {
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data); 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. // [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
this.disposables.push( this.disposables.push(
this.listenAction('io.lotus.hotkey', (evt) => { this.listenAction('io.lotus.hotkey', (evt) => {
@@ -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);
});