fix(call): ringtone loudness, caller decline notice, All-Muted badge

Three issues from live testing:
- A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much
  louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all
  ringtones sit at a comparable level.
- A3/A4: the caller had no indication when a DM/group callee declined — their
  UI kept "ringing" until the notification lifetime expired. IncomingCallListener
  now listens for RTCDecline events for a call we're hosting in the room and
  toasts the caller ("<name> declined your call").
- G1: the PiP "All muted" badge fired when any single remote participant muted.
  useRemoteAllMuted now returns true only when there is >=1 remote and every
  remote participant is muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:13:40 -04:00
parent 6ace96f2cf
commit 5ef0a1fd3e
3 changed files with 41 additions and 5 deletions
+28 -1
View File
@@ -37,6 +37,7 @@ import {
useCallStart, useCallStart,
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call'; import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -405,6 +406,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const mx = useMatrixClient(); const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom); const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const setToast = useSetAtom(toastQueueAtom);
const [callInfo, setCallInfo] = useState<IncomingCallInfo>(); const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
@@ -424,6 +426,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
await event.getDecryptionPromise(); await event.getDecryptionPromise();
} }
// Caller-side: a participant declined a call we're hosting in this room.
// Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no.
if (event.getType() === EventType.RTCDecline) {
const decliner = event.getSender();
if (
data.liveEvent &&
room &&
decliner &&
decliner !== mx.getSafeUserId() &&
callEmbed?.roomId === room.roomId
) {
const declinerName =
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
setToast({
id: `rtc-decline-${event.getId() ?? decliner}`,
displayName: declinerName,
body: 'Declined your call',
roomName: room.name,
roomId: room.roomId,
});
}
return;
}
if ( if (
!room || !room ||
event.getType() !== EventType.RTCNotification || event.getType() !== EventType.RTCNotification ||
@@ -486,7 +513,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(info); setCallInfo(info);
}, },
[mx, directs], [mx, directs, callEmbed, setToast],
); );
useEffect(() => { useEffect(() => {
+7 -3
View File
@@ -145,13 +145,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
// Each participant's mute icon has data-muted="true"|"false" and // Each participant's mute icon has data-muted="true"|"false" and
// aria-label set to their Matrix user ID. // aria-label set to their Matrix user ID.
const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]'); const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]');
let anyRemoteMuted = false; let remoteCount = 0;
let remoteMutedCount = 0;
muteIcons.forEach((el) => { muteIcons.forEach((el) => {
const userId = el.getAttribute('aria-label') ?? ''; const userId = el.getAttribute('aria-label') ?? '';
if (userId === localUserId) return; if (userId === localUserId) return;
if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true; remoteCount += 1;
if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1;
}); });
setMuted(anyRemoteMuted); // "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; let tileObserver: MutationObserver | undefined;
+6 -1
View File
@@ -102,12 +102,17 @@ const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode):
}); });
}; };
// The bundled call.ogg is mastered near full scale, so at equal `volume` it is
// perceptibly much louder than the synthesized styles (which peak at ~0.120.3).
// Attenuate it so all ringtones sit at a comparable loudness.
const CLASSIC_GAIN = 0.45;
const startClassic = (volume: number, loop: boolean): (() => void) => { const startClassic = (volume: number, loop: boolean): (() => void) => {
let audio: HTMLAudioElement | undefined; let audio: HTMLAudioElement | undefined;
try { try {
audio = new Audio(CallSound); audio = new Audio(CallSound);
audio.loop = loop; audio.loop = loop;
audio.volume = clamp01(volume); audio.volume = clamp01(volume) * CLASSIC_GAIN;
audio.play().catch(() => undefined); audio.play().catch(() => undefined);
} catch { } catch {
audio = undefined; audio = undefined;