Compare commits

...

2 Commits

Author SHA1 Message Date
jared 66cc51d6d0 docs(bugs): update #1 (camera focus) and #4 (ringtone) statuses
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 37s
#1 documented as implemented (focusCameraParticipant + MemberGlance
"Focus camera" menu); #4 ringtone selection landed, with the remaining
active-call non-intrusive-notification work scoped and deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:38:05 -04:00
jared 4a87588435 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>
2026-06-24 17:25:32 -04:00
5 changed files with 317 additions and 128 deletions
+10 -7
View File
@@ -23,11 +23,12 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 1. No Camera Focus During Screenshare
- **File:** `cinny/src/app/features/call/CallControls.tsx`
- **Status:** **OPEN**
- **File:** `cinny/src/app/plugins/call/CallControl.ts`, `cinny/src/app/features/call-status/MemberGlance.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with an active screenshare + a participant on camera
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states.
- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override.
- **Root Cause:** Before this feature there was no UI path to manually pick a camera to focus, so EC's auto-spotlight (which prioritizes an active screenshare) always won.
- **Fix Applied:** `CallControl.focusCameraParticipant(userId)` switches EC to spotlight mode and clicks that participant's `[data-testid="videoTile"]` inside the EC iframe — in Element Call, clicking a tile in spotlight **pins** it, so the user's explicit selection takes precedence over the auto-pinned screenshare. Exposed via a "Focus camera" item in the `MemberGlance` participant menu (avatar → menu). Falls back to a plain spotlight toggle if the tile isn't rendered (e.g. camera off).
- **Architectural note:** EC owns the grid/spotlight renderer inside its iframe; our control is DOM-level tile clicks. The pin persists until changed, so a one-shot focus is sufficient. A continuously-enforced "sticky" focus that re-pins on every EC spotlight change was deliberately **not** built — it would require fighting EC's internal state on each mutation and risks flicker.
### 2. Chat Background Animation Flickering
@@ -48,11 +49,13 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 4. DM and Group Message Calls
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume control added. Remaining: ringtone selection, suppression during active calls.
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume + ringtone selection added. Remaining: (b) non-intrusive notification when already in another call.
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
- **Fix Applied:** Added `ringtoneVolume` setting (0100, default 70). `IncomingCall` reads this setting and applies `audioElement.volume = ringtoneVolume / 100` before `play()`. Slider added to Settings → General → Calls section.
- **Remaining:** (a) Ringtone selection (still hardcoded to `call.ogg`); (b) Suppression during active calls — not investigated.
- **Fix Applied:**
- `ringtoneVolume` setting (0100, default 70); `IncomingCall` applies it to the ring. Slider in Settings → General → Calls.
- **(a) Ringtone selection** (`4a875884`): `ringtoneId` setting (`classic | chime | soft | retro | none`). New `utils/ringtones.ts` synthesizes the three styles in-browser (WebAudio, mirroring `callSounds.ts`) — no new binary assets; `classic` keeps `call.ogg`; `none` is silent/visual-only. `startRingtone()` loops until stopped; `previewRingtone()` powers the on-select preview in Settings. Persisted id is whitelisted in `getSettings`.
- **Remaining:** **(b)** When the user is already in a call (`joined`), a second incoming call is currently dropped from the UI entirely (`!joined && callInfo`). Fully un-suppressing would put a disruptive full-screen takeover over the active call — the correct fix is a compact, non-intrusive in-call notification (e.g. a toast with Answer/Reject) rather than the existing `Overlay` dialog. Deferred pending that design.
### 5. Seasonal Themes and Chat Backgrounds Design
+102 -121
View File
@@ -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>
);
}
@@ -52,6 +52,7 @@ import {
MessageLayout,
MessageSpacing,
NoiseSuppressionMode,
RingtoneId,
Settings,
settingsAtom,
} from '../../../state/settings';
@@ -78,6 +79,7 @@ import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
type ThemeSelectorProps = {
@@ -1242,12 +1244,18 @@ function Calls() {
'callJoinLeaveSound',
);
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value);
if (value !== 'off') playCallJoinSound(value);
};
const handleRingtoneChange = (value: RingtoneId) => {
setRingtoneId(value);
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
};
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
@@ -1573,6 +1581,19 @@ function Calls() {
/>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Ringtone"
description="Sound played for incoming calls. Selecting an option plays a preview."
after={
<SettingsSelect
value={ringtoneId}
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
options={RINGTONE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Ringtone Volume"
+15
View File
@@ -20,6 +20,10 @@ export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
// Incoming-call ringtone. 'classic' is the bundled call.ogg clip; 'chime' /
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
// 'none' is silent (visual-only incoming-call UI).
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
export type ChatBackground =
| 'none'
| 'blueprint'
@@ -148,6 +152,7 @@ export interface Settings {
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
ringtoneId: RingtoneId;
ringtoneVolume: number; // 0100
seasonalThemeOverride:
@@ -243,6 +248,7 @@ const defaultSettings: Settings = {
afkTimeoutMinutes: 10,
callJoinLeaveSound: 'chime',
ringtoneId: 'classic',
ringtoneVolume: 70,
seasonalThemeOverride: 'auto',
@@ -273,6 +279,15 @@ export const getSettings = (): Settings => {
saved.callDenoiseModel === 'deepfilternet'
? saved.callDenoiseModel
: defaultSettings.callDenoiseModel,
// Coerce any unknown persisted ringtone id back to the default.
ringtoneId:
saved.ringtoneId === 'classic' ||
saved.ringtoneId === 'chime' ||
saved.ringtoneId === 'soft' ||
saved.ringtoneId === 'retro' ||
saved.ringtoneId === 'none'
? saved.ringtoneId
: defaultSettings.ringtoneId,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
+169
View File
@@ -0,0 +1,169 @@
import CallSound from '../../../public/sound/call.ogg';
import { RingtoneId } from '../state/settings';
export const RINGTONE_OPTIONS: { value: RingtoneId; label: string }[] = [
{ value: 'classic', label: 'Classic' },
{ value: 'chime', label: 'Chime' },
{ value: 'soft', label: 'Soft' },
{ value: 'retro', label: 'Retro' },
{ value: 'none', label: 'Silent' },
];
export const isRingtoneId = (v: unknown): v is RingtoneId =>
v === 'classic' || v === 'chime' || v === 'soft' || v === 'retro' || v === 'none';
type SynthStyle = 'chime' | 'soft' | 'retro';
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
// Shared WebAudio context for synthesized ringtones. Kept separate from the
// join/leave-sound context (callSounds.ts) to keep blast radius small.
let sharedCtx: AudioContext | undefined;
const getCtx = (): AudioContext | undefined => {
try {
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
return sharedCtx;
} catch {
return undefined;
}
};
type Note = {
freq: number;
/** Offset from phrase start, in seconds */
at: number;
/** Duration in seconds */
dur: number;
};
// One looping phrase per synth style + the period before it repeats.
const PHRASES: Record<
SynthStyle,
{ type: OscillatorType; gain: number; period: number; notes: Note[] }
> = {
// Two-tone "ring … ring" telephone cadence.
chime: {
type: 'sine',
gain: 0.3,
period: 3,
notes: [
{ freq: 587.33, at: 0, dur: 0.35 },
{ freq: 880, at: 0.4, dur: 0.35 },
{ freq: 587.33, at: 1.0, dur: 0.35 },
{ freq: 880, at: 1.4, dur: 0.35 },
],
},
// Gentle rising triangle pair.
soft: {
type: 'triangle',
gain: 0.24,
period: 3.2,
notes: [
{ freq: 523.25, at: 0, dur: 0.5 },
{ freq: 659.25, at: 0.55, dur: 0.7 },
],
},
// Retro arpeggio sweep.
retro: {
type: 'square',
gain: 0.12,
period: 2.4,
notes: [
{ freq: 440, at: 0, dur: 0.12 },
{ freq: 554.37, at: 0.13, dur: 0.12 },
{ freq: 659.25, at: 0.26, dur: 0.12 },
{ freq: 880, at: 0.39, dur: 0.22 },
],
},
};
const playPhrase = (style: SynthStyle, volume: number): void => {
const ctx = getCtx();
if (!ctx) return;
const { type, gain: peak, notes } = PHRASES[style];
const scaledPeak = peak * clamp01(volume);
if (scaledPeak <= 0) return;
const now = ctx.currentTime;
notes.forEach(({ freq, at, dur }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
const start = now + at;
// Short attack + exponential decay to avoid clicks.
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + dur + 0.02);
});
};
const startClassic = (volume: number, loop: boolean): (() => void) => {
let audio: HTMLAudioElement | undefined;
try {
audio = new Audio(CallSound);
audio.loop = loop;
audio.volume = clamp01(volume);
audio.play().catch(() => undefined);
} catch {
audio = undefined;
}
return () => {
if (!audio) return;
audio.pause();
audio.currentTime = 0;
audio = undefined;
};
};
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
playPhrase(style, volume);
if (!loop) return () => undefined;
const period = PHRASES[style].period * 1000;
const id = window.setInterval(() => playPhrase(style, volume), period);
return () => window.clearInterval(id);
};
/**
* Start an incoming-call ringtone, looping until the returned stop fn is
* called. `volume` is 0..1. Returns a no-op stop fn for 'none'.
*
* Synthesized styles share the WebAudio autoplay limitation of the bundled
* 'classic' file: until the page has had a user gesture the browser may keep
* audio suspended, so the very first ring after a cold page load can be
* silent. This matches the pre-existing behaviour of the classic ringtone.
*/
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
if (id === 'none') return () => undefined;
if (id === 'classic') return startClassic(volume, true);
return startSynth(id, volume, true);
};
// Only one preview may sound at a time; starting a new one cancels the last.
let activePreviewStop: (() => void) | null = null;
/**
* Play a single, non-looping preview of a ringtone (used by Settings).
* Auto-stops the bundled 'classic' clip after a few seconds and cancels any
* previously-playing preview. Returns a stop fn for early cancellation.
*/
export const previewRingtone = (id: RingtoneId, volume: number): (() => void) => {
activePreviewStop?.();
activePreviewStop = null;
if (id === 'none') return () => undefined;
const stop = id === 'classic' ? startClassic(volume, false) : startSynth(id, volume, false);
let timer = 0;
const wrapped = () => {
window.clearTimeout(timer);
stop();
if (activePreviewStop === wrapped) activePreviewStop = null;
};
timer = window.setTimeout(wrapped, 4000);
activePreviewStop = wrapped;
return wrapped;
};