Files
vidconf/frontend/src/components/room/RoomParticipantTile.tsx
Max Ronzhin fa8270c156
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00

319 lines
16 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 { Hand, Mic, Pin, PinOff, ScreenShare, Video } 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'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import { useToast } from '@/components/ui/ToastProvider'
import { muteParticipant } from '@/api/conferences'
/** Достаёт URL аватара из метаданных участника — `null`, если поля нет (гость/без аватара). */
function parseAvatarUrl(metadata: string | undefined): string | null {
const parsed = parseParticipantMetadata(metadata)
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
}
/**
* Кнопки принудительного мьюта организатором (задача B2) — микрофон/камера
* ЧУЖОГО участника. Видны только организатору (`useIsOrganizer`, подсказка
* UI — сервер перепроверяет права по владельцу конференции в БД) и только на
* чужой плитке камеры (на своей — обычный тулбарный toggle, мьютить себя
* через «принудительное» действие не нужно).
*
* Не проверяют текущее состояние мьюта заранее (усложнило бы плитку ради
* малополезной оптимизации): клик по уже выключенному треку — не ошибка, а
* no-op на backend (`muted: false` в ответе, см. `services/room_control.py`).
*/
function OrganizerMuteControls({
conferenceId,
identity,
displayName,
}: {
conferenceId: string
identity: string
displayName: string
}) {
const toast = useToast()
async function handleMute(source: 'microphone' | 'camera') {
try {
const result = await muteParticipant(conferenceId, identity, source)
if (!result.muted) {
toast.show(
source === 'microphone' ? 'Микрофон и так выключен' : 'Камера и так выключена',
'info',
)
}
} catch {
toast.show('Не удалось выключить трек участника', 'error')
}
}
return (
<div className="room-organizer-controls">
<button
type="button"
title={`Выключить микрофон: ${displayName}`}
aria-label={`Выключить микрофон: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('microphone')
}}
>
<Mic aria-hidden="true" />
</button>
<button
type="button"
title={`Выключить камеру: ${displayName}`}
aria-label={`Выключить камеру: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('camera')
}}
>
<Video aria-hidden="true" />
</button>
</div>
)
}
/**
* Содержимое плитки — рендерится ВНУТРИ `ParticipantTile` (значит, внутри её
* `TrackRefContext`/`ParticipantContext`), поэтому берёт трек и участника из
* контекста теми же хуками, что использует сама библиотека в оригинальной
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: TileControlsProps) {
const trackReference = useEnsureTrackRef()
const isOrganizer = useIsOrganizer()
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`; в мини-плеере своего тулбара нет и плитка одна, поэтому
// булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25
// действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
// Бейдж поднятой руки (задача B1) — только на плитке КАМЕРЫ участника, не
// на плитке его демонстрации экрана (рука — про человека, не про экран).
const isHandRaised =
trackReference.source === Track.Source.Camera &&
Boolean(raisedHandIdentities?.has(trackReference.participant.identity))
// Кнопки принудительного мьюта (задача B2) — организатору, только на
// чужой плитке камеры (см. докстринг `OrganizerMuteControls`).
const showOrganizerMuteControls =
isOrganizer &&
Boolean(conferenceId) &&
trackReference.source === Track.Source.Camera &&
!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>
{isHandRaised && (
<div className="room-hand-badge" title={`${displayName}: поднята рука`}>
<Hand className="lucide" aria-hidden="true" />
</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>
)}
{showOrganizerMuteControls && conferenceId && (
<OrganizerMuteControls
conferenceId={conferenceId}
identity={trackReference.participant.identity}
displayName={displayName}
/>
)}
</>
)
}
/** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
interface TileControlsProps {
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
* Передавать только для фокус-плитки — в карусели демонстрация
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
/**
* Identity участников с поднятой рукой прямо сейчас (задача B1, из
* `useChat().handQueue`) — плитка сама решает, её ли это identity. Не
* передан — бейдж нигде не рендерится (мини-плеер).
*/
raisedHandIdentities?: Set<string>
/**
* Id конференции (не slug/номер) — нужен для вызова эндпоинта мьюта
* (задача B2). Не передан — кнопок мьюта на плитке нет (мини-плеер).
*/
conferenceId?: string
}
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,
raisedHandIdentities,
conferenceId,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
trackRef={trackRef}
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody
onStopSharing={onStopSharing}
pinnedKey={pinnedKey}
onTogglePin={onTogglePin}
raisedHandIdentities={raisedHandIdentities}
conferenceId={conferenceId}
/>
</ParticipantTile>
)
}