2 Commits

Author SHA1 Message Date
88401d6aa1 release: версия 0.0.34
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
2026-08-09 21:24:22 +03:00
ca0b1e23fa 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, переподключающий уже
открытый поток к любому новому узлу автоматически.
2026-08-09 21:22:34 +03:00
7 changed files with 113 additions and 40 deletions

View File

@@ -128,7 +128,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.33
VIDCONF_VERSION=0.0.34
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,26 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.34] — 2026-08-09
Правка к проверке устройств на входе (0.0.33): превью и кнопки
камеры/микрофона теперь и на шаге «Подключиться к конференции», не только
на карточке «Как вас зовут?». Закрывает реальный пробел — авторизованный
пользователь, входящий через `/join`, карточку «Как вас зовут?» не проходит
вовсе (сразу подключение) и раньше проверку устройств не видел никогда.
### Исправлено
- Превью камеры и кнопки вкл/выкл — на шаге ввода ссылки/номера конференции,
тем же компонентом, что и на карточке гостя. Поток живёт непрерывно на
обоих шагах — переход между ними не гасит и не переоткрывает камеру.
- Состояние кнопок «войти с камерой/микрофоном» теперь доезжает до комнаты
и для гостя (`input → guest-info → комната`), и для авторизованного
пользователя (`input → connecting → комната`).
- Починена грабля с превью между шагами: `<video>` на разных шагах —
разные DOM-узлы, обычный `ref` не переживал переезд между ними (картинка
гасла в чёрный прямоугольник, хотя поток оставался жив). `videoRef` хука
стал callback-ref, переподключающим поток к новому узлу автоматически.
## [0.0.33] — 2026-08-09
Проверка устройств на входе — отключаемый модуль (по умолчанию выключен).

View File

@@ -1 +1 @@
0.0.33
0.0.34

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.33}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.34}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул

View File

@@ -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`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку.

View File

@@ -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

View File

@@ -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>