19feca4964
useCallSpeakers rebuilt the speaker Set from only the mutated tiles in each batch (so a still-speaking participant whose tile didn't mutate was dropped), and observed a static querySelectorAll NodeList (so tiles for participants who joined mid-call were never watched). Rewritten to mirror useRemoteAllMuted in the same file: a single body-level MutationObserver (subtree+childList+attrs) re-scans ALL [data-video-fit] tiles on each relevant mutation. The speaking criterion (::before background-image !== 'none') and the id (aria-label + isUserId) are unchanged, so behavior on real EC DOM is a strict superset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { CallEmbed } from '../plugins/call';
|
|
import { isUserId } from '../utils/matrix';
|
|
import { useCallMembers, useCallSession } from './useCall';
|
|
import { useCallJoined } from './useCallEmbed';
|
|
|
|
/**
|
|
* Returns the set of Matrix user IDs currently speaking in the Element Call
|
|
* iframe.
|
|
*
|
|
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
|
|
* When a participant is speaking, EC draws a speaking indicator via the tile's
|
|
* `::before` pseudo-element `background-image` (anything other than `none`).
|
|
* The participant's Matrix user ID is exposed on the first descendant carrying
|
|
* an `aria-label`.
|
|
*
|
|
* We watch the whole iframe document so tiles added/removed mid-call are picked
|
|
* up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]`
|
|
* tiles and rebuild the set from the full current DOM state (rather than just the
|
|
* tiles in the mutation batch).
|
|
*/
|
|
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
|
const callSession = useCallSession(callEmbed.room);
|
|
const callMembers = useCallMembers(callSession);
|
|
const joined = useCallJoined(callEmbed);
|
|
|
|
useEffect(() => {
|
|
if (!callMembers || !joined) {
|
|
setSpeakers(new Set<string>());
|
|
return undefined;
|
|
}
|
|
|
|
const getDoc = (): Document | undefined =>
|
|
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
|
|
|
const syncState = (): void => {
|
|
const doc = getDoc();
|
|
if (!doc) {
|
|
setSpeakers(new Set<string>());
|
|
return;
|
|
}
|
|
const s = new Set<string>();
|
|
// Re-scan every tile on each mutation and build the set from the full
|
|
// current DOM state, not just the tiles that mutated this batch.
|
|
const tiles = doc.querySelectorAll<HTMLElement>('[data-video-fit]');
|
|
tiles.forEach((el) => {
|
|
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);
|
|
};
|
|
|
|
let tileObserver: MutationObserver | undefined;
|
|
|
|
const attachObserver = (): void => {
|
|
const doc = getDoc();
|
|
if (!doc) return;
|
|
tileObserver?.disconnect();
|
|
// Watch the whole document for attribute changes on tiles (which carry
|
|
// the speaking indicator) and for new tiles being added/removed.
|
|
tileObserver = new MutationObserver((mutations) => {
|
|
const relevant = mutations.some(
|
|
(m) =>
|
|
m.type === 'attributes' ||
|
|
(m.type === 'childList' &&
|
|
(Array.from(m.addedNodes).some(
|
|
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
|
) ||
|
|
Array.from(m.removedNodes).some(
|
|
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
|
))),
|
|
);
|
|
if (relevant) syncState();
|
|
});
|
|
tileObserver.observe(doc.body, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ['class', 'style'],
|
|
});
|
|
syncState();
|
|
};
|
|
|
|
attachObserver();
|
|
|
|
// If iframe isn't ready yet, wait for body to be available.
|
|
let bodyWatcher: MutationObserver | undefined;
|
|
if (!getDoc()?.body) {
|
|
bodyWatcher = new MutationObserver(() => {
|
|
if (getDoc()?.body) {
|
|
bodyWatcher?.disconnect();
|
|
bodyWatcher = undefined;
|
|
attachObserver();
|
|
}
|
|
});
|
|
const doc = getDoc();
|
|
if (doc) bodyWatcher.observe(doc, { childList: true });
|
|
}
|
|
|
|
return () => {
|
|
tileObserver?.disconnect();
|
|
bodyWatcher?.disconnect();
|
|
};
|
|
}, [callEmbed, callMembers, joined]);
|
|
|
|
return speakers;
|
|
};
|
|
|
|
/**
|
|
* Returns true when any REMOTE participant has their microphone muted in the
|
|
* Element Call iframe.
|
|
*
|
|
* EC renders a mute-icon element per participant tile with a `data-muted`
|
|
* attribute ("true" = muted, "false" = unmuted) and an `aria-label` set to
|
|
* the participant's Matrix user ID. We watch for attribute changes on all
|
|
* `[data-muted]` elements, filter out the local user, and return true if any
|
|
* remaining participant is muted.
|
|
*/
|
|
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 localUserId = callEmbed.room.client?.getUserId() ?? '';
|
|
|
|
const syncState = (): void => {
|
|
const doc = getDoc();
|
|
if (!doc) {
|
|
setMuted(false);
|
|
return;
|
|
}
|
|
// Each participant's mute icon has data-muted="true"|"false" and
|
|
// aria-label set to their Matrix user ID.
|
|
const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]');
|
|
let anyRemoteMuted = false;
|
|
muteIcons.forEach((el) => {
|
|
const userId = el.getAttribute('aria-label') ?? '';
|
|
if (userId === localUserId) return;
|
|
if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true;
|
|
});
|
|
setMuted(anyRemoteMuted);
|
|
};
|
|
|
|
let tileObserver: MutationObserver | undefined;
|
|
|
|
const attachObserver = (): void => {
|
|
const doc = getDoc();
|
|
if (!doc) return;
|
|
tileObserver?.disconnect();
|
|
// Watch the whole document for attribute changes on data-muted elements
|
|
// and for new tiles being added/removed.
|
|
tileObserver = new MutationObserver((mutations) => {
|
|
const relevant = mutations.some(
|
|
(m) =>
|
|
m.type === 'attributes' ||
|
|
(m.type === 'childList' &&
|
|
(Array.from(m.addedNodes).some(
|
|
(n) => n instanceof Element && n.querySelector('[data-muted]'),
|
|
) ||
|
|
Array.from(m.removedNodes).some(
|
|
(n) => n instanceof Element && n.querySelector('[data-muted]'),
|
|
))),
|
|
);
|
|
if (relevant) syncState();
|
|
});
|
|
tileObserver.observe(doc.body, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ['data-muted'],
|
|
});
|
|
syncState();
|
|
};
|
|
|
|
attachObserver();
|
|
|
|
// If iframe isn't ready yet, wait for body to be available.
|
|
let bodyWatcher: MutationObserver | undefined;
|
|
if (!getDoc()?.body) {
|
|
bodyWatcher = new MutationObserver(() => {
|
|
if (getDoc()?.body) {
|
|
bodyWatcher?.disconnect();
|
|
bodyWatcher = undefined;
|
|
attachObserver();
|
|
}
|
|
});
|
|
const doc = getDoc();
|
|
if (doc) bodyWatcher.observe(doc, { childList: true });
|
|
}
|
|
|
|
return () => {
|
|
tileObserver?.disconnect();
|
|
bodyWatcher?.disconnect();
|
|
};
|
|
}, [callEmbed]);
|
|
|
|
return muted;
|
|
};
|