Files
vidconf/frontend/src/components/auth/DeviceCheckCard.tsx
Max Ronzhin 17437880b1 feat(room): замена фона видео на картинку — только на десктопе
Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`LocalVideoTrack.setProcessor`), а НЕ пересозданием
`RoomOptions` — ссылка на них обязана оставаться стабильной, иначе
`LiveKitRoom` переподключается к комнате.

Фича только для ДЕСКТОПА, и «десктоп» определяется по возможностям устройства
(`pointer: fine` + `hover: hover` + `maxTouchPoints`), а НЕ по ширине окна:
узкое окно на десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет,
который сегментация греет. На мобильном кнопки нет вовсе, а не задизейбленной.

Ассеты сегментации отдаются СО СВОЕГО домена: библиотека по умолчанию тянет
wasm с jsdelivr, а модель с storage.googleapis.com, и в закрытом контуре фича
молча не работала бы. Модель (Apache 2.0, см. NOTICE.txt) лежит в репозитории,
wasm-рантайм (~19 МБ) копируется из node_modules плагином сборки. Сама
библиотека и модель грузятся ЛЕНИВО — только когда фон реально включают, вход
в конференцию не стал медленнее.

Три дефолтные сцены — собственные векторные рисунки (`design/backgrounds/`),
а не фотографии из интернета: у нарисованной сцены нет чужой лицензии, а
продукт расходится по инсталляциям, и проверять права на каждую копию некому.

Свои картинки — в профиле, до 10 штук, с уменьшением до 1280px и переводом в
WebP прямо в браузере перед отправкой. Удаление применённого сейчас фона
сбрасывает выбор на «без фона»: хранится ключ записи, а не URL картинки.

Смена камеры фон не теряет (`restartTrack` перезапускает процессор сам),
выключение и включение камеры — навешивает его на новый трек заново.

⚠️ Прокси dev-сервера для `/media/` — обязательно со слэшем: ключ `/media`
Vite матчит префиксом и перехватывает заодно `/mediapipe/...`, из-за чего
модель получала 404 и фон молча не включался.
2026-08-10 08:59:47 +03:00

104 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css'
interface DeviceCheckCardProps {
/** Коллбэк-реф из `useDeviceCheckAccess` — см. докстринг там: карточка
* может рендериться в разных местах JSX-дерева (разные шаги `JoinPage`),
* обычный объект-реф не пережил бы такой переезд. */
videoRef: (node: HTMLVideoElement | null) => void
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
hint: string | null
/** Одновременно и «войти с камерой/микрофоном включёнными», и (для видео)
* реальное состояние превью — см. докстринг `useDeviceCheckAccess`. */
videoEnabled: boolean
audioEnabled: boolean
onToggleVideo: () => void
onToggleAudio: () => void
/**
* Выбор фона под кнопками (сессия 35) — приходит готовым узлом, а не набором
* пропсов: карточка про доступ к устройствам и знать про фоны, их лимиты и
* загрузку не обязана. `undefined` — модуль выключен, устройство не
* десктопное либо браузер не умеет сегментацию; тогда блока нет вовсе.
*/
backgroundPicker?: ReactNode
}
/**
* Превью камеры + переключатели «микрофон/камера» — окошко превью и кнопки
* составляют одну композицию (кнопки не шире окошка, см. `device-check.css`).
* Кнопки без подписи — только пиктограмма (иначе не умещаются под окошком
* шириной в половину карточки); машиночитаемое состояние — `aria-label`.
* Кликабельны только после реального разрешения (`granted`) — до этого
* непонятно, что вообще включать.
*
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — коллбэк-реф
* хука подключает `srcObject` при КАЖДОМ монтировании узла (см. докстринг
* `useDeviceCheckAccess`); если бы элемент монтировался условно, в момент
* присвоения его ещё не было бы в DOM и поток повис бы никуда не
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку.
*
* `muted`+`playsInline`+`autoPlay` — обязательны: без них iOS Safari не
* запустит воспроизведение живого потока, и вместо своего лица пользователь
* увидит чёрный прямоугольник.
*/
export function DeviceCheckCard({
videoRef,
videoStatus,
audioStatus,
hint,
videoEnabled,
audioEnabled,
onToggleVideo,
onToggleAudio,
backgroundPicker,
}: 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>
{backgroundPicker && <div className="device-check-backgrounds">{backgroundPicker}</div>}
{hint && <p className="field-hint device-check-hint">{hint}</p>}
</div>
)
}