Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { AlertCircle, Send, X } from 'lucide-react'
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
import { formatLocalTime } from '@/lib/localTime'
interface ChatPanelProps {
messages: ChatMessageOut[]
status: ChatConnectionStatus
statusMessage: string | null
onSend: (text: string) => void
onClose: () => void
}
/**
* Панель чата комнаты конференции (см. design/mockups/room.html, блок
* `.chat-panel`). Всегда в развёрнутом виде рендерится только пока сама
* панель открыта — сворачивание/разворачивание и счётчик непрочитанных
* управляются на уровне RoomPage (WS-соединение живёт независимо от того,
* открыта ли панель, — иначе при сворачивании терялась бы история).
*
* Textarea, а не `<input>` из макета — сознательное отступление ради
* Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными
* пилюле-полю из макета.
*/
export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) {
const [draft, setDraft] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Автоскролл к последнему сообщению — только если пользователь и так был
// внизу списка, чтобы не мешать чтению прокрученной вверх истории.
useEffect(() => {
const el = listRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceFromBottom < 120) {
el.scrollTop = el.scrollHeight
}
}, [messages])
function handleSend() {
const text = draft.trim()
if (!text || status !== 'open') return
onSend(text)
setDraft('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
setDraft(event.target.value)
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
const el = event.target
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}
return (
<aside className="chat-panel">
<div className="chat-head">
<h2>Чат встречи</h2>
<button type="button" aria-label="Свернуть чат" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{statusMessage && (
<p className="chat-status-banner">
<AlertCircle className="lucide" aria-hidden="true" /> {statusMessage}
</p>
)}
<div className="chat-messages" ref={listRef}>
{messages.length === 0 && status === 'open' && !statusMessage && (
<p className="chat-empty">Сообщений пока нет начните обсуждение</p>
)}
{messages.map((message) => (
<div className="msg" key={message.id}>
<div className="msg-meta">
<span className="msg-author">
{message.author_name}
{message.is_guest && <span className="msg-guest-badge">гость</span>}
</span>
<span className="msg-time">{formatLocalTime(message.created_at)}</span>
</div>
<div className="msg-bubble">{message.text}</div>
</div>
))}
</div>
<form
className="chat-input-row"
onSubmit={(event) => {
event.preventDefault()
handleSend()
}}
>
<textarea
ref={textareaRef}
rows={1}
placeholder="Написать сообщение…"
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
disabled={status !== 'open'}
maxLength={2000}
/>
<button type="submit" aria-label="Отправить" disabled={!draft.trim() || status !== 'open'}>
<Send className="lucide" aria-hidden="true" />
</button>
</form>
</aside>
)
}

View File

