From b11feac5297c47f01cdc8435e1f14bb30178ea42 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Mon, 27 Jul 2026 17:32:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(room):=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D0=B0=D1=8F=20=D1=88=D1=82=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/ `room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее с телефона. Десктоп не меняется. Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение пальца и либо закрывает панель, либо возвращает её на место пружиной). --- .../components/room/DeviceSettingsDialog.tsx | 92 ++++++++++++++++--- frontend/src/styles/room.css | 40 ++++++++ 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/room/DeviceSettingsDialog.tsx b/frontend/src/components/room/DeviceSettingsDialog.tsx index 63b3501..bceac76 100644 --- a/frontend/src/components/room/DeviceSettingsDialog.tsx +++ b/frontend/src/components/room/DeviceSettingsDialog.tsx @@ -1,32 +1,51 @@ +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, читается заново при следующем + * Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`: + * список устройств и переключение целиком в `useMediaDeviceSelect` (сама + * подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры — + * в `usePersistentUserChoices` (localStorage, читается заново при следующем * входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`). - * Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — - * `LocalUserChoices` LiveKit про него не знает, а `RoomOptions.audioOutput` - * читает сохранённый `deviceId` при следующем подключении. + * Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices` + * LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый + * `deviceId` при следующем подключении. * - * Выбор устройства вывода показываем только там, где браузер реально умеет - * им управлять (`isAudioOutputSelectable` — проверка по возможностям - * `setSinkId`, не по User-Agent): в iOS Safari метода нет вообще, вместо - * списка — подсказка, что переключение звука там на стороне системы. + * На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо + * модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку. + * Десктоп не меняется. * * ДОЛЖЕН рендериться внутри ``: `useMediaDeviceSelect` без явно * переданного `room` берёт активную комнату из `RoomContext` — вне контекста @@ -40,6 +59,13 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { const camera = useMediaDeviceSelect({ kind: 'videoinput' }) const speaker = useMediaDeviceSelect({ kind: 'audiooutput' }) const speakerSelectable = isAudioOutputSelectable() + const isCompact = useIsCompactViewport() + + useModalDismiss(onClose) + + const dragStartYRef = useRef(null) + const [dragOffset, setDragOffset] = useState(0) + const [isDragging, setIsDragging] = useState(false) async function handleMicChange(deviceId: string) { try { @@ -69,15 +95,53 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { } } + function handleHandleTouchStart(e: React.TouchEvent) { + dragStartYRef.current = e.touches[0].clientY + setIsDragging(true) + } + + function handleHandleTouchMove(e: React.TouchEvent) { + 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 (
-
e.stopPropagation()}> +
e.stopPropagation()} + style={ + isCompact && dragOffset + ? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined } + : undefined + } + > + {isCompact && ( +
+ )} +

Настройки устройств