Add own control buttons for element-call (#2744)

* add mutation observer hok

* add hook to read speaking member by observing iframe content

* display speaking member name in call status bar and improve layout

* fix shrining

* add joined call control bar

* remove chat toggle from room header

* change member speaking icon to mic

* fix joined call control appear in other

* show spinner on end call button

* hide call statusbar for mobile view when room is selected

* make call statusbar more mobile friendly

* fix call status bar item align
This commit is contained in:
Ajay Bura
2026-03-09 14:04:48 +11:00
committed by GitHub
parent 55e8306576
commit bc6caddcc8
17 changed files with 521 additions and 100 deletions

View File

@@ -1,5 +1,6 @@
import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { config } from 'folds';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,

View File

@@ -1,7 +1,8 @@
import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useState } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -104,9 +105,11 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
function ScreenShareButton() {
const [enabled, setEnabled] = useState(false);
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
@@ -123,7 +126,7 @@ function ScreenShareButton() {
fill="Soft"
radii="300"
size="300"
onClick={() => setEnabled(!enabled)}
onClick={onToggle}
outlined
>
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
@@ -133,8 +136,14 @@ function ScreenShareButton() {
);
}
export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
const { microphone, video, sound } = useCallControlState(callEmbed.control);
export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
return (
<Box shrink="No" alignItems="Center" gap="300">
@@ -144,21 +153,36 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
{false && <ScreenShareButton />}
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
)}
</Box>
<StatusDivider />
<Chip
variant="Critical"
radii="300"
radii="Pill"
fill="Soft"
before={<Icon size="50" src={Icons.PhoneDown} filled />}
before={
exiting ? (
<Spinner variant="Critical" fill="Soft" size="50" />
) : (
<Icon size="50" src={Icons.PhoneDown} filled />
)
}
disabled={exiting}
outlined
onClick={() => callEmbed.hangup()}
onClick={hangup}
>
{!compact && (
<Text as="span" size="L400">
End
</Text>
)}
</Chip>
</Box>
);

View File

@@ -47,20 +47,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
) : (
<Spinner variant="Secondary" size="200" />
)}
<Box
grow="Yes"
alignItems="Center"
gap="Inherit"
justifyContent={compact ? 'Center' : undefined}
>
<Box grow="Yes" alignItems="Center" gap="Inherit">
{!compact && (
<>
<CallRoomName room={room} />
{speakers.size > 0 && !compact && (
{speakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
</>
)}
</>
)}
</Box>
{memberVisible && (
<Box shrink="No">
@@ -69,8 +68,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
)}
</Box>
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" justifyContent="Center" gap="Inherit">
<CallControl callEmbed={callEmbed} />
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<CallRoomName room={room} />
</Box>
)}
<CallControl compact={compact} callEmbed={callEmbed} />
</Box>
</Box>
);

View File

@@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400">
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>

View File

@@ -14,7 +14,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
);
return (
<Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Phone} filled />
<Icon size="100" src={Icons.Mic} filled />
<Text size="T200" truncate>
{speakingNames.length === 1 && (
<>

View File

@@ -0,0 +1,203 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import {
Box,
Button,
config,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
ChatButton,
ControlDivider,
MicrophoneButton,
ScreenShareButton,
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type CallControlsProps = {
callEmbed: CallEmbed;
};
export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
useResizeObserver(
useCallback(() => {
const element = controlRef.current;
if (!element) return;
setCompact(element.clientWidth < 500);
}, []),
useCallback(() => controlRef.current, [])
);
const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
callEmbed.control
);
const [cords, setCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleSpotlightClick = () => {
callEmbed.control.toggleSpotlight();
setCords(undefined);
};
const handleReactionsClick = () => {
callEmbed.control.toggleReactions();
setCords(undefined);
};
const handleSettingsClick = () => {
callEmbed.control.toggleSettings();
setCords(undefined);
};
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
return (
<Box
ref={controlRef}
className={css.CallControlContainer}
justifyContent="Center"
alignItems="Center"
>
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
</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 />
<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-pressed={!!cords}
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</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>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -12,6 +12,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) {
if (hasParticipant) return null;
@@ -39,13 +40,10 @@ function AlreadyInCallMessage() {
);
}
export function CallView() {
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const callViewRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callViewRef);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -57,19 +55,9 @@ export function CallView() {
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
<Box
ref={callViewRef}
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
@@ -89,17 +77,48 @@ export function CallView() {
<PrescreenControls canJoin={canJoin} />
<Header size="300">
{!inOtherCall &&
(canJoin ? (
<JoinMessage hasParticipant={hasParticipant} />
) : (
<NoPermissionMessage />
))}
(canJoin ? <JoinMessage hasParticipant={hasParticipant} /> : <NoPermissionMessage />)}
{inOtherCall && <AlreadyInCallMessage />}
</Header>
</Box>
</Box>
</Scroll>
)}
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
const callEmbed = useCallEmbed();
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
</Box>
);
}
export function CallView() {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callContainerRef);
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
</Box>
);
}

