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:
@@ -1,10 +1,12 @@
|
||||
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>
|
||||
/** Коллбэк-реф из `useDeviceCheckAccess` — см. докстринг там: карточка
|
||||
* может рендериться в разных местах JSX-дерева (разные шаги `JoinPage`),
|
||||
* обычный объект-реф не пережил бы такой переезд. */
|
||||
videoRef: (node: HTMLVideoElement | null) => void
|
||||
videoStatus: DeviceCheckStatus
|
||||
audioStatus: DeviceCheckStatus
|
||||
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
|
||||
@@ -25,10 +27,10 @@ interface DeviceCheckCardProps {
|
||||
* Кликабельны только после реального разрешения (`granted`) — до этого
|
||||
* непонятно, что вообще включать.
|
||||
*
|
||||
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — хук
|
||||
* привязывает `srcObject` к `videoRef.current` синхронно в момент получения
|
||||
* потока, ДО следующего рендера; если бы элемент монтировался условно,
|
||||
* в момент присвоения его ещё не было бы в DOM и поток повис бы никуда не
|
||||
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — коллбэк-реф
|
||||
* хука подключает `srcObject` при КАЖДОМ монтировании узла (см. докстринг
|
||||
* `useDeviceCheckAccess`); если бы элемент монтировался условно, в момент
|
||||
* присвоения его ещё не было бы в DOM и поток повис бы никуда не
|
||||
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
|
||||
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
|
||||
* поток (см. докстринг хука), а не просто прячет картинку.
|
||||
|
||||
@@ -6,9 +6,16 @@ export type DeviceCheckStatus = 'idle' | 'pending' | 'granted' | 'denied'
|
||||
interface UseDeviceCheckAccessResult {
|
||||
videoStatus: DeviceCheckStatus
|
||||
audioStatus: DeviceCheckStatus
|
||||
/** Привязать к `<video>` превью — хук сам управляет `srcObject` по мере
|
||||
* появления/остановки потока камеры. */
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
/** Привязать к `ref` `<video>` превью — коллбэк, НЕ объект-реф: карточка
|
||||
* превью рендерится в разных местах JSX-дерева на разных шагах
|
||||
* (`input`/`guest-info` в `JoinPage`), и React монтирует для каждого места
|
||||
* СВОЙ DOM-узел `<video>`, хотя тип компонента один и тот же — обычный
|
||||
* `ref.current` продолжал бы указывать на старый (уже отмонтированный)
|
||||
* узел. Коллбэк вызывается при каждом монтировании нового узла и сам
|
||||
* подключает уже открытый поток (см. докстринг хука) — без этого переход
|
||||
* между шагами давал бы на месте превью чёрный прямоугольник: поток жив,
|
||||
* но не подключён к новому элементу. */
|
||||
videoRef: (node: HTMLVideoElement | null) => void
|
||||
/** Повесить на карточку (`onPointerDown`/`onKeyDown`) — первое взаимодействие
|
||||
* внутри неё запускает запрос доступа. Идемпотентно, повторные вызовы —
|
||||
* no-op (см. `requestedRef`). */
|
||||
@@ -88,7 +95,17 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
const toggleAudioEnabled = useCallback(() => setAudioEnabled((v) => !v), [])
|
||||
|
||||
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)
|
||||
|
||||
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана
|
||||
@@ -103,8 +120,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
videoStreamRef.current = null
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = null
|
||||
}
|
||||
requestedRef.current = false
|
||||
setVideoStatus('idle')
|
||||
@@ -127,8 +144,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
try {
|
||||
const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
|
||||
videoStreamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = stream
|
||||
}
|
||||
setVideoStatus('granted')
|
||||
// Первичная верификация сразу показывает превью — «включено» по факту
|
||||
@@ -152,8 +169,8 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
videoStreamRef.current = null
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = null
|
||||
}
|
||||
setVideoEnabled(false)
|
||||
return
|
||||
|
||||
@@ -70,29 +70,44 @@ export function JoinPage() {
|
||||
|
||||
const autoResolvedRef = useRef(false)
|
||||
|
||||
// Проверка устройств на входе (сессия 33) — только на карточке «Как вас
|
||||
// зовут?» (гостевой шаг), не на вводе ссылки/пароле. Отключаемый модуль,
|
||||
// дефолт выключен — см. `instance_settings.device_check`. Кнопки «войти
|
||||
// с камерой/микрофоном» здесь, в отличие от LoginPage, РЕАЛЬНО определяют
|
||||
// Проверка устройств на входе (сессия 34, правка к сессии 33) — теперь на
|
||||
// ДВУХ шагах: «Подключиться к конференции» (`input`) и «Как вас зовут?»
|
||||
// (`guest-info`), не только на гостевом. Это закрывает реальную дыру:
|
||||
// авторизованный пользователь, входящий через /join, шаг `guest-info`
|
||||
// вообще не проходит (см. `handleResolve` — сразу `connecting`), и без
|
||||
// превью на `input` проверка устройств и кнопки «войти с камерой/
|
||||
// микрофоном» ему были бы недоступны никогда. Отключаемый модуль, дефолт
|
||||
// выключен — см. `instance_settings.device_check`. Кнопки «войти с
|
||||
// камерой/микрофоном», в отличие от LoginPage, РЕАЛЬНО определяют
|
||||
// состояние входа — см. `performJoin` (решение оператора, сессия 33).
|
||||
const { data: publicSettings } = usePublicSettings()
|
||||
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` целиком
|
||||
// пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по
|
||||
// себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект
|
||||
// в зависимостях).
|
||||
const { release: releaseDeviceCheck } = deviceCheck
|
||||
// Карточка «Как вас зовут?» — это условный рендер ОДНОГО и того же
|
||||
// компонента (шаги флоу не размонтируют JoinPage), поэтому размонтирование
|
||||
// хука само по себе камеру не освободит: и уход вперёд (пароль/коннект),
|
||||
// и назад («Назад» → `resetToInput`) должны погасить камеру явно. Эффект
|
||||
// с cleanup по смене `step` ловит оба направления одним местом, а не
|
||||
// отдельным release() в каждом обработчике перехода.
|
||||
// Шаги флоу — условный рендер ОДНОГО и того же компонента (не
|
||||
// размонтируют JoinPage), поэтому размонтирование хука само по себе
|
||||
// камеру не освободит: уход С ОБОИХ шагов превью (вперёд — пароль/
|
||||
// коннект, назад — в лобби) должен погасить камеру явно. Эффект с
|
||||
// cleanup по смене `inDeviceCheckSteps` (не `step`!) ловит именно уход из
|
||||
// группы шагов одним местом, а не любой шаг внутри нее.
|
||||
useEffect(() => {
|
||||
if (step !== 'guest-info') return
|
||||
if (!inDeviceCheckSteps) return
|
||||
return () => releaseDeviceCheck()
|
||||
}, [step, releaseDeviceCheck])
|
||||
}, [inDeviceCheckSteps, releaseDeviceCheck])
|
||||
|
||||
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
|
||||
// дожидаясь клика по «Продолжить», но только после того как известен
|
||||
@@ -183,10 +198,12 @@ export function JoinPage() {
|
||||
handQueueEnabled: data.hand_queue_enabled,
|
||||
publishQualityCap: data.publish_quality_cap,
|
||||
stageMaxTiles: data.stage_max_tiles,
|
||||
// Кнопки превью «войти с камерой/микрофоном» (только гостевой шаг
|
||||
// JoinPage, см. докстринг выше) — при их отсутствии (модуль
|
||||
// выключен, авторизованный пользователь минул guest-info)
|
||||
// остаются false, RoomPage ведёт себя как раньше.
|
||||
// Кнопки превью «войти с камерой/микрофоном» — теперь доступны и
|
||||
// гостю (input → guest-info → сюда), и авторизованному пользователю
|
||||
// (input → connecting → сюда, guest-info он не проходит вовсе, см.
|
||||
// докстринг выше) одним и тем же `deviceCheck`, живущим с шага
|
||||
// `input`. При модуле выключенном оба флага остаются `false`,
|
||||
// RoomPage ведёт себя как раньше.
|
||||
enterWithVideo: deviceCheck.videoEnabled,
|
||||
enterWithAudio: deviceCheck.audioEnabled,
|
||||
},
|
||||
@@ -255,10 +272,27 @@ export function JoinPage() {
|
||||
<main className="join-main">
|
||||
{step === 'input' && (
|
||||
<section className="screen-block">
|
||||
<div className="flow-card">
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
<Link2 className="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<div
|
||||
className="flow-card"
|
||||
onPointerDown={deviceCheck.triggerOnGesture}
|
||||
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>
|
||||
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user