feat(call): PTT, deafen label, camera default off, screenshare confirm, noise suppression setting
- Push to Talk: keydown/keyup binds mic to configurable key (default Space) with visual PTT indicator and key-binding UI in Settings > General > Calls - Camera always defaults OFF on join; cameraOnJoin setting for explicit opt-in - Deafen button tooltip corrected to Deafen/Undeafen instead of Turn Off/On Sound - Screenshare confirmation dialog before broadcasting to call participants - Noise suppression toggle wired from settings through CallEmbed URL params - CallControl.setMicrophone() public method for programmatic mic control - Calls settings section added to General settings page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
@@ -51,6 +53,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -71,12 +77,37 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pttMode) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === pttKey && !e.repeat && !microphone) {
|
||||
e.preventDefault();
|
||||
callEmbed.control.setMicrophone(true);
|
||||
setPttActive(true);
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === pttKey) {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
setPttActive(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [pttMode, pttKey, callEmbed, microphone]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||
);
|
||||
const exiting =
|
||||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||
|
||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={controlRef}
|
||||
@@ -84,6 +115,63 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
{pttMode && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
|
||||
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
|
||||
borderRadius: '99px',
|
||||
padding: '0.2rem 0.9rem',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em' }}>
|
||||
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{shareConfirm && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300" variant="Success" fill="Solid" radii="300"
|
||||
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300" variant="Surface" fill="Soft" radii="300" outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
@@ -105,7 +193,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
onToggle={() => screenshare
|
||||
? callEmbed.control.toggleScreenshare()
|
||||
: setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
delay={500}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||||
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -805,6 +805,80 @@ function Editor() {
|
||||
}
|
||||
|
||||
|
||||
function Calls() {
|
||||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [listeningForKey, setListeningForKey] = useState(false);
|
||||
|
||||
const handleKeyBind = () => {
|
||||
setListeningForKey(true);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForKey(false);
|
||||
} else {
|
||||
setPttKey(e.code);
|
||||
setListeningForKey(false);
|
||||
}
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
};
|
||||
|
||||
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Calls</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join with Camera On"
|
||||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Noise Suppression"
|
||||
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
|
||||
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title="Push to Talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||||
/>
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key Binding"
|
||||
description="Press a key to bind it as your push-to-talk key."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant={listeningForKey ? 'Warning' : 'Secondary'}
|
||||
fill={listeningForKey ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={handleKeyBind}
|
||||
style={{ minWidth: '90px' }}
|
||||
>
|
||||
<Text size="B300">
|
||||
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatBgGrid() {
|
||||
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||
const theme = useTheme();
|
||||
@@ -1110,6 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<DateAndTime />
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Calls />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useResizeObserver } from './useResizeObserver';
|
||||
import { CallControlState } from '../plugins/call/CallControlState';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||
|
||||
@@ -42,14 +44,15 @@ export const createCallEmbed = (
|
||||
dm: boolean,
|
||||
themeKind: ElementCallThemeKind,
|
||||
container: HTMLElement,
|
||||
pref?: CallPreferences
|
||||
pref?: CallPreferences,
|
||||
noiseSuppression = true
|
||||
): CallEmbed => {
|
||||
const rtcSession = mx.matrixRTC.getRoomSession(room);
|
||||
const ongoing =
|
||||
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||
|
||||
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression);
|
||||
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
|
||||
|
||||
const embed = new CallEmbed(mx, room, widget, container, controlState);
|
||||
@@ -62,6 +65,7 @@ export const useCallStart = (dm = false) => {
|
||||
const theme = useTheme();
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
|
||||
const startCall = useCallback(
|
||||
(room: Room, pref?: CallPreferences) => {
|
||||
@@ -69,11 +73,11 @@ export const useCallStart = (dm = false) => {
|
||||
if (!container) {
|
||||
throw new Error('Failed to start call, No embed container element found!');
|
||||
}
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true);
|
||||
|
||||
setCallEmbed(callEmbed);
|
||||
},
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef]
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression]
|
||||
);
|
||||
|
||||
return startCall;
|
||||
|
||||
@@ -173,6 +173,14 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
public setMicrophone(enabled: boolean) {
|
||||
const payload: ElementMediaStatePayload = {
|
||||
audio_enabled: enabled,
|
||||
video_enabled: this.video,
|
||||
};
|
||||
return this.setMediaState(payload);
|
||||
}
|
||||
|
||||
public toggleMicrophone() {
|
||||
const payload: ElementMediaStatePayload = {
|
||||
audio_enabled: !this.microphone,
|
||||
|
||||
@@ -59,7 +59,8 @@ export class CallEmbed {
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
intent: ElementCallIntent,
|
||||
themeKind: ElementCallThemeKind
|
||||
themeKind: ElementCallThemeKind,
|
||||
noiseSuppression = true
|
||||
): Widget {
|
||||
const userId = mx.getSafeUserId();
|
||||
const deviceId = mx.getDeviceId() ?? '';
|
||||
@@ -81,6 +82,7 @@ export class CallEmbed {
|
||||
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||||
lang: 'en-EN',
|
||||
theme: themeKind,
|
||||
noiseSuppression: noiseSuppression.toString(),
|
||||
});
|
||||
|
||||
const widgetUrl = new URL(
|
||||
|
||||
@@ -28,10 +28,13 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom =>
|
||||
storeKey,
|
||||
(key) => {
|
||||
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
|
||||
return v;
|
||||
// Never restore camera state — always start with camera off for privacy.
|
||||
// Users can toggle it in the prescreen before joining.
|
||||
return { ...v, video: false };
|
||||
},
|
||||
(key, value) => {
|
||||
setLocalStorageItem(key, value);
|
||||
// Don't persist video state — always resets to off on next load.
|
||||
setLocalStorageItem(key, { ...value, video: false });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useCallback, useContext } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CallPreferences, CallPreferencesAtom } from '../callPreferences';
|
||||
import { useSetting } from './settings';
|
||||
import { settingsAtom } from '../settings';
|
||||
|
||||
const CallPreferencesAtomContext = createContext<CallPreferencesAtom | null>(null);
|
||||
export const CallPreferencesProvider = CallPreferencesAtomContext.Provider;
|
||||
@@ -21,6 +23,7 @@ export const useCallPreferences = (): CallPreferences & {
|
||||
} => {
|
||||
const callPrefAtom = useCallPreferencesAtom();
|
||||
const [pref, setPref] = useAtom(callPrefAtom);
|
||||
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
|
||||
const toggleMicrophone = useCallback(() => {
|
||||
const microphone = !pref.microphone;
|
||||
@@ -54,6 +57,7 @@ export const useCallPreferences = (): CallPreferences & {
|
||||
|
||||
return {
|
||||
...pref,
|
||||
video: cameraOnJoin ? pref.video : false,
|
||||
toggleMicrophone,
|
||||
toggleVideo,
|
||||
toggleSound,
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface Settings {
|
||||
|
||||
chatBackground: ChatBackground;
|
||||
perMessageProfiles: boolean;
|
||||
|
||||
cameraOnJoin: boolean;
|
||||
callNoiseSuppression: boolean;
|
||||
pttMode: boolean;
|
||||
pttKey: string;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -84,6 +89,11 @@ const defaultSettings: Settings = {
|
||||
|
||||
chatBackground: 'none',
|
||||
perMessageProfiles: false,
|
||||
|
||||
cameraOnJoin: false,
|
||||
callNoiseSuppression: true,
|
||||
pttMode: false,
|
||||
pttKey: 'Space',
|
||||
};
|
||||
|
||||
export const getSettings = () => {
|
||||
|
||||
Reference in New Issue
Block a user