Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания демонстрации экрана (holdScreenShare), без приоритета говорящего с включённой камерой, без антидребезга говорящего и с собственным чистым useState для закрепления. На практике это читалось как поломка — закрепление, сделанное в основном окне, в мини-окне не действовало, а демонстрация экрана пропадала, стоило кому-то сказать слово. Теперь pickStageFocus получает одинаковые правила в обоих вариантах сцены. Единственное сознательное отличие — localKey («показать себя» последним фолбэком), он остаётся только у мини-плеера: это защита от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе. Закрепление переезжает между окнами тем же мостиком через RoomPage, что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий источник правды не нужен: экземпляр сцены в каждый момент ровно один — пока открыт Document PiP, основное окно показывает заглушку. Заодно в снятии закрепления «участник вышел из комнаты» добавлена охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее через initialPinnedKey закрепление обнулялось прямо при монтировании, то есть мини-плеер терял его каждый раз. Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»: закрепление больше не ограничено основным окном.
319 lines
16 KiB
TypeScript
319 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|