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

243 lines
8.6 KiB
TypeScript
Raw Normal View History

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);
2026-05-23 17:20:41 +05:30
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 => {
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
// sent yet (null) OR sent a spurious empty list (you're always present in
// your own joined call, so [] means "no usable data", not "nobody").
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const ls = new Set<string>();
lotus.forEach((p) => {
if (p.speaking && isUserId(p.userId)) ls.add(p.userId);
});
setSpeakers(ls);
return;
}
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();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// 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();
unsubLotus();
};
}, [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 => {
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
// ignore a spurious empty list (fall back to DOM).
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const remote = lotus.filter((p) => p.userId !== localUserId);
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
return;
}
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 remoteCount = 0;
let remoteMutedCount = 0;
muteIcons.forEach((el) => {
const userId = el.getAttribute('aria-label') ?? '';
if (userId === localUserId) return;
remoteCount += 1;
if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1;
});
// "All muted" badge: true only when there is at least one remote
// participant and every one of them is muted (not merely any single one).
setMuted(remoteCount > 0 && remoteMutedCount === remoteCount);
};
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();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// 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();
unsubLotus();
};
}, [callEmbed]);
return muted;
};