@@ -0,0 +1,110 @@
import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
interface DeviceSettingsDialogProps {
onClose: () => void
}
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}`
}
/**
* Диалог «Настройки устройств» — два селекта
* на хуках `@livekit/components-react`: список устройств и переключение —
* целиком в `useMediaDeviceSelect` (сама подписана на
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
* (localStorage, читается заново при следующем входе в комнату — см.
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
* устройство «в никуда».
*/
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
const toast = useToast()
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
async function handleMicChange(deviceId: string) {
try {
await mic.setActiveMediaDevice(deviceId)
saveAudioInputDeviceId(deviceId)
} catch {
// activeDeviceId хука — источник истины, состояние селекта само не меняется.
toast.show('Не удалось переключить микрофон — устройство занято или отключено', 'error')
}
}
async function handleCameraChange(deviceId: string) {
try {
await camera.setActiveMediaDevice(deviceId)
saveVideoInputDeviceId(deviceId)
} catch {
toast.show('Не удалось переключить камеру — устройство занято или отключено', 'error')
}
}
return (
<div
className="room-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="device-settings-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<div className="room-field">
<label htmlFor="device-settings-mic">Микрофон</label>
<select
id="device-settings-mic"
value={mic.activeDeviceId}
onChange={(e) => void handleMicChange(e.target.value)}
>
{/* Заглушка на случай, пока activeDeviceId не совпадает ни с одним
устройством из списка (нет разрешения на медиа/список ещё не
перечислен) — без неё controlled-select рассинхронизируется с
DOM (ни одна из настоящих option не соответствует value). */}
<option value="" disabled>
Определяется
</option>
{mic.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Микрофон')}
</option>
))}
</select>
</div>
<div className="room-field">
<label htmlFor="device-settings-camera">Камера</label>
<select
id="device-settings-camera"
value={camera.activeDeviceId}
onChange={(e) => void handleCameraChange(e.target.value)}
>
<option value="" disabled>
Определяется
</option>
{camera.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Камера')}
</option>
))}
</select>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { ScreenShare } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
ConnectionQualityIndicator,
FocusToggle,
LockLockedIcon,
ParticipantName,
ParticipantTile,
ScreenShareIcon,
TrackMutedIndicator,
VideoTrack,
isTrackReference,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useParticipantInfo,
type ParticipantClickEvent,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
avatar_url?: string | null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
}
}
/**
* Содержимое плитки — рендерится ВНУТРИ `ParticipantTile` (значит, внутри её
* `TrackRefContext`/`ParticipantContext`), поэтому берёт трек и участника из
* контекста теми же хуками, что использует сама библиотека в оригинальной
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const trackReference = useEnsureTrackRef()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на
// ParticipantMetadataChanged); в текущей версии metadata приходит статично
// с токена при входе и в течение сеанса не меняется, но хук — штатный
// способ читать её реактивно, если это когда-нибудь изменится.
const { name, metadata } = useParticipantInfo({ participant: trackReference.participant })
const avatarUrl = parseAvatarUrl(metadata)
const displayName = name || trackReference.participant.identity
// Чип «Вы демонстрируете экран» — только на СВОЕЙ демонстрации;
// `onStopSharing` передаётся снаружи (`RoomStage.tsx`) только для фокус-
// плитки, поэтому в карусели/гриде чип не появится даже при том же треке.
const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
)
return (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack trackRef={trackReference} manageSubscription={autoManageSubscription} />
) : (
isTrackReference(trackReference) && <AudioTrack trackRef={trackReference} />
)}
{/* Вместо штатной иконки-заглушки (ParticipantPlaceholder) — аватар
участника: фото по avatar_url из метаданных, фолбэк — инициалы имени
(см. `ui/Avatar`). Видимость (opacity) этого блока при выключенной
камере управляется тем же CSS-правилом библиотеки
(`[data-lk-video-muted='true'][data-lk-source='camera'] .lk-participant-placeholder`)
— data-атрибуты на корневой div выставляет сам `ParticipantTile`
независимо от children. */}
<div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div>
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
<>
{isEncrypted && <LockLockedIcon style={{ marginRight: '0.25rem' }} />}
<TrackMutedIndicator
trackRef={{ participant: trackReference.participant, source: Track.Source.Microphone }}
show="muted"
/>
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
{/* `ParticipantName` рендерит `participant.name` (или identity),
а `children` — необязательный суффикс ПОСЛЕ имени; оригинал
дописывал сюда английское `'s screen` («Имя's screen»),
здесь — русский суффикс («Имя — демонстрация экрана»). */}
<ParticipantName>{' — демонстрация экрана'}</ParticipantName>
</>
)}
</div>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
<FocusToggle trackRef={trackReference} />
{showSharingChip && (
<div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" />
<span>Вы демонстрируете экран</span>
<button type="button" onClick={onStopSharing}>
Остановить
</button>
</div>
)}
</>
)
}
interface RoomParticipantTileProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
* Передавать только для фокус-плитки — в карусели демонстрация
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
}
/**
* Обёртка над штатным `ParticipantTile` (@livekit/components-react 2.9.23,
* версия запинена в package.json — при апгрейде библиотеки сверить разметку
* заново): та же структура (видео/аудио-трек, блок метаданных с именем и индикатором мьюта,
* FocusToggle), но вместо стандартной SVG-пиктограммы при выключенной камере —
* аватар участника. Работает и для локального участника (свои метаданные из
* собственного токена), и для удалённых.
*
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
* мёртвым кодом без провайдера контекста.
*/
export function RoomParticipantTile({
trackRef,
disableSpeakingIndicator,
onParticipantClick,
onStopSharing,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
trackRef={trackRef}
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} />
</ParticipantTile>
)
}

