2026-03-08 22:00:35 +11:00
|
|
|
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<string> => {
|
|
|
|
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
|
|
|
|
const callSession = useCallSession(callEmbed.room);
|
2026-05-23 17:20:41 +05:30
|
|
|
const callMembers = useCallMembers(callSession);
|
2026-03-08 22:00:35 +11:00
|
|
|
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<string>();
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[callEmbed],
|
|
|
|
|
),
|
2026-03-08 22:00:35 +11:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
videoContainers?.forEach((element) => {
|
|
|
|
|
mutationObserver.observe(element, {
|
|
|
|
|
attributes: true,
|
|
|
|
|
attributeFilter: ['class', 'style'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
mutationObserver.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [videoContainers, mutationObserver]);
|
|
|
|
|
|
|
|
|
|
return speakers;
|
|
|
|
|
};
|
2026-06-03 22:13:22 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns true when the local user's microphone is muted in the Element Call
|
|
|
|
|
* iframe. The state is read directly from the EC mute button DOM using the
|
|
|
|
|
* same MutationObserver / `data-kind` pattern that CallControl.ts uses for the
|
|
|
|
|
* screenshare button:
|
|
|
|
|
*
|
|
|
|
|
* [data-testid="incall_mute"][data-kind="primary"] → mic is muted
|
|
|
|
|
* [data-testid="incall_mute"][data-kind="secondary"] → mic is active
|
|
|
|
|
*
|
|
|
|
|
* This is used by the PiP overlay so the viewer can see at a glance whether
|
|
|
|
|
* their microphone is muted while navigated away from the call room.
|
|
|
|
|
*/
|
|
|
|
|
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
|
|
|
|
const [muted, setMuted] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!callEmbed) return undefined;
|
|
|
|
|
|
|
|
|
|
const getDoc = (): Document | undefined =>
|
|
|
|
|
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
|
|
|
|
|
|
|
|
|
const getMuteBtn = (): HTMLElement | null =>
|
|
|
|
|
getDoc()?.querySelector('[data-testid="incall_mute"]') ?? null;
|
|
|
|
|
|
|
|
|
|
/** Read the current button state and update React state. */
|
|
|
|
|
const syncState = (): void => {
|
|
|
|
|
const btn = getMuteBtn();
|
|
|
|
|
// data-kind="primary" means the button is in its "active/on" primary style,
|
|
|
|
|
// which EC uses for muted state (consistent with screenshare active = primary).
|
|
|
|
|
setMuted(btn?.getAttribute('data-kind') === 'primary');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let observer: MutationObserver | undefined;
|
|
|
|
|
|
|
|
|
|
const attachObserver = (): void => {
|
|
|
|
|
const btn = getMuteBtn();
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
|
|
|
|
observer?.disconnect();
|
|
|
|
|
observer = new MutationObserver(syncState);
|
|
|
|
|
observer.observe(btn, {
|
|
|
|
|
attributes: true,
|
|
|
|
|
attributeFilter: ['data-kind'],
|
|
|
|
|
});
|
|
|
|
|
// Read the current state immediately once we have a button to observe.
|
|
|
|
|
syncState();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// If the button is already present, start observing immediately.
|
|
|
|
|
attachObserver();
|
|
|
|
|
|
|
|
|
|
// If not yet present (iframe still loading), watch the document body for
|
|
|
|
|
// the button to appear — mirrors the styleRetryObserver pattern in CallEmbed.
|
|
|
|
|
let bodyObserver: MutationObserver | undefined;
|
|
|
|
|
if (!getMuteBtn()) {
|
|
|
|
|
const doc = getDoc();
|
|
|
|
|
if (doc) {
|
|
|
|
|
bodyObserver = new MutationObserver(() => {
|
|
|
|
|
if (getMuteBtn()) {
|
|
|
|
|
bodyObserver?.disconnect();
|
|
|
|
|
bodyObserver = undefined;
|
|
|
|
|
attachObserver();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
bodyObserver.observe(doc.body, { childList: true, subtree: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
observer?.disconnect();
|
|
|
|
|
bodyObserver?.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [callEmbed]);
|
|
|
|
|
|
|
|
|
|
return muted;
|
|
|
|
|
};
|