feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами: - `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус вопреки говорящим, но уступает любой активной демонстрации экрана; как только демонстрация закончилась, фокус возвращается именно на закреплённого (правило стоит выше удержания предыдущего фокуса). Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной булавкой; на тач-устройствах булавка видна без наведения. - `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий. Основное окно теперь следует за говорящим (`followSpeaker`) поверх `useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не уводят. Среди одновременно говорящих предпочитается тот, у кого включена камера (`cameraKeysWithVideo`). `stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ScreenShare } from 'lucide-react'
|
||||
import { Pin, PinOff, ScreenShare } from 'lucide-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import {
|
||||
AudioTrack,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { stageTrackKey } from '@/components/room/stageFocus'
|
||||
|
||||
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
|
||||
interface ParticipantMetadata {
|
||||
@@ -43,7 +44,7 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
|
||||
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
|
||||
* версия 2.9.23 — источник этой копии).
|
||||
*/
|
||||
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
|
||||
function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
|
||||
const trackReference = useEnsureTrackRef()
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
@@ -60,6 +61,12 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
|
||||
const showSharingChip = Boolean(
|
||||
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
|
||||
)
|
||||
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
|
||||
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
|
||||
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
|
||||
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
|
||||
const tileKey = stageTrackKey(trackReference)
|
||||
const isPinned = pinnedKey === tileKey
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,6 +113,25 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
|
||||
<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" />
|
||||
@@ -119,10 +145,8 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface RoomParticipantTileProps {
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
disableSpeakingIndicator?: boolean
|
||||
onParticipantClick?: (event: ParticipantClickEvent) => void
|
||||
/** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
|
||||
interface TileControlsProps {
|
||||
/**
|
||||
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
|
||||
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
|
||||
@@ -131,6 +155,23 @@ interface RoomParticipantTileProps {
|
||||
* в этом приложении не появляется (см. `RoomStage.tsx`).
|
||||
*/
|
||||
onStopSharing?: () => void
|
||||
/**
|
||||
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
|
||||
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
|
||||
*/
|
||||
pinnedKey?: string | null
|
||||
/**
|
||||
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
|
||||
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
|
||||
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
|
||||
*/
|
||||
onTogglePin?: (key: string) => void
|
||||
}
|
||||
|
||||
interface RoomParticipantTileProps extends TileControlsProps {
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
disableSpeakingIndicator?: boolean
|
||||
onParticipantClick?: (event: ParticipantClickEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,15 +183,20 @@ interface RoomParticipantTileProps {
|
||||
* собственного токена), и для удалённых.
|
||||
*
|
||||
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
|
||||
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
|
||||
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
|
||||
* мёртвым кодом без провайдера контекста.
|
||||
* сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
|
||||
* в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
|
||||
* той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
|
||||
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
|
||||
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
|
||||
* `pickStageFocus`.
|
||||
*/
|
||||
export function RoomParticipantTile({
|
||||
trackRef,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
onStopSharing,
|
||||
pinnedKey,
|
||||
onTogglePin,
|
||||
}: RoomParticipantTileProps) {
|
||||
return (
|
||||
<ParticipantTile
|
||||
@@ -158,7 +204,7 @@ export function RoomParticipantTile({
|
||||
disableSpeakingIndicator={disableSpeakingIndicator}
|
||||
onParticipantClick={onParticipantClick}
|
||||
>
|
||||
<TileBody onStopSharing={onStopSharing} />
|
||||
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
|
||||
</ParticipantTile>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user