View File

@@ -0,0 +1,228 @@
import { useState } from 'react'
import { Track } from 'livekit-client'
import {
CarouselLayout,
FocusLayoutContainer,
GridLayout,
RoomAudioRenderer,
isTrackReference,
useRoomContext,
useSpeakingParticipants,
useTracks,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus'
/**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
* источники треков для `useTracks`. Важно: внутренний `useMemo` хука
* `useTracks` держит СЫРОЙ параметр `sources` в списке зависимостей (не
* только `trackReferences`/`participants`), поэтому инлайновый литерал
* массива в вызове (`useTracks([{ source: ... }], ...)`) пересоздавался бы
* заново при каждом рендере `RoomStage` и ломал мемоизацию: `tracks` был бы
* НОВОЙ ссылкой на каждый рендер даже без реальных изменений участников.
* Раньше это было безобидно (код ниже не сравнивал `tracks` по ссылке
* между рендерами), но стало критично после того, как ниже появилось
* состояние, которое обновляется именно по признаку «пришёл новый `tracks`»
* (см. комментарий у `prevTracks`) — без этой константы получался бесконечный
* цикл рендеров.
*
* Демонстрация экрана: к камере добавлен `Track.Source.ScreenShare`
* без плейсхолдера (демонстрация либо есть, либо участника просто нет в этом
* наборе — в отличие от камеры, «пустая» демонстрация не показывается вовсе).
* Штатный паттерн смешанных источников — как в `VideoConference` из самой
* `@livekit/components-react`.
*/
const STAGE_TRACK_SOURCES = [
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */
function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
/**
* Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
* участниках, GridLayout при одном/двух).
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
* колонка сбоку, скролл по вертикали). Это штатное поведение самого
* `@livekit/components-react` (2.9.23) — `FocusLayoutContainer` рендерит
* `.lk-focus-layout` (`grid-template-columns: 1fr 5fr` — первая, узкая
* колонка ровно под превью), а `CarouselLayout` без явного `orientation`
* сам определяет направление по РАЗМЕРАМ СВОЕГО КОНТЕЙНЕРА (`height >= width`
* → `vertical`) — важно НЕ передавать сюда className, перекрывающий базовый
* `lk-carousel` (см. её исходник: `{...props}` после `className="lk-carousel"`
* в JSX полностью заменяет, а не объединяет класс — раньше здесь было
* `className="preview-row"`, из-за этого терялись все стили направления/
* авторазмера тайлов, и лента визуально превращалась в горизонтальный ряд).
* Адаптив уже встроен в @livekit/components-styles: на ≤600px
* `.lk-focus-layout` сам переключается в колонку (спикер сверху, превью —
* узкая горизонтальная полоса снизу, `.lk-carousel{order:1}`) — без
* дополнительного JS с нашей стороны. Точечные доводки размеров/скроллбара —
* styles/room.css (`.stage-tiles`/`.lk-carousel`).
*
* Демонстрация экрана: при наличии хотя бы одной активной
* демонстрации `FocusLayoutContainer` форсируется БЕЗУСЛОВНО (независимо от
* числа участников) — в фокусе демонстрация, в карусели ВСЕ camera-треки,
* включая камеру самого демонстратора (решение архитектора). Без демонстрации
* поведение не меняется — прежняя раскладка по спикеру/первому треку.
*
* Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер
* (`variant="full"`, дефолт) не меняется вовсе.
*/
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
const room = useRoomContext()
const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false,
})
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of
// Hooks) — для `variant="full"` его результат просто не используется.
const speakingParticipants = useSpeakingParticipants()
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
const hasScreenShare = screenShareTracks.length > 0
// Кто «в фокусе» — храним по составному ключу (см. `stageTrackKey` и
// обоснование в `stageFocus.ts`) в состоянии, а не пересчитываем заново из
// порядка `tracks` на каждом рендере. Порядок этого массива не
// гарантированно стабилен между кадрами (у @livekit/components-core он
// может поменяться при переходе placeholder → реальный трек, при
// подключении нового участника или при старте/остановке демонстрации), и
// если брать `tracks[0]`/`.find()` напрямую при каждом рендере, «в фокусе»
// и «в карусели» на паре соседних рендеров может оказаться то один, то
// другой участник — тогда CarouselLayout получает на входе список, где
// полностью сменился единственный элемент (был трек A, стал трек B), а её
// собственный `useVisualStableUpdate` считает это не reflow'ом, а
// поломанным состоянием и кидает (пойманную и залогированную, но всё
// равно шумную) `Error: Element not part of the array`.
//
// Вся логика выбора «что дальше в фокусе» вынесена в чистую функцию
// `pickStageFocus` (last-wins для нового шэра, удержание текущего фокуса,
// фолбэк на активного спикера/первый трек) — здесь только вызов в
// санкционированном React-паттерне «adjusting state during rendering» (не
// useEffect — eslint-plugin-react-hooks не даёт ни читать ref во время
// рендера, ни синхронно звать setState внутри эффекта), под охраной
// сравнения с предыдущим `tracks` — сравниваем по ссылке, `useTracks` отдаёт
// новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки).
//
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый
// массив только при реальном изменении состава/порядка говорящих), и
// передаётся `followSpeaker: true` — фокус живо переключается на нового
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null)
const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants
if (tracksChanged || speakingChanged) {
const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно:
// здесь строго тот же расчёт, что был в основном окне ДО этой правки
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK,
// чтобы followSpeaker не дёргался на каждый чих).
const speakingCameraKeys =
variant === 'pip'
? speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey),
screenShareKeys: screenShareTracks.map(stageTrackKey),
speakingCameraKeys,
prevKeys,
prevFocusKey: focusKey,
followSpeaker: variant === 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем).
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
})
if (result.focusKey !== focusKey) {
setFocusKey(result.focusKey)
}
}
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
// ВСЕ демонстрации, проигравшие фокус (второй демонстратор при last-wins —
// см. `pickStageFocus`): её трек продолжает публиковаться, и, по решению
// архитектора, должен остаться видимой обычной плиткой в карусели, а не
// молча пропадать из UI. Сравнение — по тому же ключу `identity:source`,
// что и у `pickStageFocus` (устойчивее прямого сравнения ссылок между
// рендерами). Когда демонстратор один — его screenshare как раз и есть
// focusTrack, фильтр исключает его из карусели (дубля нет).
const carouselTracks = hasScreenShare
? [...cameraTracks, ...screenShareTracks.filter((t) => stageTrackKey(t) !== focusTrackKey)]
: tracks.filter((t) => t !== focusTrack)
/**
* Остановить СВОЮ демонстрацию — передаётся в фокус-плитку, чип «Вы
* демонстрируете экран» и его видимость решает сама `RoomParticipantTile`
* (показывает только когда трек фокуса — своя демонстрация экрана).
*/
function handleStopSharing() {
void room.localParticipant.setScreenShareEnabled(false)
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть.
if (variant === 'pip') {
return (
<section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<RoomAudioRenderer />
</section>
)
}
return (
<section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
<GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile />
</GridLayout>
) : (
<FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}>
<RoomParticipantTile />
</CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
(см. её исходник), поэтому вместо неё используем свою обёртку
напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
</FocusLayoutContainer>
)}
<RoomAudioRenderer />
</section>
)
}

