diff --git a/frontend/src/components/room/DeviceSettingsDialog.tsx b/frontend/src/components/room/DeviceSettingsDialog.tsx index 742b10a..63b3501 100644 --- a/frontend/src/components/room/DeviceSettingsDialog.tsx +++ b/frontend/src/components/room/DeviceSettingsDialog.tsx @@ -1,6 +1,7 @@ import { X } from 'lucide-react' import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' +import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice' interface DeviceSettingsDialogProps { onClose: () => void @@ -12,12 +13,20 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): } /** - * Диалог «Настройки устройств» — два селекта + * Диалог «Настройки устройств» — три селекта * на хуках `@livekit/components-react`: список устройств и переключение — * целиком в `useMediaDeviceSelect` (сама подписана на - * `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices` - * (localStorage, читается заново при следующем входе в комнату — см. - * `RoomPage.tsx`, `options` пропс `LiveKitRoom`). + * `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры — в + * `usePersistentUserChoices` (localStorage, читается заново при следующем + * входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`). + * Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — + * `LocalUserChoices` LiveKit про него не знает, а `RoomOptions.audioOutput` + * читает сохранённый `deviceId` при следующем подключении. + * + * Выбор устройства вывода показываем только там, где браузер реально умеет + * им управлять (`isAudioOutputSelectable` — проверка по возможностям + * `setSinkId`, не по User-Agent): в iOS Safari метода нет вообще, вместо + * списка — подсказка, что переключение звука там на стороне системы. * * ДОЛЖЕН рендериться внутри ``: `useMediaDeviceSelect` без явно * переданного `room` берёт активную комнату из `RoomContext` — вне контекста @@ -29,6 +38,8 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices() const mic = useMediaDeviceSelect({ kind: 'audioinput' }) const camera = useMediaDeviceSelect({ kind: 'videoinput' }) + const speaker = useMediaDeviceSelect({ kind: 'audiooutput' }) + const speakerSelectable = isAudioOutputSelectable() async function handleMicChange(deviceId: string) { try { @@ -49,6 +60,15 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { } } + async function handleSpeakerChange(deviceId: string) { + try { + await speaker.setActiveMediaDevice(deviceId) + saveAudioOutputDeviceId(deviceId) + } catch { + toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error') + } + } + return (
+ + {speakerSelectable ? ( +
+ + +
+ ) : ( +
+ Динамики +

+ Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы — этот + браузер не позволяет управлять им со страницы. +

+
+ )} ) diff --git a/frontend/src/lib/audioOutputDevice.ts b/frontend/src/lib/audioOutputDevice.ts new file mode 100644 index 0000000..6b7089e --- /dev/null +++ b/frontend/src/lib/audioOutputDevice.ts @@ -0,0 +1,33 @@ +const STORAGE_KEY = 'vidconf-audio-output-device' + +/** + * Поддержка выбора устройства аудиовывода — определяем по наличию + * `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще + * (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть. + */ +export function isAudioOutputSelectable(): boolean { + return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype +} + +/** + * Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices` + * LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера), + * поля для аудиовыхода там нет. + */ +export function loadAudioOutputDeviceId(): string { + try { + return localStorage.getItem(STORAGE_KEY) ?? '' + } catch { + // localStorage недоступен (приватный режим/политики браузера) — без + // сохранённого выбора, устройство по умолчанию. + return '' + } +} + +export function saveAudioOutputDeviceId(deviceId: string): void { + try { + localStorage.setItem(STORAGE_KEY, deviceId) + } catch { + // Сохранение недоступно — выбор продержится до конца сессии в комнате. + } +} diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index 6ea9102..56b5cd8 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage' import { RoomToolbar } from '@/components/room/RoomToolbar' import { ChatPanel } from '@/components/room/ChatPanel' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' +import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' interface RoomJoinState { livekitUrl: string @@ -163,6 +164,12 @@ export function RoomPage() { () => ({ audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined }, videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined }, + // Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через + // usePersistentUserChoices: LocalUserChoices LiveKit про него не знает + // (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании — + // как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog + // (setActiveMediaDevice), а не пересозданием roomOptions. + audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined }, }), [userChoices], ) diff --git a/frontend/src/styles/room.css b/frontend/src/styles/room.css index 0714b06..52c57fa 100644 --- a/frontend/src/styles/room.css +++ b/frontend/src/styles/room.css @@ -378,6 +378,8 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } font-family: var(--font-body); } .room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); } +.room-field-label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); } +.room-field-hint { font: var(--text-body); color: var(--color-room-text-secondary); margin: 0; } /* * ---------- Чип «Вы демонстрируете экран» ----------