На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/ `room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее с телефона. Десктоп не меняется. Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение пальца и либо закрывает панель, либо возвращает её на место пружиной).
223 lines
9.8 KiB
TypeScript
223 lines
9.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { X } from 'lucide-react'
|
||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||
import { useToast } from '@/components/ui/ToastProvider'
|
||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||
|
||
interface DeviceSettingsDialogProps {
|
||
onClose: () => void
|
||
}
|
||
|
||
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
||
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||
|
||
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
||
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
|
||
return device.label || `${fallback} ${index + 1}`
|
||
}
|
||
|
||
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
|
||
function useIsCompactViewport(): boolean {
|
||
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||
|
||
useEffect(() => {
|
||
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||
media.addEventListener('change', handleChange)
|
||
return () => media.removeEventListener('change', handleChange)
|
||
}, [])
|
||
|
||
return isCompact
|
||
}
|
||
|
||
/**
|
||
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
||
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
||
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
|
||
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
|
||
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
||
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
|
||
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
|
||
* `deviceId` при следующем подключении.
|
||
*
|
||
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
|
||
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||
* Десктоп не меняется.
|
||
*
|
||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
||
* устройство «в никуда».
|
||
*/
|
||
export function DeviceSettingsDialog({ onClose }: 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<number | null>(null)
|
||
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 handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||
dragStartYRef.current = e.touches[0].clientY
|
||
setIsDragging(true)
|
||
}
|
||
|
||
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||
if (dragStartYRef.current === null) return
|
||
const delta = e.touches[0].clientY - dragStartYRef.current
|
||
if (delta > 0) setDragOffset(delta)
|
||
}
|
||
|
||
function handleHandleTouchEnd() {
|
||
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
|
||
onClose()
|
||
return
|
||
}
|
||
dragStartYRef.current = null
|
||
setIsDragging(false)
|
||
setDragOffset(0)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="device-settings-title"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={
|
||
isCompact && dragOffset
|
||
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
|
||
: undefined
|
||
}
|
||
>
|
||
{isCompact && (
|
||
<div
|
||
className="room-sheet-handle"
|
||
onTouchStart={handleHandleTouchStart}
|
||
onTouchMove={handleHandleTouchMove}
|
||
onTouchEnd={handleHandleTouchEnd}
|
||
/>
|
||
)}
|
||
|
||
<div className="room-modal-head">
|
||
<h2 id="device-settings-title">Настройки устройств</h2>
|
||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="room-field">
|
||
<label htmlFor="device-settings-mic">Микрофон</label>
|
||
<select
|
||
id="device-settings-mic"
|
||
value={mic.activeDeviceId}
|
||
onChange={(e) => void handleMicChange(e.target.value)}
|
||
>
|
||
{/* Заглушка на случай, пока activeDeviceId не совпадает ни с одним
|
||
устройством из списка (нет разрешения на медиа/список ещё не
|
||
перечислен) — без неё controlled-select рассинхронизируется с
|
||
DOM (ни одна из настоящих option не соответствует value). */}
|
||
<option value="" disabled>
|
||
Определяется…
|
||
</option>
|
||
{mic.devices.map((device, index) => (
|
||
<option key={device.deviceId} value={device.deviceId}>
|
||
{deviceLabel(device, index, 'Микрофон')}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="room-field">
|
||
<label htmlFor="device-settings-camera">Камера</label>
|
||
<select
|
||
id="device-settings-camera"
|
||
value={camera.activeDeviceId}
|
||
onChange={(e) => void handleCameraChange(e.target.value)}
|
||
>
|
||
<option value="" disabled>
|
||
Определяется…
|
||
</option>
|
||
{camera.devices.map((device, index) => (
|
||
<option key={device.deviceId} value={device.deviceId}>
|
||
{deviceLabel(device, index, 'Камера')}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</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>
|
||
)
|
||
}
|