feat(auth): проверка устройств на входе — запрос доступа и превью камеры

Отключаемый модуль (instance_settings.device_check, дефолт выключен):
запрос доступа к камере/микрофону на LoginPage и в карточке "Как вас
зовут?" (JoinPage), живое зеркальное превью и кнопки вкл/выкл камеры и
микрофона там же. На JoinPage кнопки определяют, с чем гость войдёт в
конференцию (RoomPage.LiveKitRoom audio/video вместо жёстких false) —
на LoginPage только пре-авторизуют разрешение, без UI (карточка ведёт
в лобби, применить выбор некуда). Вход в комнату по умолчанию, как и
раньше, с выключенными микрофоном/камерой.

Публичный GET /api/v1/public/settings отдаёт флаг модуля обеим
страницам до аутентификации.
This commit is contained in:
2026-08-09 18:09:10 +03:00
parent ba01548088
commit 7ee68b17ee
19 changed files with 713 additions and 7 deletions

View File

@@ -0,0 +1,90 @@
import type { RefObject } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css'
interface DeviceCheckCardProps {
videoRef: RefObject<HTMLVideoElement | null>
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
hint: string | null
/** Одновременно и «войти с камерой/микрофоном включёнными», и (для видео)
* реальное состояние превью — см. докстринг `useDeviceCheckAccess`. */
videoEnabled: boolean
audioEnabled: boolean
onToggleVideo: () => void
onToggleAudio: () => void
}
/**
* Превью камеры + переключатели «микрофон/камера» — окошко превью и кнопки
* составляют одну композицию (кнопки не шире окошка, см. `device-check.css`).
* Кнопки без подписи — только пиктограмма (иначе не умещаются под окошком
* шириной в половину карточки); машиночитаемое состояние — `aria-label`.
* Кликабельны только после реального разрешения (`granted`) — до этого
* непонятно, что вообще включать.
*
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — хук
* привязывает `srcObject` к `videoRef.current` синхронно в момент получения
* потока, ДО следующего рендера; если бы элемент монтировался условно,
* в момент присвоения его ещё не было бы в DOM и поток повис бы никуда не
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку.
*
* `muted`+`playsInline`+`autoPlay` — обязательны: без них iOS Safari не
* запустит воспроизведение живого потока, и вместо своего лица пользователь
* увидит чёрный прямоугольник.
*/
export function DeviceCheckCard({
videoRef,
videoStatus,
audioStatus,
hint,
videoEnabled,
audioEnabled,
onToggleVideo,
onToggleAudio,
}: DeviceCheckCardProps) {
const showPlaceholder = videoStatus !== 'granted' || !videoEnabled
return (
<div className="device-check">
<div className="device-check-inner">
<div className="device-check-frame">
<video ref={videoRef} muted playsInline autoPlay className="device-check-video" />
{showPlaceholder && (
<div className="device-check-placeholder" aria-hidden="true">
<VideoOff className="lucide" />
</div>
)}
</div>
<div className="device-check-toggles">
<button
type="button"
className={`device-check-toggle${audioEnabled ? ' is-on' : ''}`}
disabled={audioStatus !== 'granted'}
onClick={onToggleAudio}
aria-pressed={audioEnabled}
aria-label={audioEnabled ? 'Войти с выключенным микрофоном' : 'Войти с включённым микрофоном'}
>
{audioEnabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
</button>
<button
type="button"
className={`device-check-toggle${videoEnabled ? ' is-on' : ''}`}
disabled={videoStatus !== 'granted'}
onClick={onToggleVideo}
aria-pressed={videoEnabled}
aria-label={videoEnabled ? 'Выключить камеру' : 'Включить камеру'}
>
{videoEnabled ? <Video className="lucide" aria-hidden="true" /> : <VideoOff className="lucide" aria-hidden="true" />}
</button>
</div>
</div>
{hint && <p className="field-hint device-check-hint">{hint}</p>}
</div>
)
}