feat(calls): selectable incoming-call ringtone (#4)
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the incoming-call ring is no longer hardcoded to call.ogg. The three synth styles are generated in-browser via a new utils/ringtones.ts module (mirroring the existing callSounds.ts WebAudio pattern), so no new binary assets are bundled; 'classic' keeps the existing call.ogg clip and 'none' is a silent, visual-only incoming-call UI. - ringtones.ts: startRingtone() loops until stopped; previewRingtone() plays a single non-looping preview and auto-cancels the prior preview. - IncomingCall: ring driven by the setting; <audio> element removed. - Settings > Calls: Ringtone selector with on-select preview, beside the existing Ringtone Volume slider. - settings.ts: persisted value whitelisted back to a known id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { startRingtone } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
@@ -103,8 +103,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
@@ -125,25 +125,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
),
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
if (!audioElement) return;
|
||||
audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100));
|
||||
audioElement.play().catch(() => undefined);
|
||||
}, [ringtoneVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (info.notificationType === 'ring') {
|
||||
playSound();
|
||||
}
|
||||
return () => {
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
}
|
||||
};
|
||||
}, [playSound, info.notificationType]);
|
||||
if (info.notificationType !== 'ring') return undefined;
|
||||
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
return stop;
|
||||
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||
@@ -156,112 +142,107 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => onIgnore(),
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{getMemberDisplayName(info.room, info.sender) ??
|
||||
getMxIdLocalPart(info.sender) ??
|
||||
info.sender}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||
<Text size="H3" align="Center" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T300" align="Center">
|
||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => onIgnore(),
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{getMemberDisplayName(info.room, info.sender) ??
|
||||
getMxIdLocalPart(info.sender) ??
|
||||
info.sender}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
{!livekitSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your homeserver does not support calling.
|
||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||
<Text size="H3" align="Center" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported() && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
<Text size="T300" align="Center">
|
||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||
before={
|
||||
<Icon
|
||||
size="200"
|
||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant={dm ? 'Critical' : 'Secondary'}
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
||||
<source src={CallSound} type="audio/ogg" />
|
||||
</audio>
|
||||
</>
|
||||
{!livekitSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported() && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||
before={
|
||||
<Icon
|
||||
size="200"
|
||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant={dm ? 'Critical' : 'Secondary'}
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user