From f89bf1ad64760cdc1fa2fb9af36b17bba61298e6 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Tue, 4 Aug 2026 21:13:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(room):=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B5=D0=BC=D0=BE=D0=BD=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=BE=D0=BA=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В Document PiP (Chrome/Edge) своего тулбара нет, и начать демонстрацию, не свернув мини-окно, было нельзя. Кнопка сделана по образцу кнопки микрофона, добавленной в 0.0.15: тот же `useTrackToggle` через `RoomContext`, поэтому она и кнопка основного тулбара — два вида одного состояния и рассинхрону взяться неоткуда. Главный вопрос задачи — пустит ли платформа `getDisplayMedia()`, вызванный из кода основного окна по клику в ДРУГОМ окне. Замер в Chrome 150: после клика в PiP `navigator.userActivation.isActive === true` в обоих окнах, активация доезжает до опенера, вызов проходит, системный пикер выбора экрана открывается отдельным окном поверх всего, а не прячется за заглушкой основного окна. Опции захвата (`SCREEN_SHARE_CAPTURE_OPTIONS`) вынесены из `RoomToolbar` в `lib/screenShareOptions.ts`: кнопок демонстрации теперь две, и разойдись они хотя бы в `audio`, демонстрация получалась бы разной в зависимости от того, откуда её запустили. Фокус в мини-окне не менялся: своя демонстрация показывается по тем же правилам `pickStageFocus`, что и в основном окне. В Safari мини-окно — нативный video-PiP без собственного DOM, кнопке там негде жить; в Firefox мини-окна нет вовсе. Это ограничение платформы. --- frontend/src/components/room/RoomStage.tsx | 77 +++++++++++++++++++- frontend/src/components/room/RoomToolbar.tsx | 23 +----- frontend/src/lib/screenShareOptions.ts | 29 ++++++++ frontend/src/styles/room.css | 44 ++++++++--- 4 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/screenShareOptions.ts diff --git a/frontend/src/components/room/RoomStage.tsx b/frontend/src/components/room/RoomStage.tsx index ff9e703..49c91d5 100644 --- a/frontend/src/components/room/RoomStage.tsx +++ b/frontend/src/components/room/RoomStage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react' -import { EyeOff, Mic, MicOff, Users } from 'lucide-react' +import { EyeOff, Mic, MicOff, ScreenShare, ScreenShareOff, Users } from 'lucide-react' import { Track, type Participant } from 'livekit-client' import { CarouselLayout, @@ -13,8 +13,10 @@ import { } from '@livekit/components-react' import { RoomParticipantTile } from '@/components/room/RoomParticipantTile' import { StageGrid } from '@/components/room/StageGrid' +import { useToast } from '@/components/ui/ToastProvider' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus' +import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions' import type { StageLayoutMode } from '@/lib/stageLayoutMode' /** @@ -115,7 +117,7 @@ function PipMicToggle() { + ) +} + /** * Основная сцена конференции: превью остальных участников + крупная плитка * активного спикера (FocusLayoutContainer + CarouselLayout при нескольких @@ -441,12 +496,26 @@ export function RoomStage({ // демонстрации, антидребезг говорящего) — переиспользуем как есть. // Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет, // закрепление делается в основном окне и приезжает сюда через - // `initialPinnedKey`. + // `initialPinnedKey`. А вот микрофон и демонстрация экрана в мини-окне есть: + // это не «вид», а действия, которые нужны прямо посреди разговора, и ради них + // разворачивать основное окно (то есть закрывать мини-окно) бессмысленно. + // + // СОБСТВЕННАЯ ДЕМОНСТРАЦИЯ в мини-окне ПОКАЗЫВАЕТСЯ — по тем же правилам + // `pickStageFocus`, что и в основном окне (last-wins на старте, возврат на + // говорящего/закреплённого после остановки). Решение оператора при приёмке + // 0.0.29: демонстратор должен видеть, что именно он демонстрирует, ровно то + // же, что видят остальные. Пробная версия прятала свой шэр из PiP (чтобы + // вместо него была видна аудитория и чтобы при выборе «весь экран» не + // получался зеркальный туннель «мини-окно внутри мини-окна») — отклонена. + // Туннель при выборе «весь экран» остаётся известным и принятым поведением. if (variant === 'pip') { return (
{focusTrack && } - +
+ + +
) } diff --git a/frontend/src/components/room/RoomToolbar.tsx b/frontend/src/components/room/RoomToolbar.tsx index b385a04..c463f41 100644 --- a/frontend/src/components/room/RoomToolbar.tsx +++ b/frontend/src/components/room/RoomToolbar.tsx @@ -13,33 +13,14 @@ import { Video, VideoOff, } from 'lucide-react' -import { Track, type ScreenShareCaptureOptions } from 'livekit-client' +import { Track } from 'livekit-client' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import type { HandQueueEntry } from '@/hooks/useChat' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { HandQueueMenu } from '@/components/room/HandQueueMenu' - -/** - * Опции захвата демонстрации экрана: `audio: true` — звук - * вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти - * всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'` - * — не предлагать в списке - * источников собственную вкладку (зеркальный туннель самой конференции); - * `surfaceSwitching: 'include'` — разрешить переключать источник прямо во - * время демонстрации, не останавливая её; `systemAudio: 'include'` — не - * запрещать захват системного звука при выборе «весь экран». Вынесено в - * модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)` - * в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден, - * но константа явнее фиксирует неизменность опций. - */ -const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = { - audio: true, - selfBrowserSurface: 'exclude', - surfaceSwitching: 'include', - systemAudio: 'include', -} +import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions' interface RoomToolbarProps extends StageViewProps { /** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */ diff --git a/frontend/src/lib/screenShareOptions.ts b/frontend/src/lib/screenShareOptions.ts new file mode 100644 index 0000000..eb6fa45 --- /dev/null +++ b/frontend/src/lib/screenShareOptions.ts @@ -0,0 +1,29 @@ +import type { ScreenShareCaptureOptions } from 'livekit-client' + +/** + * Опции захвата демонстрации экрана: `audio: true` — звук + * вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти + * всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'` + * — не предлагать в списке + * источников собственную вкладку (зеркальный туннель самой конференции); + * `surfaceSwitching: 'include'` — разрешить переключать источник прямо во + * время демонстрации, не останавливая её; `systemAudio: 'include'` — не + * запрещать захват системного звука при выборе «весь экран». + * + * Вынесено в модульную константу — `useTrackToggle` держит + * `JSON.stringify(captureOptions)` в зависимостях внутреннего `useMemo`, + * инлайновый литерал был бы безвреден, но константа явнее фиксирует + * неизменность опций. + * + * Живёт в `lib/`, а не рядом с тулбаром, потому что кнопок демонстрации теперь + * ДВЕ: в основном тулбаре (`RoomToolbar`) и в мини-окне (`RoomStage`, + * `PipScreenShareToggle`). Опции у них обязаны совпадать: обе кнопки управляют + * одной и той же публикацией, и разойдись они хотя бы в `audio`, демонстрация + * получалась бы разной в зависимости от того, откуда её запустили. + */ +export const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = { + audio: true, + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + systemAudio: 'include', +} diff --git a/frontend/src/styles/room.css b/frontend/src/styles/room.css index e68cca1..6448073 100644 --- a/frontend/src/styles/room.css +++ b/frontend/src/styles/room.css @@ -133,18 +133,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } * `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы * плитка не схлопывалась, если видео ещё не выдало реальные размеры. */ -.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; } +.room-single-tile { + position: relative; + flex: 1; + min-height: 0; + display: flex; + /* Ширина ряда кнопок мини-плеера (`.room-pip-controls`): две круглые кнопки + по 36px плюс зазор. Живёт на общем предке, а не на самом ряду, потому что + от неё считается и отступ плашки с именем участника — иначе подпись + («Второй участник — демонстрация экрана») уезжает ПОД кнопки. Появится + третья кнопка — правится одно число, а не два места. */ + --room-pip-controls-width: calc(2 * 36px + 0.375rem); +} .room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; } +/* Библиотечная плашка с именем прижата к обоим нижним углам (`left: 4px; + right: 4px`) и потому начинается ровно там, где лежат наши кнопки. Сдвигаем + её содержимое вправо на ширину ряда — сама плашка прозрачная, подпись внутри + со своим фоном, так что двигать надо именно отступ, а не `left`. */ +.room-single-tile .lk-participant-metadata { padding-left: var(--room-pip-controls-width); } -/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет, - кнопка лежит поверх единственной плитки левым нижним углом (правый занят - штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет, - в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */ -.room-pip-mic-toggle { +/* Кнопки мини-плеера (микрофон — задача A5, демонстрация экрана — 0.0.29): + своего тулбара в PiP нет, кнопки лежат поверх единственной плитки левым + нижним углом (правый занят штатным `.lk-focus-toggle-button`/нашей булавкой + закрепления — тут её нет, в PiP закреплять нечего, но геометрия зеркалит + `.room-pin-toggle`). Ряд, а не две абсолютно спозиционированные кнопки: + так добавление третьей не потребует пересчёта координат. */ +.room-pip-controls { position: absolute; left: 0.5rem; bottom: 0.5rem; z-index: 5; + display: flex; + gap: 0.375rem; +} +.room-pip-btn { width: 36px; height: 36px; border-radius: 50%; @@ -152,12 +174,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } display: flex; align-items: center; justify-content: center; - background: var(--color-room-mic-on); - color: #10331f; cursor: pointer; } -.room-pip-mic-toggle svg { width: 18px; height: 18px; } +.room-pip-btn svg { width: 18px; height: 18px; } +.room-pip-mic-toggle { background: var(--color-room-mic-on); color: #10331f; } .room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; } +/* Демонстрация — «выключено» это нейтральное состояние (не тревога, в отличие + от снятого микрофона), поэтому неактивная кнопка тёмная под цвет плитки, а + активная подсвечена тем же зелёным, что включённый микрофон. */ +.room-pip-share-toggle { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); } +.room-pip-share-toggle.is-on { background: var(--color-room-mic-on); color: #10331f; } /* * ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------