View File

@@ -114,6 +114,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);

View File

@@ -6,7 +6,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
import { CallControlState } from '../../plugins/call/CallControlState';
type PrescreenControlsProps = {
canJoin?: boolean;
@@ -50,7 +49,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
onClick={() => startCall(room, new CallControlState(microphone, video, sound))}
onClick={() => startCall(room, { microphone, video, sound })}
disabled={disabled || joining}
before={
joining ? (

View File

@@ -18,3 +18,7 @@ export const ControlDivider = style({
export const CallMemberCard = style({
padding: config.space.S300,
});
export const CallControlContainer = style({
padding: config.space.S400,
});

View File

@@ -57,7 +57,6 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { CallControlState } from '../../plugins/call/CallControlState';
type RoomNavItemMenuProps = {
room: Room;
@@ -292,7 +291,7 @@ export function RoomNavItem({
// Start call in second click
if (selected) {
evt.preventDefault();
startCall(room, new CallControlState(callPref.microphone, callPref.video, callPref.sound));
startCall(room, callPref);
}
};

View File

@@ -24,7 +24,6 @@ import {
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -68,7 +67,6 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { callChatAtom } from '../../state/callEmbed';
import { RoomSettingsPage } from '../../state/roomSettings';
type RoomMenuProps = {
@@ -266,8 +264,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
const [chat, setChat] = useAtom(callChatAtom);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
@@ -476,24 +472,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</TooltipProvider>
)}
{callView && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={() => setChat(!chat)}>
<Icon size="400" src={Icons.Message} filled={chat} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"

View File

@@ -14,6 +14,7 @@ import { callEmbedAtom } from '../state/callEmbed';
import { useResizeObserver } from './useResizeObserver';
import { CallControlState } from '../plugins/call/CallControlState';
import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@@ -41,7 +42,7 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
controlState?: CallControlState
pref?: CallPreferences
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
@@ -49,6 +50,8 @@ export const createCallEmbed = (
const intent = CallEmbed.getIntent(dm, ongoing);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
const embed = new CallEmbed(mx, room, widget, container, controlState);
return embed;
@@ -61,12 +64,12 @@ export const useCallStart = (dm = false) => {
const callEmbedRef = useCallEmbedRef();
const startCall = useCallback(
(room: Room, controlState?: CallControlState) => {
(room: Room, pref?: CallPreferences) => {
const container = callEmbedRef.current;
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, controlState);
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
setCallEmbed(callEmbed);
},

View File

@@ -1,11 +1,18 @@
import React from 'react';
import { useCallEmbed } from '../hooks/useCallEmbed';
import { CallStatus } from '../features/call-status';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
export function CallStatusRenderer() {
const callEmbed = useCallEmbed();
const selectedRoom = useSelectedRoom();
const screenSize = useScreenSizeContext();
if (!callEmbed) return null;
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
return <CallStatus callEmbed={callEmbed} />;
}

View File

@@ -14,12 +14,58 @@ export class CallControl extends EventEmitter implements CallControlState {
private iframe: HTMLIFrameElement;
private controlMutationObserver: MutationObserver;
private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}
private get screenshareButton(): HTMLElement | undefined {
const screenshareBtn = this.document?.querySelector(
'[data-testid="incall_screenshare"]'
) as HTMLElement | null;
return screenshareBtn ?? undefined;
}
private get settingsButton(): HTMLElement | undefined {
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
return settingsButton ?? undefined;
}
private get reactionsButton(): HTMLElement | undefined {
const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
return reactionsButton ?? undefined;
}
private get spotlightButton(): HTMLInputElement | undefined {
const spotlightButton = this.document?.querySelector(
'input[value="spotlight"]'
) as HTMLInputElement | null;
return spotlightButton ?? undefined;
}
private get gridButton(): HTMLInputElement | undefined {
const gridButton = this.document?.querySelector(
'input[value="grid"]'
) as HTMLInputElement | null;
return gridButton ?? undefined;
}
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super();
this.state = state;
this.call = call;
this.iframe = iframe;
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
}
public getState(): CallControlState {
@@ -38,6 +84,14 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.state.sound;
}
public get screenshare(): boolean {
return this.state.screenshare;
}
public get spotlight(): boolean {
return this.state.spotlight;
}
public async applyState() {
await this.setMediaState({
audio_enabled: this.microphone,
@@ -47,6 +101,26 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate();
}
public startObserving() {
this.controlMutationObserver.disconnect();
const screenshareBtn = this.screenshareButton;
if (screenshareBtn) {
this.controlMutationObserver.observe(screenshareBtn, {
attributes: true,
attributeFilter: ['data-kind'],
});
}
const spotlightBtn = this.spotlightButton;
if (spotlightBtn) {
this.controlMutationObserver.observe(spotlightBtn, {
attributes: true,
});
}
this.onControlMutation();
}
public applySound() {
this.setSound(this.sound);
}
@@ -72,7 +146,9 @@ export class CallControl extends EventEmitter implements CallControlState {
const state = new CallControlState(
data.audio_enabled ?? this.microphone,
data.video_enabled ?? this.video,
this.sound
this.sound,
this.screenshare,
this.spotlight
);
this.state = state;
@@ -83,6 +159,20 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
public onControlMutation() {
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
this.state = new CallControlState(
this.microphone,
this.video,
this.sound,
screenshare,
spotlight
);
this.emitStateUpdate();
}
public toggleMicrophone() {
const payload: ElementMediaStatePayload = {
audio_enabled: !this.microphone,
@@ -104,7 +194,13 @@ export class CallControl extends EventEmitter implements CallControlState {
this.setSound(sound);
const state = new CallControlState(this.microphone, this.video, sound);
const state = new CallControlState(
this.microphone,
this.video,
sound,
this.screenshare,
this.spotlight
);
this.state = state;
this.emitStateUpdate();
@@ -113,6 +209,30 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
public toggleScreenshare() {
this.screenshareButton?.click();
}
public toggleSpotlight() {
if (this.spotlight) {
this.gridButton?.click();
return;
}
this.spotlightButton?.click();
}
public toggleReactions() {
this.reactionsButton?.click();
}
public toggleSettings() {
this.settingsButton?.click();
}
public dispose() {
this.controlMutationObserver.disconnect();
}
private emitStateUpdate() {
this.emit(CallControlEvent.StateUpdate);
}

View File

@@ -5,9 +5,21 @@ export class CallControlState {
public readonly sound: boolean;
constructor(microphone: boolean, video: boolean, sound: boolean) {
public readonly screenshare: boolean;
public readonly spotlight: boolean;
constructor(
microphone: boolean,
video: boolean,
sound: boolean,
screenshare = false,
spotlight = false
) {
this.microphone = microphone;
this.video = video;
this.sound = sound;
this.screenshare = screenshare;
this.spotlight = spotlight;
}
}

View File

@@ -220,6 +220,7 @@ export class CallEmbed {
});
this.call.stop();
this.container.removeChild(this.iframe);
this.control.dispose();
this.mx.off(ClientEvent.Event, this.onEvent.bind(this));
this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this));
@@ -233,6 +234,21 @@ export class CallEmbed {
private onCallJoined(): void {
this.joined = true;
this.applyStyles();
this.control.startObserving();
}
private applyStyles(): void {
const doc = this.document;
if (!doc) return;
doc.body.style.setProperty('background', 'none', 'important');
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
?.parentElement;
if (controls) {
controls.style.setProperty('position', 'absolute');
controls.style.setProperty('visibility', 'hidden');
}
}
private onEvent(ev: MatrixEvent): void {