Files
vidconf/frontend/src/components/room/RoomParticipantTile.tsx
Max Ronzhin 5daff3b7af feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:

- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
  вопреки говорящим, но уступает любой активной демонстрации экрана; как
  только демонстрация закончилась, фокус возвращается именно на
  закреплённого (правило стоит выше удержания предыдущего фокуса).
  Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
  из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
  булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
  (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.

Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).

`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
2026-07-27 01:31:42 +03:00

211 lines
11 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 { Pin, PinOff, 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'
import { stageTrackKey } from '@/components/room/stageFocus'
/** Метаданные участника из 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, pinnedKey, onTogglePin }: TileControlsProps) {
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,
)
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
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} />
{onTogglePin && (
<button
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'}
aria-label={
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».
e.stopPropagation()
onTogglePin(tileKey)
}}
>
{isPinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
</button>
)}
{showSharingChip && (
<div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" />
<span>Вы демонстрируете экран</span>
<button type="button" onClick={onStopSharing}>
Остановить
</button>
</div>
)}
</>
)
}
/** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
interface TileControlsProps {
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
* Передавать только для фокус-плитки — в карусели демонстрация
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
}
interface RoomParticipantTileProps extends TileControlsProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
}
/**
* Обёртка над штатным `ParticipantTile` (@livekit/components-react 2.9.23,
* версия запинена в package.json — при апгрейде библиотеки сверить разметку
* заново): та же структура (видео/аудио-трек, блок метаданных с именем и индикатором мьюта,
* FocusToggle), но вместо стандартной SVG-пиктограммы при выключенной камере —
* аватар участника. Работает и для локального участника (свои метаданные из
* собственного токена), и для удалённых.
*
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
* в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
* той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
* `pickStageFocus`.
*/
export function RoomParticipantTile({
trackRef,
disableSpeakingIndicator,
onParticipantClick,
onStopSharing,
pinnedKey,
onTogglePin,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
trackRef={trackRef}
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
</ParticipantTile>
)
}