diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 39cd975b..097d00c9 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -20,12 +20,12 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
{(anchorRef) => (
onToggle()}
- outlined={!enabled}
+ outlined
>
@@ -51,12 +51,12 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) {
{(anchorRef) => (
onToggle()}
- outlined={!enabled}
+ outlined
>
(
onToggle()}
- outlined={enabled}
+ outlined
>
(
setEnabled(!enabled)}
- outlined={enabled}
+ outlined
>
@@ -144,9 +144,6 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
callEmbed.control.toggleSound()} />
-
-
-
callEmbed.control.toggleVideo()} />
{false && }
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index e2a17222..2b8b809f 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -12,6 +12,8 @@ import { MemberGlance } from './MemberGlance';
import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
+import { useCallSpeakers } from '../../hooks/useCallSpeakers';
+import { MemberSpeaking } from './MemberSpeaking';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -23,28 +25,51 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const callMembers = useCallMembers(room, callSession);
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
+ const speakers = useCallSpeakers(callEmbed);
+
+ const compact = screenSize === ScreenSize.Mobile;
+
+ const memberVisible = callJoined && callMembers.length > 0;
return (
-
- {callJoined && callMembers.length > 0 ? (
-
-
+
+ {memberVisible ? (
+
) : (
)}
-
-
+
+
+ {speakers.size > 0 && !compact && (
+ <>
+
+
+
+ >
+ )}
+
+ {memberVisible && (
+
+
+
+ )}
-
+ {memberVisible && !compact && }
+
diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx
index f4991f93..2e65069a 100644
--- a/src/app/features/call-status/MemberGlance.tsx
+++ b/src/app/features/call-status/MemberGlance.tsx
@@ -10,13 +10,15 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
+import * as css from './styles.css';
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
+ speakers: Set;
max?: number;
};
-export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) {
+export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
@@ -38,6 +40,7 @@ export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) {
return (
;
+};
+export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
+ const speakingNames = Array.from(speakers).map(
+ (userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId
+ );
+ return (
+
+
+
+ {speakingNames.length === 1 && (
+ <>
+ {speakingNames[0]}
+
+ {' is speaking...'}
+
+ >
+ )}
+ {speakingNames.length === 2 && (
+ <>
+ {speakingNames[0]}
+
+ {' and '}
+
+ {speakingNames[1]}
+
+ {' are speaking...'}
+
+ >
+ )}
+ {speakingNames.length === 3 && (
+ <>
+ {speakingNames[0]}
+
+ {', '}
+
+ {speakingNames[1]}
+
+ {' and '}
+
+ {speakingNames[2]}
+
+ {' are speaking...'}
+
+ >
+ )}
+ {speakingNames.length > 3 && (
+ <>
+ {speakingNames[0]}
+
+ {', '}
+
+ {speakingNames[1]}
+
+ {', '}
+
+ {speakingNames[2]}
+
+ {' and '}
+
+ {speakingNames.length - 3} others
+
+ {' are speaking...'}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts
index 26b08f0b..dd32bc3d 100644
--- a/src/app/features/call-status/styles.css.ts
+++ b/src/app/features/call-status/styles.css.ts
@@ -15,3 +15,7 @@ export const CallStatus = style([
export const ControlDivider = style({
height: toRem(16),
});
+
+export const SpeakerAvatarOutline = style({
+ boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
+});
diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts
new file mode 100644
index 00000000..24003678
--- /dev/null
+++ b/src/app/hooks/useCallSpeakers.ts
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { CallEmbed } from '../plugins/call';
+import { useMutationObserver } from './useMutationObserver';
+import { isUserId } from '../utils/matrix';
+import { useCallMembers, useCallSession } from './useCall';
+import { useCallJoined } from './useCallEmbed';
+
+export const useCallSpeakers = (callEmbed: CallEmbed): Set => {
+ const [speakers, setSpeakers] = useState(new Set());
+ const callSession = useCallSession(callEmbed.room);
+ const callMembers = useCallMembers(callEmbed.room, callSession);
+ const joined = useCallJoined(callEmbed);
+
+ const videoContainers = useMemo(() => {
+ if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
+ return undefined;
+ }, [callEmbed, callMembers, joined]);
+
+ const mutationObserver = useMutationObserver(
+ useCallback(
+ (mutations) => {
+ const s = new Set();
+
+ mutations.forEach((mutation) => {
+ if (mutation.type !== 'attributes') return;
+ const el = mutation.target as HTMLElement;
+
+ const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
+ if (!style) return;
+ const tileBackgroundImage = style.getPropertyValue('background-image');
+ const speaking = tileBackgroundImage !== 'none';
+ if (!speaking) return;
+
+ const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
+ if (speakerId && isUserId(speakerId)) {
+ s.add(speakerId);
+ }
+ });
+
+ setSpeakers(s);
+ },
+ [callEmbed]
+ )
+ );
+
+ useEffect(() => {
+ videoContainers?.forEach((element) => {
+ mutationObserver.observe(element, {
+ attributes: true,
+ attributeFilter: ['class', 'style'],
+ });
+ });
+
+ return () => {
+ mutationObserver.disconnect();
+ };
+ }, [videoContainers, mutationObserver]);
+
+ return speakers;
+};
diff --git a/src/app/hooks/useMutationObserver.ts b/src/app/hooks/useMutationObserver.ts
new file mode 100644
index 00000000..5d0ce2ab
--- /dev/null
+++ b/src/app/hooks/useMutationObserver.ts
@@ -0,0 +1,37 @@
+import { useEffect, useMemo } from 'react';
+
+export type OnMutationCallback = (mutations: MutationRecord[]) => void;
+
+export const getMutationRecord = (
+ target: Node,
+ mutations: MutationRecord[]
+): MutationRecord | undefined => mutations.find((mutation) => mutation.target === target);
+
+export const useMutationObserver = (
+ onMutationCallback: OnMutationCallback,
+ observeElement?: Node | null | (() => Node | null),
+ options?: MutationObserverInit
+): MutationObserver => {
+ const mutationObserver = useMemo(
+ () => new MutationObserver(onMutationCallback),
+ [onMutationCallback]
+ );
+
+ useEffect(() => () => mutationObserver?.disconnect(), [mutationObserver]);
+
+ useEffect(() => {
+ const element = typeof observeElement === 'function' ? observeElement() : observeElement;
+
+ if (element) {
+ mutationObserver.observe(element, options);
+ }
+
+ return () => {
+ if (element) {
+ mutationObserver.disconnect();
+ }
+ };
+ }, [mutationObserver, observeElement, options]);
+
+ return mutationObserver;
+};
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index b852e3ef..f01cd685 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -163,6 +163,10 @@ export class CallEmbed {
return this.room.roomId;
}
+ get document(): Document | undefined {
+ return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
+ }
+
public setTheme(theme: ElementCallThemeKind) {
return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, {
name: theme,