feat(room): выбор устройства аудиовывода в настройках
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind: 'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices (LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново при следующем подключении через RoomOptions.audioOutput. Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent: там, где браузер не умеет переключать вывод (iOS Safari), список скрыт, вместо него — подсказка, что маршрутизация звука на стороне системы.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface DeviceSettingsDialogProps {
|
interface DeviceSettingsDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -12,12 +13,20 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Диалог «Настройки устройств» — два селекта
|
* Диалог «Настройки устройств» — три селекта
|
||||||
* на хуках `@livekit/components-react`: список устройств и переключение —
|
* на хуках `@livekit/components-react`: список устройств и переключение —
|
||||||
* целиком в `useMediaDeviceSelect` (сама подписана на
|
* целиком в `useMediaDeviceSelect` (сама подписана на
|
||||||
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
|
* `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры — в
|
||||||
* (localStorage, читается заново при следующем входе в комнату — см.
|
* `usePersistentUserChoices` (localStorage, читается заново при следующем
|
||||||
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
||||||
|
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) —
|
||||||
|
* `LocalUserChoices` LiveKit про него не знает, а `RoomOptions.audioOutput`
|
||||||
|
* читает сохранённый `deviceId` при следующем подключении.
|
||||||
|
*
|
||||||
|
* Выбор устройства вывода показываем только там, где браузер реально умеет
|
||||||
|
* им управлять (`isAudioOutputSelectable` — проверка по возможностям
|
||||||
|
* `setSinkId`, не по User-Agent): в iOS Safari метода нет вообще, вместо
|
||||||
|
* списка — подсказка, что переключение звука там на стороне системы.
|
||||||
*
|
*
|
||||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||||
@@ -29,6 +38,8 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
||||||
|
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
const speakerSelectable = isAudioOutputSelectable()
|
||||||
|
|
||||||
async function handleMicChange(deviceId: string) {
|
async function handleMicChange(deviceId: string) {
|
||||||
try {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="room-modal-overlay"
|
className="room-modal-overlay"
|
||||||
@@ -104,6 +124,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{speakerSelectable ? (
|
||||||
|
<div className="room-field">
|
||||||
|
<label htmlFor="device-settings-speaker">Динамики</label>
|
||||||
|
<select
|
||||||
|
id="device-settings-speaker"
|
||||||
|
value={speaker.activeDeviceId}
|
||||||
|
onChange={(e) => void handleSpeakerChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Определяется…
|
||||||
|
</option>
|
||||||
|
{speaker.devices.map((device, index) => (
|
||||||
|
<option key={device.deviceId} value={device.deviceId}>
|
||||||
|
{deviceLabel(device, index, 'Динамик')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="room-field">
|
||||||
|
<span className="room-field-label">Динамики</span>
|
||||||
|
<p className="room-field-hint">
|
||||||
|
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы — этот
|
||||||
|
браузер не позволяет управлять им со страницы.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
33
frontend/src/lib/audioOutputDevice.ts
Normal file
33
frontend/src/lib/audioOutputDevice.ts
Normal file
@@ -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 {
|
||||||
|
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage'
|
|||||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
@@ -163,6 +164,12 @@ export function RoomPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||||||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||||||
|
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
|
||||||
|
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
|
||||||
|
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
|
||||||
|
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
|
||||||
|
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||||
|
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||||
}),
|
}),
|
||||||
[userChoices],
|
[userChoices],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
|
.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; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ---------- Чип «Вы демонстрируете экран» ----------
|
* ---------- Чип «Вы демонстрируете экран» ----------
|
||||||
|
|||||||
Reference in New Issue
Block a user