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:
@@ -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} />
|
||||
)}
|
||||
|
||||
831
src/app/features/settings/audio/Audio.tsx
Normal file
831
src/app/features/settings/audio/Audio.tsx
Normal 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't see your devices, make sure they are connected and permissions are granted.
|
||||
</Text>
|
||||
<Text size="T300">
|
||||
• The "Test" 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>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/audio/index.ts
Normal file
1
src/app/features/settings/audio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Audio';
|
||||
Reference in New Issue
Block a user