Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
)
}