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 (
) } /** * Содержимое плитки — рендерится ВНУТРИ `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`; в мини-плеере плитка одна, закреплять нечего). // Ключ плитки берём из её собственного трека: в карусели/гриде плитки // рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать. 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) ? ( ) : ( isTrackReference(trackReference) && )} {/* Вместо штатной иконки-заглушки (ParticipantPlaceholder) — аватар участника: фото по avatar_url из метаданных, фолбэк — инициалы имени (см. `ui/Avatar`). Видимость (opacity) этого блока при выключенной камере управляется тем же CSS-правилом библиотеки (`[data-lk-video-muted='true'][data-lk-source='camera'] .lk-participant-placeholder`) — data-атрибуты на корневой div выставляет сам `ParticipantTile` независимо от children. */}
{isHandRaised && (
)}
{trackReference.source === Track.Source.Camera ? ( <> {isEncrypted && } ) : ( <> {/* `ParticipantName` рендерит `participant.name` (или identity), а `children` — необязательный суффикс ПОСЛЕ имени; оригинал дописывал сюда английское `'s screen` («Имя's screen»), здесь — русский суффикс («Имя — демонстрация экрана»). */} {' — демонстрация экрана'} )}
{onTogglePin && ( )} {showSharingChip && (
)} {showOrganizerMuteControls && conferenceId && ( )} ) } /** Управляющие элементы поверх плитки — общие для обёртки и её `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 /** * 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 ( ) }