Files
vidconf/frontend/src/components/room/DeviceSettingsDialog.tsx
Max Ronzhin 06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00

245 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`. Поэтому и заголовок панели на
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `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<number | null>(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<HTMLDivElement>) {
dragEligibleRef.current = e.currentTarget.scrollTop === 0
if (!dragEligibleRef.current) return
dragStartYRef.current = e.touches[0].clientY
setIsDragging(true)
}
function handlePanelTouchMove(e: React.TouchEvent<HTMLDivElement>) {
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 (
<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()}
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 && <div className="room-sheet-handle" />}
<div className="room-modal-head">
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</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>
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
{isCompact && (
<div className="stage-view-section">
<StageViewOptions
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
</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>
)
}