Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами: - `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус вопреки говорящим, но уступает любой активной демонстрации экрана; как только демонстрация закончилась, фокус возвращается именно на закреплённого (правило стоит выше удержания предыдущего фокуса). Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной булавкой; на тач-устройствах булавка видна без наведения. - `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий. Основное окно теперь следует за говорящим (`followSpeaker`) поверх `useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не уводят. Среди одновременно говорящих предпочитается тот, у кого включена камера (`cameraKeysWithVideo`). `stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
211 lines
11 KiB
TypeScript
211 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|