import { useRef, useState } from 'react' import { X } from 'lucide-react' import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useModalDismiss } from '@/hooks/useModalDismiss' import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions' import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice' interface DeviceSettingsDialogProps extends StageViewProps { onClose: () => void } /** Свайп панели вниз дальше этого порога (px) закрывает шторку, меньше — она возвращается на место. */ const SHEET_DISMISS_THRESHOLD_PX = 80 /** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string { return device.label || `${fallback} ${index + 1}` } /** * Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`: * список устройств и переключение целиком в `useMediaDeviceSelect` (сама * подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры — * в `usePersistentUserChoices` (localStorage, читается заново при следующем * входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`). * Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices` * LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый * `deviceId` при следующем подключении. * * На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо * модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз по ЛЮБОМУ * месту панели (не только за ручку-волосок `.room-sheet-handle` — та её не * пережила бы: 40×4px, попасть пальцем почти нереально). Свайп срабатывает, * только если содержимое панели проскроллено в самый верх (`scrollTop === 0` * на touchstart, см. `dragEligibleRef`) — иначе палец должен листать список * устройств, а не закрывать окно; решение фиксируется на весь жест, а не * пересчитывается на каждый touchmove, как в любом стандартном bottom sheet. * Десктоп не меняется. * * Там же, и только там, первой секцией идёт «Вид» (режим показа участников и * скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной * кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на * мобильном шире по смыслу («Настройки», а не «Настройки устройств»). * * ДОЛЖЕН рендериться внутри ``: `useMediaDeviceSelect` без явно * переданного `room` берёт активную комнату из `RoomContext` — вне контекста * он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы * устройство «в никуда». */ export function DeviceSettingsDialog({ onClose, layoutMode, onLayoutModeChange, hideOthers, onHideOthersChange, }: DeviceSettingsDialogProps) { const toast = useToast() const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices() const mic = useMediaDeviceSelect({ kind: 'audioinput' }) const camera = useMediaDeviceSelect({ kind: 'videoinput' }) const speaker = useMediaDeviceSelect({ kind: 'audiooutput' }) const speakerSelectable = isAudioOutputSelectable() const isCompact = useIsCompactViewport() useModalDismiss(onClose) const dragStartYRef = useRef(null) // Жест начат, когда контент панели был проскроллен в самый верх — свайп по // панели, у которой ещё есть что скроллить, должен листать содержимое, а не // закрывать шторку. Решается ОДИН раз в touchstart и держится весь жест // (даже если внутри него направление сменится) — так же, как в любом // стандартном bottom sheet. const dragEligibleRef = useRef(false) const [dragOffset, setDragOffset] = useState(0) const [isDragging, setIsDragging] = useState(false) async function handleMicChange(deviceId: string) { try { await mic.setActiveMediaDevice(deviceId) saveAudioInputDeviceId(deviceId) } catch { // activeDeviceId хука — источник истины, состояние селекта само не меняется. toast.show('Не удалось переключить микрофон — устройство занято или отключено', 'error') } } async function handleCameraChange(deviceId: string) { try { await camera.setActiveMediaDevice(deviceId) saveVideoInputDeviceId(deviceId) } catch { toast.show('Не удалось переключить камеру — устройство занято или отключено', 'error') } } async function handleSpeakerChange(deviceId: string) { try { await speaker.setActiveMediaDevice(deviceId) saveAudioOutputDeviceId(deviceId) } catch { toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error') } } function handlePanelTouchStart(e: React.TouchEvent) { dragEligibleRef.current = e.currentTarget.scrollTop === 0 if (!dragEligibleRef.current) return dragStartYRef.current = e.touches[0].clientY setIsDragging(true) } function handlePanelTouchMove(e: React.TouchEvent) { if (!dragEligibleRef.current || dragStartYRef.current === null) return const delta = e.touches[0].clientY - dragStartYRef.current if (delta > 0) setDragOffset(delta) } function handlePanelTouchEnd() { if (!dragEligibleRef.current) return if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) { onClose() return } dragStartYRef.current = null setIsDragging(false) setDragOffset(0) } return (
e.stopPropagation()} onTouchStart={isCompact ? handlePanelTouchStart : undefined} onTouchMove={isCompact ? handlePanelTouchMove : undefined} onTouchEnd={isCompact ? handlePanelTouchEnd : undefined} style={ isCompact && dragOffset ? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined } : undefined } > {isCompact &&
}

{isCompact ? 'Настройки' : 'Настройки устройств'}

{/* Обёртка НЕ `.room-field`: там `label { display: block }` со специфичностью выше, чем у `.stage-view-mode` — пункты режимов рассыпались бы в столбик (кружок отдельно, текст под ним). */} {isCompact && (
)}
{speakerSelectable ? (
) : (
Динамики

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

)}
) }