fix(auth): превью и проверка устройств на шаге "Подключиться к конференции"

Продолжение 33: раньше превью показывалось только на карточке "Как вас
зовут?" (guest-info), а авторизованный пользователь, входящий через
/join, этот шаг вообще не проходит (сразу connecting) — значит, никогда
не видел проверку устройств и не мог задать enterWithVideo/Audio.

Теперь превью и кнопки — на обоих шагах (input и guest-info), с одним
непрерывным потоком: hook enabled/release эффект завязаны на общий флаг
"мы на одном из шагов с превью", а не на конкретный step, иначе переход
input -> guest-info выглядел бы для эффекта как уход с гашением камеры.

Заодно нашёл и починил реальную грабли: <video> на разных шагах — это
разные DOM-узлы (разные позиции в JSX), обычный ref.current не пережил
бы переезд между ними — поток остаётся жив, но картинка гаснет в чёрный
прямоугольник. videoRef хука теперь callback-ref, переподключающий уже
открытый поток к любому новому узлу автоматически.
This commit is contained in:
2026-08-09 21:22:34 +03:00
parent 9463e64e73
commit ca0b1e23fa
3 changed files with 90 additions and 37 deletions

View File

@@ -1,10 +1,12 @@
import type { RefObject } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react' import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess' import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css' import '@/styles/device-check.css'
interface DeviceCheckCardProps { interface DeviceCheckCardProps {
videoRef: RefObject<HTMLVideoElement | null> /** Коллбэк-реф из `useDeviceCheckAccess` — см. докстринг там: карточка
* может рендериться в разных местах JSX-дерева (разные шаги `JoinPage`),
* обычный объект-реф не пережил бы такой переезд. */
videoRef: (node: HTMLVideoElement | null) => void
videoStatus: DeviceCheckStatus videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus audioStatus: DeviceCheckStatus
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */ /** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
@@ -25,10 +27,10 @@ interface DeviceCheckCardProps {
* Кликабельны только после реального разрешения (`granted`) — до этого * Кликабельны только после реального разрешения (`granted`) — до этого
* непонятно, что вообще включать. * непонятно, что вообще включать.
* *
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — хук * `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — коллбэк-реф
* привязывает `srcObject` к `videoRef.current` синхронно в момент получения * хука подключает `srcObject` при КАЖДОМ монтировании узла (см. докстринг
* потока, ДО следующего рендера; если бы элемент монтировался условно, * `useDeviceCheckAccess`); если бы элемент монтировался условно, в момент
* в момент присвоения его ещё не было бы в DOM и поток повис бы никуда не * присвоения его ещё не было бы в DOM и поток повис бы никуда не
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера * подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает * выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку. * поток (см. докстринг хука), а не просто прячет картинку.

View File

@@ -6,9 +6,16 @@ export type DeviceCheckStatus = 'idle' | 'pending' | 'granted' | 'denied'
interface UseDeviceCheckAccessResult { interface UseDeviceCheckAccessResult {
videoStatus: DeviceCheckStatus videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus audioStatus: DeviceCheckStatus
/** Привязать к `<video>` превью — хук сам управляет `srcObject` по мере /** Привязать к `ref` `<video>` превью — коллбэк, НЕ объект-реф: карточка
* появления/остановки потока камеры. */ * превью рендерится в разных местах JSX-дерева на разных шагах
videoRef: React.RefObject<HTMLVideoElement | null> * (`input`/`guest-info` в `JoinPage`), и React монтирует для каждого места
* СВОЙ DOM-узел `<video>`, хотя тип компонента один и тот же — обычный
* `ref.current` продолжал бы указывать на старый (уже отмонтированный)
* узел. Коллбэк вызывается при каждом монтировании нового узла и сам
* подключает уже открытый поток (см. докстринг хука) — без этого переход
* между шагами давал бы на месте превью чёрный прямоугольник: поток жив,
* но не подключён к новому элементу. */
videoRef: (node: HTMLVideoElement | null) => void
/** Повесить на карточку (`onPointerDown`/`onKeyDown`) — первое взаимодействие /** Повесить на карточку (`onPointerDown`/`onKeyDown`) — первое взаимодействие
* внутри неё запускает запрос доступа. Идемпотентно, повторные вызовы — * внутри неё запускает запрос доступа. Идемпотентно, повторные вызовы —
* no-op (см. `requestedRef`). */ * no-op (см. `requestedRef`). */
@@ -88,7 +95,17 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
const toggleAudioEnabled = useCallback(() => setAudioEnabled((v) => !v), []) const toggleAudioEnabled = useCallback(() => setAudioEnabled((v) => !v), [])
const videoStreamRef = useRef<MediaStream | null>(null) const videoStreamRef = useRef<MediaStream | null>(null)
const videoRef = useRef<HTMLVideoElement | null>(null) const videoNodeRef = useRef<HTMLVideoElement | null>(null)
// См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает
// уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого
// переход между шагами с превью терял бы картинку (но не поток — камера
// продолжала бы физически работать, просто без видимого превью).
const videoRef = useCallback((node: HTMLVideoElement | null) => {
videoNodeRef.current = node
if (node) {
node.srcObject = videoStreamRef.current
}
}, [])
const requestedRef = useRef(false) const requestedRef = useRef(false)
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана // Полный сброс — не только остановка треков, но и статусы/флаги/охрана
@@ -103,8 +120,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null videoStreamRef.current = null
} }
if (videoRef.current) { if (videoNodeRef.current) {
videoRef.current.srcObject = null videoNodeRef.current.srcObject = null
} }
requestedRef.current = false requestedRef.current = false
setVideoStatus('idle') setVideoStatus('idle')
@@ -127,8 +144,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
try { try {
const stream = await openStream('video', userChoicesRef.current.videoDeviceId) const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
videoStreamRef.current = stream videoStreamRef.current = stream
if (videoRef.current) { if (videoNodeRef.current) {
videoRef.current.srcObject = stream videoNodeRef.current.srcObject = stream
} }
setVideoStatus('granted') setVideoStatus('granted')
// Первичная верификация сразу показывает превью — «включено» по факту // Первичная верификация сразу показывает превью — «включено» по факту
@@ -152,8 +169,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null videoStreamRef.current = null
} }
if (videoRef.current) { if (videoNodeRef.current) {
videoRef.current.srcObject = null videoNodeRef.current.srcObject = null
} }
setVideoEnabled(false) setVideoEnabled(false)
return return

