Первоначальная версия VidConf
This commit is contained in:
120
frontend/src/components/room/ChatPanel.tsx
Normal file
120
frontend/src/components/room/ChatPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal file
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/room/RoomStage.tsx
Normal file
228
frontend/src/components/room/RoomStage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
frontend/src/components/room/RoomToolbar.tsx
Normal file
215
frontend/src/components/room/RoomToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/room/RoomTopbar.tsx
Normal file
62
frontend/src/components/room/RoomTopbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/room/stageFocus.ts
Normal file
133
frontend/src/components/room/stageFocus.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user