diff --git a/frontend/src/components/room/RoomParticipantTile.tsx b/frontend/src/components/room/RoomParticipantTile.tsx index 80bcb52..61c2c17 100644 --- a/frontend/src/components/room/RoomParticipantTile.tsx +++ b/frontend/src/components/room/RoomParticipantTile.tsx @@ -126,8 +126,10 @@ function TileBody({ const showSharingChip = Boolean( onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal, ) - // Кнопка закрепления — только там, где сцена умеет закрепление (основное - // окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего). + // Кнопка закрепления — только там, где сцена её даёт (основное окно передаёт + // `onTogglePin`; в мини-плеере своего тулбара нет и плитка одна, поэтому + // булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25 + // действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`). // Ключ плитки берём из её собственного трека: в карусели/гриде плитки // рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать. const tileKey = stageTrackKey(trackReference) @@ -200,10 +202,8 @@ function TileBody({ type="button" className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`} aria-pressed={isPinned} - title={isPinned ? 'Открепить' : 'Закрепить в основном окне'} - aria-label={ - isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}` - } + title={isPinned ? 'Открепить' : 'Закрепить'} + aria-label={isPinned ? `Открепить: ${displayName}` : `Закрепить: ${displayName}`} onClick={(e) => { // Иначе клик долетит до самой плитки (`onParticipantClick` // у `ParticipantTile`) — булавка не должна означать «клик по плитке». diff --git a/frontend/src/components/room/RoomStage.tsx b/frontend/src/components/room/RoomStage.tsx index 61a1e63..ff9e703 100644 --- a/frontend/src/components/room/RoomStage.tsx +++ b/frontend/src/components/room/RoomStage.tsx @@ -43,9 +43,11 @@ const STAGE_TRACK_SOURCES = [ ] /** - * Удержание фокуса основного окна при смене говорящего, мс. + * Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах + * сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не + * было вовсе, фокус там переключался мгновенно). * - * Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без + * Сцена следует за спикером (`followSpeaker`, задача 3.2), и без * удержания короткие реплики («ага», «угу») уводили бы большую плитку на * секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants` * поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но @@ -58,6 +60,10 @@ const STAGE_TRACK_SOURCES = [ * фокус с задержкой, которая на глаз читается как плавность, а не как тормоз. * Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно * запаздывает переход на нового докладчика. + * + * В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное + * переключение читается не как «камера следует за разговором», а как мигание + * всего окна целиком. */ const SPEAKER_HOLD_MS = 1200 @@ -68,8 +74,9 @@ const SPEAKER_HOLD_MS = 1200 * применять уже нечего (cleanup эффекта гасит таймер, а новое значение * сравнивается по ссылке с текущим). * - * `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там - * фокус обязан следовать за говорящим мгновенно, поведение не менялось). + * `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим + * режимом никто не пользуется (обе сцены удерживают состав), но параметр + * оставлен: он и делает функцию пригодной для повторного использования. */ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] { const [steady, setSteady] = useState(speakers) @@ -147,9 +154,9 @@ function PipMicToggle() { * `tiles` скрывать нечего (карусели нет), переключатель там заблокирован — * см. `StageViewOptions`. * - * ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ - * экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он - * начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и + * ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — + * ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние + * фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и * `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно * открывалось на самом пользователе вместо того, что он видел крупно. В Safari * бага не было видно: там Document PiP не используется, а video-фолбэк @@ -158,6 +165,13 @@ function PipMicToggle() { * `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны * — возврат из мини-плеера тоже не сбрасывает фокус. * + * Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` / + * `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и + * закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе. + * Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый + * момент ровно один (пока открыт Document PiP, основное окно показывает + * заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете. + * * Раскладка — вертикальная колонка миниатюр слева от основной сцены (не * горизонтальная лента, см. design/mockups/room.html после правки: узкая * колонка сбоку, скролл по вертикали). Это штатное поведение самого @@ -187,12 +201,21 @@ function PipMicToggle() { * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида; * режимы показа и скрытие остальных на мини-плеер не влияют вовсе. * - * Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у - * `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному: - * PiP переключается мгновенно и всегда показывает говорящего, а основное окно - * ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из - * фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление - * участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке. + * ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был + * намеренно «упрощён»: без удержания говорящего, без удержания демонстрации + * экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и + * без закрепления. На практике это читалось как поломка: в мини-окне + * демонстрация экрана слетала от любой чужой реплики, а закрепление, + * сделанное в основном окне, не действовало. Теперь `pickStageFocus` + * получает одни и те же правила независимо от варианта — разным остаётся + * ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо + * раскладки. + * + * Единственное сознательное отличие — `localKey`: у мини-плеера есть + * последний фолбэк «показать себя», у основного окна его нет (там фолбэк — + * первый трек по порядку, поведение не менялось). Строка из того же сюжета, + * что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате + * выбирало произвольного участника. */ export function RoomStage({ variant = 'full', @@ -202,6 +225,8 @@ export function RoomStage({ onHideOthers, initialFocusKey = null, onFocusKeyChange, + initialPinnedKey = null, + onPinnedKeyChange, onPinFocus, raisedHandIdentities, conferenceId, @@ -220,6 +245,10 @@ export function RoomStage({ initialFocusKey?: string | null /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */ onFocusKeyChange?: (key: string | null) => void + /** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */ + initialPinnedKey?: string | null + /** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */ + onPinnedKeyChange?: (key: string | null) => void /** * Участника только что закрепили (не открепили) в режиме без крупной * плитки — сцена сама переключиться не может (режим живёт в `RoomPage`), @@ -244,9 +273,9 @@ export function RoomStage({ // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`, // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`). - // Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers` - // и `SPEAKER_HOLD_MS`), PiP берёт значение как есть. - const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS) + // Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и + // `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково. + const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS) const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera) const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare) @@ -288,8 +317,10 @@ export function RoomStage({ const [focusKey, setFocusKey] = useState(initialFocusKey) // Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source` // плитки, которую пользователь закрепил булавкой; `null` — закрепления нет. - // Только для основного окна — в PiP плитка одна и закреплять нечего. - const [pinnedKey, setPinnedKey] = useState(null) + // Стартовое значение приходит от предыдущего экземпляра сцены (тот же + // мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное + // в основном окне, действует и в мини-плеере. + const [pinnedKey, setPinnedKey] = useState(initialPinnedKey) const [prevPinnedKey, setPrevPinnedKey] = useState(null) const cameraKeys = cameraTracks.map(stageTrackKey) @@ -298,13 +329,23 @@ export function RoomStage({ // камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди // демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном // состоянии и булавка не «висела» на исчезнувшем ключе. + // + // `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере + // нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав + // приезжает следующим рендером, из подписки на события комнаты). Без этой + // проверки пустой набор читается как «все вышли», и закрепление, приехавшее + // через `initialPinnedKey`, обнулялось сразу при монтировании — то есть + // мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25). + // Пустых наборов при живой комнате не бывает: камера есть у каждого + // участника хотя бы плейсхолдером. + const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0 const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey)) const tracksChanged = tracks !== prevTracks const speakingChanged = speakingParticipants !== prevSpeakingParticipants const pinnedChanged = pinnedKey !== prevPinnedKey - if (pinnedKey !== null && !pinnedAlive) { + if (pinnedKey !== null && tracksKnown && !pinnedAlive) { setPinnedKey(null) } @@ -327,14 +368,12 @@ export function RoomStage({ cameraKeys, screenShareKeys, speakingCameraKeys, - // Приоритет «говорящий с камерой выше говорящего без камеры» — только - // основному окну: PiP по договорённости ведёт себя ровно как раньше. - cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey), + cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey), prevKeys, prevFocusKey: focusKey, pinnedKey: pinnedAlive ? pinnedKey : null, followSpeaker: true, - holdScreenShare: variant !== 'pip', + holdScreenShare: true, // Только для PiP — в основном окне фолбэк на «первый трек» не менялся. localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null, }) @@ -351,6 +390,11 @@ export function RoomStage({ onFocusKeyChange?.(focusKey) }, [focusKey, onFocusKeyChange]) + // То же самое для закрепления — см. `initialPinnedKey`. + useEffect(() => { + onPinnedKeyChange?.(pinnedKey) + }, [pinnedKey, onPinnedKeyChange]) + const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0] const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null // При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И @@ -393,7 +437,11 @@ export function RoomStage({ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // грида, одна плитка на весь контейнер (см. `.room-single-tile`, // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus` - // (с `followSpeaker: true` для этого варианта) — переиспользуем как есть. + // и по тем же правилам, что и в основном окне (закрепление, удержание + // демонстрации, антидребезг говорящего) — переиспользуем как есть. + // Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет, + // закрепление делается в основном окне и приезжает сюда через + // `initialPinnedKey`. if (variant === 'pip') { return (
diff --git a/frontend/src/components/room/stageFocus.ts b/frontend/src/components/room/stageFocus.ts index 3149a8a..b375981 100644 --- a/frontend/src/components/room/stageFocus.ts +++ b/frontend/src/components/room/stageFocus.ts @@ -62,8 +62,10 @@ export interface PickStageFocusInput { /** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */ prevFocusKey: string | null /** - * Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на - * плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет. + * Ключ трека, ЗАКРЕПЛЁННОГО пользователем (кнопка-булавка на плитке + * основного окна; состояние живёт в `RoomStage.tsx` и переезжает в + * мини-плеер через `RoomPage`, см. там `initialPinnedKey`); `null` — + * закрепления нет. * Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной * демонстрации экрана (формулировка оператора: «перебивается только чьей-либо * демонстрацией экрана») — а когда демонстрация закончилась, фокус @@ -79,19 +81,22 @@ export interface PickStageFocusInput { * не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для * основного окна — решение оператора (этап 3, задача 3.2). Защита от * дребезга — на стороне вызывающего: источник «говорящих» — throttled - * `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в - * основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в - * `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`. + * `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged` плюс + * удержание в ~1.2 с (см. `useSteadySpeakers` в `RoomStage.tsx`, с 0.0.25 — + * в обеих сценах), не сырой дребезжащий `participant.isSpeaking`. * По умолчанию `false` — фокус удерживается (см. правило 5). */ followSpeaker?: boolean /** * Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику - * (правило 3). Нужно основному окну: там демонстрация — это содержательный - * центр разговора, и уводить её из большого окна на каждую реплику нельзя. - * Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе - * — всегда показывает того, кто говорит, поэтому там `false` (поведение - * PiP не менялось с 0.0.4). + * (правило 3). Демонстрация — это содержательный центр разговора, и уводить + * её из фокуса на каждую реплику нельзя. + * + * С 0.0.25 включено в ОБЕИХ сценах. До этого мини-плеер (PiP) намеренно + * ходил с `false` — «одна плитка, всегда показываем говорящего»; на практике + * это выглядело как поломка: демонстрация в мини-окне пропадала, стоило + * кому-то сказать слово. По умолчанию всё ещё `false` — это поведение + * функции без явного запроса удержания. */ holdScreenShare?: boolean /** @@ -132,16 +137,14 @@ function pickSpeakerKey( * фокус безусловно переходит на него (последний из новых, если появилось * сразу несколько), даже если до этого в фокусе была камера или другая * демонстрация. Так же ведут себя типовые UI конференций (Google Meet). - * 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник + * 2. Закрепление (`pinnedKey`): закреплённый участник * забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает * ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше * удержания (правило 5): как только демонстрация закончилась и * `screenShareKeys` опустел, фокус возвращается на закреплённого, а не * остаётся на том, кто был в фокусе до демонстрации. - * 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в - * фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на - * каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда - * показывает говорящего). + * 3. `holdScreenShare`: демонстрация, уже стоящая в фокусе, не уступает + * заговорившему — иначе окно уводило бы шэр на каждую реплику. * 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на * него, даже если текущий фокус ещё жив; среди одновременно говорящих * предпочитаем того, у кого включена камера (`cameraKeysWithVideo`). diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index f3e9269..6d77ae9 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -208,6 +208,12 @@ export function RoomPage() { // открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`. const [stageFocusKey, setStageFocusKey] = useState(null) + // Закрепление участника (булавка на плитке) — по той же причине и тем же + // мостиком, что и `stageFocusKey`: экземпляр `RoomStage` при открытии + // мини-плеера пересоздаётся, и до 0.0.25 закрепление, сделанное в основном + // окне, в мини-окно не попадало вовсе (там был свой чистый `useState`). + const [stagePinnedKey, setStagePinnedKey] = useState(null) + // Сохранённый выбор устройств — читаем через собственный вызов // usePersistentUserChoices (независимый от того, что использует // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: @@ -321,6 +327,8 @@ export function RoomPage() { onHideOthers={() => setHideOthers(true)} initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} + initialPinnedKey={stagePinnedKey} + onPinnedKeyChange={setStagePinnedKey} onPinFocus={handlePinFocus} raisedHandIdentities={raisedHandIdentities} conferenceId={joinState.conferenceId} @@ -373,10 +381,17 @@ export function RoomPage() { `variant="pip"` — мини-плеер показывает только активное окно (одну плитку), без карусели/грида основного окна. `initialFocusKey` — то, что было крупно в основном - окне: без него мини-окно открывалось на самом пользователе. */} + окне: без него мини-окно открывалось на самом пользователе; + `initialPinnedKey` — закрепление оттуда же. */} {pip.pipWindow && createPortal( - , + , pip.pipWindow.document.body, )}