feat(room): компактная шторка настроек устройств на мобильном
На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/ `room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее с телефона. Десктоп не меняется. Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение пальца и либо закрывает панель, либо возвращает её на место пружиной).
This commit is contained in:
@@ -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`) и свайпу вниз за ручку.
|
||||
* Десктоп не меняется.
|
||||
*
|
||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `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<number | null>(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<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="room-modal-overlay"
|
||||
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="device-settings-title"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<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}>
|
||||
|
||||
@@ -381,6 +381,46 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
.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; }
|
||||
|
||||
/*
|
||||
* ---------- Шторка «Настройки устройств» на мобильном ----------
|
||||
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
|
||||
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
|
||||
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
|
||||
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
|
||||
* смещение пальца и сам решает, закрывать или вернуть панель на место;
|
||||
* `transition` тут — только пружина возврата, во время самого драга
|
||||
* компонент подставляет инлайновый `transition: none`).
|
||||
*/
|
||||
.room-sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.room-sheet-panel {
|
||||
background: var(--color-room-surface-raised);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: var(--shadow-room-panel);
|
||||
padding: var(--space-4) var(--space-6) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
.room-sheet-handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-room-tile-border);
|
||||
margin: 0 auto var(--space-4);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* ---------- Чип «Вы демонстрируете экран» ----------
|
||||
* Рендерится внутри `RoomParticipantTile` (см. её `TileBody`) — т.е. внутри
|
||||
|
||||
Reference in New Issue
Block a user