Первоначальная версия VidConf
This commit is contained in:
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { 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'
|
||||
|
||||
/** Метаданные участника из 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 }: { onStopSharing?: () => void }) {
|
||||
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,
|
||||
)
|
||||
|
||||
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} />
|
||||
{showSharingChip && (
|
||||
<div className="stage-sharing-chip">
|
||||
<ScreenShare className="lucide" aria-hidden="true" />
|
||||
<span>Вы демонстрируете экран</span>
|
||||
<button type="button" onClick={onStopSharing}>
|
||||
Остановить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RoomParticipantTileProps {
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
disableSpeakingIndicator?: boolean
|
||||
onParticipantClick?: (event: ParticipantClickEvent) => void
|
||||
/**
|
||||
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
|
||||
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
|
||||
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
|
||||
* Передавать только для фокус-плитки — в карусели демонстрация
|
||||
* в этом приложении не появляется (см. `RoomStage.tsx`).
|
||||
*/
|
||||
onStopSharing?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над штатным `ParticipantTile` (@livekit/components-react 2.9.23,
|
||||
* версия запинена в package.json — при апгрейде библиотеки сверить разметку
|
||||
* заново): та же структура (видео/аудио-трек, блок метаданных с именем и индикатором мьюта,
|
||||
* FocusToggle), но вместо стандартной SVG-пиктограммы при выключенной камере —
|
||||
* аватар участника. Работает и для локального участника (свои метаданные из
|
||||
* собственного токена), и для удалённых.
|
||||
*
|
||||
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
|
||||
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
|
||||
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
|
||||
* мёртвым кодом без провайдера контекста.
|
||||
*/
|
||||
export function RoomParticipantTile({
|
||||
trackRef,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
onStopSharing,
|
||||
}: RoomParticipantTileProps) {
|
||||
return (
|
||||
<ParticipantTile
|
||||
trackRef={trackRef}
|
||||
disableSpeakingIndicator={disableSpeakingIndicator}
|
||||
onParticipantClick={onParticipantClick}
|
||||
>
|
||||
<TileBody onStopSharing={onStopSharing} />
|
||||
</ParticipantTile>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user