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:
90
frontend/src/components/auth/DeviceCheckCard.tsx
Normal file
90
frontend/src/components/auth/DeviceCheckCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user