feat: add call management features with useCall and useRoomCallMembers hooks

- Implemented useCall hook for managing call state and actions.
- Created useRoomCallMembers hook to track active call members in a room.
- Added useRtcConfig for fetching RTC configurations and LiveKit JWT.
- Developed Audio settings component for managing audio devices and settings.
- Introduced device selection and screen sharing options in the Audio settings.
- Persisted audio settings in localStorage for user preferences.
This commit is contained in:
2026-01-23 19:35:25 +11:00
parent c88cb4bca9
commit 94f8466d1c
19 changed files with 3270 additions and 14 deletions

View File

@@ -30,6 +30,7 @@ import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
import { Audio } from './audio';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
@@ -38,6 +39,7 @@ export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
@@ -68,6 +70,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Notifications',
icon: Icons.Bell,
},
{
page: SettingsPages.AudioPage,
name: 'Audio & Video',
icon: Icons.Phone,
},
{
page: SettingsPages.DevicesPage,
name: 'Devices',
@@ -219,6 +226,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AudioPage && (
<Audio requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}

View File

@@ -0,0 +1,831 @@
/* eslint-disable no-console */
import React, { useEffect, useState, MouseEventHandler } from 'react';
import {
Box,
Button,
config,
Header,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Switch,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { stopPropagation } from '../../../utils/keyboard';
/** Represents an audio device (microphone or speaker) */
interface AudioDevice {
deviceId: string;
label: string;
kind: MediaDeviceKind;
}
/** Screen share resolution presets */
export type ScreenShareResolution = '720p' | '1080p' | '1440p' | '4k' | 'source';
/** Screen share resolution configs */
export const SCREEN_SHARE_RESOLUTIONS: Record<ScreenShareResolution, { width: number; height: number; label: string }> = {
'720p': { width: 1280, height: 720, label: '720p (HD)' },
'1080p': { width: 1920, height: 1080, label: '1080p (Full HD)' },
'1440p': { width: 2560, height: 1440, label: '1440p (2K)' },
'4k': { width: 3840, height: 2160, label: '4K (Ultra HD)' },
'source': { width: 0, height: 0, label: 'Source Resolution' },
};
/** Screen share bitrate presets in kbps */
export type ScreenShareBitrate = 'low' | 'medium' | 'high' | 'ultra';
/** Screen share bitrate configs */
export const SCREEN_SHARE_BITRATES: Record<ScreenShareBitrate, { bitrate: number; label: string }> = {
'low': { bitrate: 1000, label: 'Low (1 Mbps)' },
'medium': { bitrate: 2500, label: 'Medium (2.5 Mbps)' },
'high': { bitrate: 5000, label: 'High (5 Mbps)' },
'ultra': { bitrate: 10000, label: 'Ultra (10 Mbps)' },
};
/** Stored audio device settings */
interface AudioSettings {
microphoneId: string | null;
speakerId: string | null;
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
screenShareResolution: ScreenShareResolution;
screenShareBitrate: ScreenShareBitrate;
screenShareFrameRate: number;
}
const AUDIO_SETTINGS_KEY = 'cinny_audio_settings';
/** Default audio settings */
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
microphoneId: null,
speakerId: null,
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
screenShareResolution: '1080p',
screenShareBitrate: 'high',
screenShareFrameRate: 30,
};
/**
* Loads audio settings from localStorage
* @returns The stored audio settings or defaults
*/
function loadAudioSettings(): AudioSettings {
try {
const stored = localStorage.getItem(AUDIO_SETTINGS_KEY);
if (stored) {
return { ...DEFAULT_AUDIO_SETTINGS, ...JSON.parse(stored) };
}
} catch (e) {
console.error('Failed to load audio settings:', e);
}
return DEFAULT_AUDIO_SETTINGS;
}
/**
* Saves audio settings to localStorage
* @param settings - The audio settings to save
*/
function saveAudioSettings(settings: AudioSettings): void {
try {
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {
console.error('Failed to save audio settings:', e);
}
}
/**
* Gets the currently stored audio settings
* @returns The stored audio settings
*/
export function getAudioSettings(): AudioSettings {
return loadAudioSettings();
}
type DeviceSelectorProps = {
devices: AudioDevice[];
selectedId: string | null;
onSelect: (deviceId: string) => void;
};
/**
* Dropdown menu for selecting an audio device
*/
function DeviceSelector({ devices, selectedId, onSelect }: DeviceSelectorProps) {
return (
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 ? (
<MenuItem size="300" variant="Surface" radii="300" disabled>
<Text size="T300">No devices found</Text>
</MenuItem>
) : (
devices.map((device) => (
<MenuItem
key={device.deviceId}
size="300"
variant={device.deviceId === selectedId ? 'Primary' : 'Surface'}
radii="300"
onClick={() => onSelect(device.deviceId)}
>
<Text size="T300" truncate>
{device.label || `Unknown Device (${device.deviceId.slice(0, 8)}...)`}
</Text>
</MenuItem>
))
)}
</Box>
</Menu>
);
}
type DeviceSelectButtonProps = {
devices: AudioDevice[];
selectedId: string | null;
onSelect: (deviceId: string) => void;
};
/**
* Button that opens a device selection dropdown
*/
function DeviceSelectButton({ devices, selectedId, onSelect }: DeviceSelectButtonProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedDevice = devices.find((d) => d.deviceId === selectedId);
const displayLabel = selectedDevice?.label || selectedDevice?.deviceId.slice(0, 8) || 'Default';
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (deviceId: string) => {
onSelect(deviceId);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(250) }}
>
<Text size="T300" truncate>
{displayLabel}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<DeviceSelector
devices={devices}
selectedId={selectedId}
onSelect={handleSelect}
/>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareResolutionSelectProps = {
value: ScreenShareResolution;
onChange: (value: ScreenShareResolution) => void;
};
/**
* Dropdown for selecting screen share resolution
*/
function ScreenShareResolutionSelect({ value, onChange }: ScreenShareResolutionSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (resolution: ScreenShareResolution) => {
onChange(resolution);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{SCREEN_SHARE_RESOLUTIONS[value].label}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{(Object.keys(SCREEN_SHARE_RESOLUTIONS) as ScreenShareResolution[]).map((res) => (
<MenuItem
key={res}
size="300"
variant={res === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(res)}
>
<Text size="T300">{SCREEN_SHARE_RESOLUTIONS[res].label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareBitrateSelectProps = {
value: ScreenShareBitrate;
onChange: (value: ScreenShareBitrate) => void;
};
/**
* Dropdown for selecting screen share bitrate
*/
function ScreenShareBitrateSelect({ value, onChange }: ScreenShareBitrateSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (bitrate: ScreenShareBitrate) => {
onChange(bitrate);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{SCREEN_SHARE_BITRATES[value].label}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{(Object.keys(SCREEN_SHARE_BITRATES) as ScreenShareBitrate[]).map((br) => (
<MenuItem
key={br}
size="300"
variant={br === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(br)}
>
<Text size="T300">{SCREEN_SHARE_BITRATES[br].label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareFrameRateSelectProps = {
value: number;
onChange: (value: number) => void;
};
const FRAME_RATES = [15, 24, 30, 60];
/**
* Dropdown for selecting screen share frame rate
*/
function ScreenShareFrameRateSelect({ value, onChange }: ScreenShareFrameRateSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (frameRate: number) => {
onChange(frameRate);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{value} FPS</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{FRAME_RATES.map((fr) => (
<MenuItem
key={fr}
size="300"
variant={fr === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(fr)}
>
<Text size="T300">{fr} FPS</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type AudioProps = {
requestClose: () => void;
};
/**
* Audio settings page component
* Allows users to select microphone and speaker devices for calls
*/
export function Audio({ requestClose }: AudioProps) {
const [microphones, setMicrophones] = useState<AudioDevice[]>([]);
const [speakers, setSpeakers] = useState<AudioDevice[]>([]);
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
useEffect(() => {
const loadDevices = async () => {
try {
// Request permission to access media devices
await navigator.mediaDevices.getUserMedia({ audio: true });
setPermissionStatus('granted');
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label,
kind: d.kind,
}));
const spks = devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label,
kind: d.kind,
}));
setMicrophones(mics);
setSpeakers(spks);
// If no device is selected yet, use the first available
if (!settings.microphoneId && mics.length > 0) {
setSettings((prev) => ({ ...prev, microphoneId: mics[0].deviceId }));
}
if (!settings.speakerId && spks.length > 0) {
setSettings((prev) => ({ ...prev, speakerId: spks[0].deviceId }));
}
} catch (e) {
console.error('Failed to enumerate devices:', e);
setPermissionStatus('denied');
}
};
loadDevices();
// Listen for device changes
const handleDeviceChange = () => {
loadDevices();
};
navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange);
};
}, [settings.microphoneId, settings.speakerId]);
const handleMicrophoneSelect = (deviceId: string) => {
const newSettings = { ...settings, microphoneId: deviceId };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleSpeakerSelect = (deviceId: string) => {
const newSettings = { ...settings, speakerId: deviceId };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleNoiseSuppressionChange = (enabled: boolean) => {
const newSettings = { ...settings, noiseSuppression: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleEchoCancellationChange = (enabled: boolean) => {
const newSettings = { ...settings, echoCancellation: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleAutoGainControlChange = (enabled: boolean) => {
const newSettings = { ...settings, autoGainControl: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareResolutionChange = (resolution: ScreenShareResolution) => {
const newSettings = { ...settings, screenShareResolution: resolution };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareBitrateChange = (bitrate: ScreenShareBitrate) => {
const newSettings = { ...settings, screenShareBitrate: bitrate };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareFrameRateChange = (frameRate: number) => {
const newSettings = { ...settings, screenShareFrameRate: frameRate };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleTestMicrophone = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : true,
});
// Create audio context to visualize
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
// Quick test - just log that it works
console.log('🎤 Microphone test: Audio stream acquired');
// Stop after a short delay
setTimeout(() => {
stream.getTracks().forEach((track) => track.stop());
audioContext.close();
console.log('🎤 Microphone test: Complete');
}, 2000);
} catch (e) {
console.error('Microphone test failed:', e);
}
};
const handleTestSpeaker = async () => {
try {
// Create a simple test tone
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 440; // A4 note
gainNode.gain.value = 0.1; // Low volume
oscillator.start();
console.log('🔊 Speaker test: Playing tone');
// Stop after a short delay
setTimeout(() => {
oscillator.stop();
audioContext.close();
console.log('🔊 Speaker test: Complete');
}, 1000);
} catch (e) {
console.error('Speaker test failed:', e);
}
};
return (
<Page>
<PageHeader>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton onClick={requestClose}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
<Text size="H4" truncate>
Audio & Video
</Text>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{permissionStatus === 'denied' && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Permission Required</Text>
</Box>
</Header>
<SettingTile
title="Microphone Access Denied"
description="Please allow microphone access in your browser settings to use audio features."
/>
</SequenceCard>
)}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Microphone</Text>
</Box>
</Header>
<SettingTile
title="Input Device"
description="Select which microphone to use for voice calls."
after={
<Box alignItems="Center" gap="200">
<DeviceSelectButton
devices={microphones}
selectedId={settings.microphoneId}
onSelect={handleMicrophoneSelect}
/>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
>
<Text size="T300">Test</Text>
</Button>
</Box>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Speaker</Text>
</Box>
</Header>
<SettingTile
title="Output Device"
description="Select which speaker to use for call audio."
after={
<Box alignItems="Center" gap="200">
<DeviceSelectButton
devices={speakers}
selectedId={settings.speakerId}
onSelect={handleSpeakerSelect}
/>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleTestSpeaker}
>
<Text size="T300">Test</Text>
</Button>
</Box>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Audio Processing</Text>
</Box>
</Header>
<SettingTile
title="Noise Suppression"
description="Reduce background noise from your microphone during calls."
after={
<Switch
variant="Primary"
value={settings.noiseSuppression}
onChange={handleNoiseSuppressionChange}
/>
}
/>
<SettingTile
title="Echo Cancellation"
description="Prevent echo when your speakers feed back into your microphone."
after={
<Switch
variant="Primary"
value={settings.echoCancellation}
onChange={handleEchoCancellationChange}
/>
}
/>
<SettingTile
title="Auto Gain Control"
description="Automatically adjust microphone volume for consistent levels."
after={
<Switch
variant="Primary"
value={settings.autoGainControl}
onChange={handleAutoGainControlChange}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Screen Sharing</Text>
</Box>
</Header>
<SettingTile
title="Resolution"
description="Maximum resolution when sharing your screen."
after={
<ScreenShareResolutionSelect
value={settings.screenShareResolution}
onChange={handleScreenShareResolutionChange}
/>
}
/>
<SettingTile
title="Bitrate"
description="Video quality for screen sharing. Higher uses more bandwidth."
after={
<ScreenShareBitrateSelect
value={settings.screenShareBitrate}
onChange={handleScreenShareBitrateChange}
/>
}
/>
<SettingTile
title="Frame Rate"
description="Frames per second when sharing your screen."
after={
<ScreenShareFrameRateSelect
value={settings.screenShareFrameRate}
onChange={handleScreenShareFrameRateChange}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Tips</Text>
</Box>
</Header>
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S300}` }}>
<Text size="T300">
If you don&apos;t see your devices, make sure they are connected and permissions are granted.
</Text>
<Text size="T300">
The &quot;Test&quot; button will help verify your devices are working correctly.
</Text>
<Text size="T300">
Changes are saved automatically and will apply to your next call.
</Text>
</Box>
</SequenceCard>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -0,0 +1 @@
export * from './Audio';