Merge pull request 'fix(call): one-row phone call bar with self-view; screenshare-audio mute only when useful (#265, #277)' (#437) from fix/phone-call-controls into lotus
CI / Build & Quality Checks (push) Successful in 1m43s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 14m46s

This commit was merged in pull request #437.
This commit is contained in:
2026-10-19 18:11:32 -04:00
6 changed files with 275 additions and 139 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.26", "@lotusguild/element-call-embedded": "0.25.0-lotus.27",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
}, },
"node_modules/@lotusguild/element-call-embedded": { "node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.26", "version": "0.25.0-lotus.27",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz", "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.27/element-call-embedded-0.25.0-lotus.27.tgz",
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==", "integrity": "sha512-24ZAvGR/t0C6LWFygZkvtk7HalPUvjERDWWkxpJJf4AZCDOJGuhoOuBrjmO+tUGwIrgLOTN6EFAWSaPpTU9rlQ==",
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.26", "@lotusguild/element-call-embedded": "0.25.0-lotus.27",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
+227 -134
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,144 +327,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}} }}
onCancel={() => setShareConfirm(false)} onCancel={() => setShareConfirm(false)}
/> />
<SequenceCard {compact ? (
className={css.ControlCard} // [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
variant="SurfaceVariant" // mute and fullscreen live in More (they rarely matter on a phone), and
gap="400" // End is icon-only. The old stacked layout took four rows (~40% of a
radii="500" // portrait phone).
alignItems="Center" <SequenceCard
justifyContent="SpaceBetween" className={css.ControlCard}
wrap="Wrap" variant="SurfaceVariant"
> gap="200"
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> radii="500"
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> alignItems="Center"
<MicrophoneButton justifyContent="SpaceBetween"
enabled={microphone} wrap="NoWrap"
onToggle={handleMicrophoneToggle} >
level={micLevel} <MicrophoneButton
/> enabled={microphone}
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} /> onToggle={handleMicrophoneToggle}
</Box> level={micLevel}
{!compact && showVideoGroup && <ControlDivider />} />
{showVideoGroup && ( <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
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<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">
{/* Show a forbidden control while its track is still live so the <MicrophoneButton
user can stop it; once stopped it hides and can't be restarted. */} enabled={microphone}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />} onToggle={handleMicrophoneToggle}
{showScreenshare && ( level={micLevel}
<> />
{screenshareMode !== 'hidden' && ( <SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</>
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box> </Box>
)} {showVideoGroup && <ControlDivider />}
</Box> {showVideoGroup && (
{!compact && <ControlDivider />} <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> {/* Show a forbidden control while its track is still live so the
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> user can stop it; once stopped it hides and can't be restarted. */}
<ChatButton /> {showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />} {showScreenshare && (
<PopOut <>
anchor={cords} {screenshareMode !== 'hidden' && (
position="Top" <ScreenShareButton
align="Center" enabled={screenshare}
content={ onToggle={() => handleScreenshareToggle()}
<FocusTrap />
focusTrapOptions={{ )}
initialFocus: false, {/* Mute-screenshare-audio sits directly next to the screenshare
onDeactivate: () => setCords(undefined), control since they're the same concern. */}
clickOutsideDeactivates: true, {showScreenshareAudio && (
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', <ScreenshareAudioButton
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', muted={screenshareAudioMuted}
escapeDeactivates: stopPropagation, onToggle={() => callEmbed.control.toggleScreenshareAudio()}
}} />
> )}
<Menu> </>
<Box direction="Column" style={{ padding: config.space.S100 }}> )}
<MenuItem {!!document.fullscreenEnabled && (
size="300" <FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
variant="Surface" )}
radii="300" </Box>
onClick={handleSpotlightClick} )}
> </Box>
<Text size="B300" truncate> <ControlDivider />
{spotlight ? 'Grid View' : 'Spotlight View'} <Box alignItems="Center" gap="Inherit" grow="Yes">
</Text> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
</MenuItem> <ChatButton />
<MenuItem {soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
size="300" {moreMenu}
variant="Surface" </Box>
radii="300" <Box shrink="No" direction="Column">
onClick={handleReactionsClick} <Button
> style={{ minWidth: toRem(88) }}
<Text size="B300" truncate> variant="Critical"
Reactions fill="Solid"
</Text> onClick={hangup}
</MenuItem> before={hangupIcon}
<MenuItem disabled={exiting}
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} /> <Text size="B400">End</Text>
</IconButton> </Button>
</PopOut> </Box>
</Box> </Box>
<Box shrink="No" direction="Column"> </SequenceCard>
<Button )}
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</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);
});