Files
cinny/src/app/hooks/useCallSpeakers.ts
T

138 lines
4.5 KiB
TypeScript
Raw Normal View History

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);
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);
},
[callEmbed],
),
);
useEffect(() => {
videoContainers?.forEach((element) => {
mutationObserver.observe(element, {
attributes: true,
attributeFilter: ['class', 'style'],
});
});
return () => {
mutationObserver.disconnect();
};
}, [videoContainers, mutationObserver]);
return speakers;
};
/**
* 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;
};