From 5daff3b7af768bbcc05c8ee3e0c91c2a5271982c Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Mon, 27 Jul 2026 01:31:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(room):=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA=D0=BD=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D1=8F=D1=89=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами: - `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус вопреки говорящим, но уступает любой активной демонстрации экрана; как только демонстрация закончилась, фокус возвращается именно на закреплённого (правило стоит выше удержания предыдущего фокуса). Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной булавкой; на тач-устройствах булавка видна без наведения. - `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий. Основное окно теперь следует за говорящим (`followSpeaker`) поверх `useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не уводят. Среди одновременно говорящих предпочитается тот, у кого включена камера (`cameraKeysWithVideo`). `stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов). --- .../components/room/RoomParticipantTile.tsx | 66 ++++++- frontend/src/components/room/RoomStage.tsx | 171 +++++++++++++----- frontend/src/components/room/stageFocus.ts | 131 ++++++++++++-- frontend/src/styles/room.css | 46 +++++ 4 files changed, 336 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/room/RoomParticipantTile.tsx b/frontend/src/components/room/RoomParticipantTile.tsx index e9937b4..d08e07d 100644 --- a/frontend/src/components/room/RoomParticipantTile.tsx +++ b/frontend/src/components/room/RoomParticipantTile.tsx @@ -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 }) { + {onTogglePin && ( + + )} {showSharingChip && (