#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:
co-authored by
Claude Opus 5.5
parent
5d2d7ca63c
commit
3418a8865d
@@ -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<RectCords>();
|
||||
@@ -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<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
return () => clearTimeout(id);
|
||||
}, [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', '');
|
||||
|
||||
return (
|
||||
@@ -204,144 +327,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
}}
|
||||
onCancel={() => setShareConfirm(false)}
|
||||
/>
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
gap="400"
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={handleMicrophoneToggle}
|
||||
level={micLevel}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
</Box>
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{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).
|
||||
<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
|
||||
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">
|
||||
{/* 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 && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<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} />
|
||||
)}
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={handleMicrophoneToggle}
|
||||
level={micLevel}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
<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 }}>
|
||||
<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"
|
||||
{showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
{/* 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 && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
control since they're the same concern. */}
|
||||
{showScreenshareAudio && (
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<ControlDivider />
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
{moreMenu}
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<Button
|
||||
style={{ minWidth: toRem(88) }}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
onClick={hangup}
|
||||
before={hangupIcon}
|
||||
disabled={exiting}
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
<Text size="B400">End</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<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>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user