View File

@@ -0,0 +1,215 @@
import {
LogOut,
Maximize,
MessageSquare,
Mic,
MicOff,
Minimize,
PictureInPicture2,
ScreenShare,
ScreenShareOff,
Settings,
Video,
VideoOff,
} from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран». Вынесено в
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
* но константа явнее фиксирует неизменность опций.
*/
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}
interface RoomToolbarProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
chatVisible: boolean
chatOpen: boolean
/** Число непрочитанных сообщений, накопленных со времени последнего открытия панели. */
chatUnreadCount: number
onToggleChat: () => void
/** Открыть диалог настроек устройств. */
onToggleSettings: () => void
/** `document.fullscreenEnabled` — false скрывает кнопку. */
fullscreenSupported: boolean
fullscreenActive: boolean
onToggleFullscreen: () => void
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — false в Firefox скрывает кнопку. */
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
}
/**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html.
*/
export function RoomToolbar({
chatVisible,
chatOpen,
chatUnreadCount,
onToggleChat,
onToggleSettings,
fullscreenSupported,
fullscreenActive,
onToggleFullscreen,
pipSupported,
pipActive,
onTogglePiP,
}: RoomToolbarProps) {
const toast = useToast()
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
// Отмена браузерного диалога выбора экрана/окна (пользователь нажал
// «Отмена») приходит сюда как NotAllowedError — молча игнорируем, это не
// ошибка приложения. Прочие сбои (например, `NotReadableError` при
// занятом источнике) показываем тостом.
onDeviceError: (error) => {
if (error.name === 'NotAllowedError') return
toast.show('Не удалось начать демонстрацию экрана', 'error')
},
})
return (
<footer className="room-toolbar">
<button
type="button"
{...mic.buttonProps}
className={`tb-btn${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
<span className="icon-shell">
{mic.enabled ? (
<Mic className="lucide" aria-hidden="true" />
) : (
<MicOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Микрофон</span>
</button>
<button
type="button"
{...camera.buttonProps}
className={`tb-btn${camera.enabled ? '' : ' is-off'}`}
aria-label={camera.enabled ? 'Выключить камеру' : 'Включить камеру'}
>
<span className="icon-shell">
{camera.enabled ? (
<Video className="lucide" aria-hidden="true" />
) : (
<VideoOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Камера</span>
</button>
<button
type="button"
{...screenShare.buttonProps}
className={`tb-btn${screenShare.enabled ? ' is-sharing' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
>
<span className="icon-shell">
{screenShare.enabled ? (
<ScreenShareOff className="lucide" aria-hidden="true" />
) : (
<ScreenShare className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Демонстрация</span>
</button>
<button
type="button"
className="tb-btn"
aria-label="Настройки устройств"
onClick={onToggleSettings}
>
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
</button>
{fullscreenSupported && (
<button
type="button"
className={`tb-btn${fullscreenActive ? ' is-panel-open' : ''}`}
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
aria-pressed={fullscreenActive}
onClick={onToggleFullscreen}
>
<span className="icon-shell">
{fullscreenActive ? (
<Minimize className="lucide" aria-hidden="true" />
) : (
<Maximize className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Экран</span>
</button>
)}
{pipSupported && (
<button
type="button"
className={`tb-btn${pipActive ? ' is-panel-open' : ''}`}
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
aria-pressed={pipActive}
onClick={onTogglePiP}
>
<span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" />
</span>
<span className="label">Мини-окно</span>
</button>
)}
{chatVisible && (
<button
type="button"
className={`tb-btn${chatOpen ? ' is-panel-open' : ''}`}
aria-label={chatOpen ? 'Свернуть чат' : 'Открыть чат'}
aria-pressed={chatOpen}
onClick={onToggleChat}
>
<span className="icon-shell">
<MessageSquare className="lucide" aria-hidden="true" />
{chatUnreadCount > 0 && (
<span className="badge-count">{chatUnreadCount > 9 ? '9+' : chatUnreadCount}</span>
)}
</span>
<span className="label">Чат</span>
</button>
)}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
<span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" />
</span>
<span className="label">Выйти</span>
</DisconnectButton>
</footer>
)
}

View File

@@ -0,0 +1,62 @@
import { Check, Copy, Users } from 'lucide-react'
import { useParticipants } from '@livekit/components-react'
import { useState } from 'react'
import { pluralizeParticipants } from '@/lib/pluralize'
interface RoomTopbarProps {
roomName: string
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string
number?: string
}
/**
* Верхняя панель комнаты: название, число участников и инвайт-чип
* (номер + копирование ссылки-приглашения `/j/:slug`).
*
* Примечание: в design/mockups/room.html этого чипа нет — он добавлен,
* чтобы ссылка и номер конференции были видны и копировались, в стиле
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм.
*/
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
const participants = useParticipants()
const [copied, setCopied] = useState(false)
async function handleCopy() {
if (!slug) return
const link = `${window.location.origin}/j/${slug}`
try {
await navigator.clipboard.writeText(link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Буфер обмена недоступен (нет разрешения/не https) — молча игнорируем,
// это некритичный вспомогательный элемент интерфейса.
}
}
return (
<header className="room-topbar">
<div className="room-title-block">
<h1>{roomName}</h1>
<p>
<span className="rec-dot" aria-hidden="true" />
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
{participants.length} {pluralizeParticipants(participants.length)}
</p>
</div>
{slug && (
<button type="button" className="room-invite-chip" onClick={handleCopy}>
<span>{number ? `${number}` : slug}</span>
{copied ? (
<Check style={{ width: 14, height: 14 }} aria-hidden="true" />
) : (
<Copy style={{ width: 14, height: 14 }} aria-hidden="true" />
)}
{copied ? 'Скопировано' : 'Пригласить'}
</button>
)}
</header>
)
}

View File

@@ -0,0 +1,133 @@
/**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
*
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы,
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера
* (vitest в проект сознательно не вводим, тестируем вручную).
*
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
* - голой identity недостаточно — у демонстратора одновременно живут camera-
* и screenshare-трек, и по одной identity нельзя понять, какой из них в
* фокусе;
* - чистый sid публикации нестабилен для камеры: каждое вкл/выкл камеры
* (`setCameraEnabled`) — это unpublish/publish с НОВЫМ sid, а при
* выключенной камере используется placeholder без sid вовсе (см. исходный
* комментарий в RoomStage.tsx про гонку placeholder → реальный трек). Если
* бы ключом был sid, сфокусированный участник терял бы фокус на каждом
* цикле мьюта камеры.
* `identity:source` даёт и устойчивость (не меняется при мьюте камеры), и
* однозначность (camera и screen_share одного участника — разные ключи).
*/
/** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share'
export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[]
/** Ключи текущих screenshare-треков (один на активную демонстрацию). */
screenShareKeys: readonly string[]
/**
* Подмножество `cameraKeys` — говорящие сейчас участники, УПОРЯДОЧЕННОЕ по
* громкости (первый — самый громкий; см. `Room.activeSpeakers`, который
* сортирует именно так) — для фолбэка на активного спикера и, при
* `followSpeaker`, для живого переключения фокуса.
*/
speakingCameraKeys: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null
/**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за
* активным спикером (переключаться сразу, а не удерживать текущий), в
* отличие от основного окна сцены — там держим фокус, даже если заговорил
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` —
* поведение основного окна не меняется.
*/
followSpeaker?: boolean
/**
* Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
* никто не говорит, лучше показать «себя», чем произвольного первого
* участника (актуально для свежего открытия PiP). Не указан — фолбэк не
* меняется (последний, по порядку `cameraKeys`).
*/
localKey?: string | null
}
export interface PickStageFocusResult {
/** Ключ трека в фокусе; `null` — треков нет вовсе. */
focusKey: string | null
kind: StageFocusKind | null
}
/**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
*
* Правила (PiP показывает только активное окно):
* 1. Last-wins: если с прошлого рендера появился НОВЫЙ screenshare-ключ —
* фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ
* переходит на него, даже если текущий фокус ещё жив. В основном окне
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3.
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»
* для PiP, когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если
* указан и жив), иначе первая по порядку.
*/
export function pickStageFocus({
cameraKeys,
screenShareKeys,
speakingCameraKeys,
prevKeys,
prevFocusKey,
followSpeaker = false,
localKey = null,
}: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
return { focusKey: null, kind: null }
}
const prevKeySet = new Set(prevKeys)
const newScreenShareKeys = screenShareKeys.filter((key) => !prevKeySet.has(key))
if (newScreenShareKeys.length > 0) {
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
}
if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key))
if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' }
}
}
if (prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (prevFocusKey && cameraKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'camera' }
}
if (screenShareKeys.length > 0) {
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
}
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key))
if (speaking) {
return { focusKey: speaking, kind: 'camera' }
}
if (localKey && cameraKeys.includes(localKey)) {
return { focusKey: localKey, kind: 'camera' }
}
return { focusKey: cameraKeys[0] ?? null, kind: cameraKeys[0] ? 'camera' : null }
}