View File

@@ -70,29 +70,44 @@ export function JoinPage() {
const autoResolvedRef = useRef(false) const autoResolvedRef = useRef(false)
// Проверка устройств на входе (сессия 33) — только на карточке «Как вас // Проверка устройств на входе (сессия 34, правка к сессии 33) — теперь на
// зовут?» (гостевой шаг), не на вводе ссылки/пароле. Отключаемый модуль, // ДВУХ шагах: «Подключиться к конференции» (`input`) и «Как вас зовут?»
// дефолт выключен — см. `instance_settings.device_check`. Кнопки «войти // (`guest-info`), не только на гостевом. Это закрывает реальную дыру:
// с камерой/микрофоном» здесь, в отличие от LoginPage, РЕАЛЬНО определяют // авторизованный пользователь, входящий через /join, шаг `guest-info`
// вообще не проходит (см. `handleResolve` — сразу `connecting`), и без
// превью на `input` проверка устройств и кнопки «войти с камерой/
// микрофоном» ему были бы недоступны никогда. Отключаемый модуль, дефолт
// выключен — см. `instance_settings.device_check`. Кнопки «войти с
// камерой/микрофоном», в отличие от LoginPage, РЕАЛЬНО определяют
// состояние входа — см. `performJoin` (решение оператора, сессия 33). // состояние входа — см. `performJoin` (решение оператора, сессия 33).
const { data: publicSettings } = usePublicSettings() const { data: publicSettings } = usePublicSettings()
const deviceCheckModuleEnabled = publicSettings?.device_check_enabled ?? false const deviceCheckModuleEnabled = publicSettings?.device_check_enabled ?? false
const deviceCheck = useDeviceCheckAccess(step === 'guest-info' && deviceCheckModuleEnabled) // Флаг «мы сейчас на одном из шагов с превью» — ОБЩИЙ для обоих шагов, а
// не сравнение с конкретным `step`, иначе переход `input → guest-info`
// (тот же самый непрерывный проход, а не уход с превью) выглядел бы для
// эффекта ниже как «ушли → зашли снова»: React перезапускает эффект при
// ЛЮБОЙ смене значения в зависимостях, а `step` меняется на каждом шаге.
// С единым булевым флагом смена `input → guest-info` не меняет флаг
// (true → true) — эффект не перезапускается, поток не мигает и не
// перезахватывается (что вдобавок раскрутило бы известную грабли с
// deviceId `"default"`, см. `useDeviceCheckAccess.ts`).
const inDeviceCheckSteps = deviceCheckModuleEnabled && (step === 'input' || step === 'guest-info')
const deviceCheck = useDeviceCheckAccess(inDeviceCheckSteps)
// Деструктурируем то, что нужно эффекту ниже, — `deviceCheck` целиком // Деструктурируем то, что нужно эффекту ниже, — `deviceCheck` целиком
// пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по // пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по
// себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект // себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект
// в зависимостях). // в зависимостях).
const { release: releaseDeviceCheck } = deviceCheck const { release: releaseDeviceCheck } = deviceCheck
// Карточка «Как вас зовут?» — это условный рендер ОДНОГО и того же // Шаги флоу — условный рендер ОДНОГО и того же компонента (не
// компонента (шаги флоу не размонтируют JoinPage), поэтому размонтирование // размонтируют JoinPage), поэтому размонтирование хука само по себе
// хука само по себе камеру не освободит: и уход вперёд (пароль/коннект), // камеру не освободит: уход С ОБОИХ шагов превью (вперёд пароль/
// и назад («Назад» → `resetToInput`) должны погасить камеру явно. Эффект // коннект, назад — в лобби) должен погасить камеру явно. Эффект с
// с cleanup по смене `step` ловит оба направления одним местом, а не // cleanup по смене `inDeviceCheckSteps` (не `step`!) ловит именно уход из
// отдельным release() в каждом обработчике перехода. // группы шагов одним местом, а не любой шаг внутри нее.
useEffect(() => { useEffect(() => {
if (step !== 'guest-info') return if (!inDeviceCheckSteps) return
return () => releaseDeviceCheck() return () => releaseDeviceCheck()
}, [step, releaseDeviceCheck]) }, [inDeviceCheckSteps, releaseDeviceCheck])
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не // Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
// дожидаясь клика по «Продолжить», но только после того как известен // дожидаясь клика по «Продолжить», но только после того как известен
@@ -183,10 +198,12 @@ export function JoinPage() {
handQueueEnabled: data.hand_queue_enabled, handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap, publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles, stageMaxTiles: data.stage_max_tiles,
// Кнопки превью «войти с камерой/микрофоном» (только гостевой шаг // Кнопки превью «войти с камерой/микрофоном» — теперь доступны и
// JoinPage, см. докстринг выше) — при их отсутствии (модуль // гостю (input → guest-info → сюда), и авторизованному пользователю
// выключен, авторизованный пользователь минул guest-info) // (input → connecting → сюда, guest-info он не проходит вовсе, см.
// остаются false, RoomPage ведёт себя как раньше. // докстринг выше) одним и тем же `deviceCheck`, живущим с шага
// `input`. При модуле выключенном оба флага остаются `false`,
// RoomPage ведёт себя как раньше.
enterWithVideo: deviceCheck.videoEnabled, enterWithVideo: deviceCheck.videoEnabled,
enterWithAudio: deviceCheck.audioEnabled, enterWithAudio: deviceCheck.audioEnabled,
}, },
@@ -255,10 +272,27 @@ export function JoinPage() {
<main className="join-main"> <main className="join-main">
{step === 'input' && ( {step === 'input' && (
<section className="screen-block"> <section className="screen-block">
<div className="flow-card"> <div
<div className="flow-icon" aria-hidden="true"> className="flow-card"
<Link2 className="icon" aria-hidden="true" /> onPointerDown={deviceCheck.triggerOnGesture}
</div> onKeyDown={deviceCheck.triggerOnGesture}
>
{deviceCheckModuleEnabled ? (
<DeviceCheckCard
videoRef={deviceCheck.videoRef}
videoStatus={deviceCheck.videoStatus}
audioStatus={deviceCheck.audioStatus}
hint={deviceCheck.hint}
videoEnabled={deviceCheck.videoEnabled}
audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled}
/>
) : (
<div className="flow-icon" aria-hidden="true">
<Link2 className="icon" aria-hidden="true" />
</div>
)}
<h1>Подключиться к конференции</h1> <h1>Подключиться к конференции</h1>
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p> <p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>