Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8220f682d | |||
| 29a3e78836 | |||
| 3fb3a5d42c | |||
| 975763a3a6 |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.10
|
VIDCONF_VERSION=0.0.11
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -3,6 +3,49 @@
|
|||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [0.0.11] — 2026-07-28
|
||||||
|
|
||||||
|
Выбор режима показа участников в конференции, скрытие остальных и круглая
|
||||||
|
иконка участника при выключенной камере.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Выбор режима показа участников на сцене вместо автоматической раскладки:
|
||||||
|
«Стандарт» (крупная плитка плюс карусель остальных сбоку, как было),
|
||||||
|
«Плитки» (все участники равными плитками) и «Живые плитки» (плитками —
|
||||||
|
только те, у кого включена камера, остальные в карусели). Кто именно
|
||||||
|
показан крупно в «Стандарте», по-прежнему решает прежняя логика фокуса
|
||||||
|
(новая демонстрация → закреплённый → говорящий с видео → говорящий).
|
||||||
|
Выбор сохраняется между заходами в комнату.
|
||||||
|
- Скрытие остальных участников — на сцене остаётся только основная плитка.
|
||||||
|
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой над
|
||||||
|
колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)», которая
|
||||||
|
видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать нечего,
|
||||||
|
переключатель там заблокирован.
|
||||||
|
- Переключатель вида: кнопка «Вид» с поповером в тулбаре на широком экране;
|
||||||
|
на мобильном — секция «Вид» в шторке настроек, чтобы не добавлять шестую
|
||||||
|
кнопку в уже ужатый тулбар.
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- Демонстрация экрана временно перебивает выбранный режим: пока идёт шэр,
|
||||||
|
сцена ведёт себя как «Стандарт», а по его завершении возвращается к
|
||||||
|
выбранному виду.
|
||||||
|
- Равномерная сетка плиток стала пригодна для телефона: 2 колонки от 360px
|
||||||
|
ширины и портретная раскладка 2×3 вместо двух плиток на страницу, которые
|
||||||
|
давал набор раскладок библиотеки. Лишние участники уходят на следующую
|
||||||
|
страницу — листается свайпом и стрелками.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Иконка участника при выключенной камере больше не превращается в эллипс:
|
||||||
|
размер считается от узкой стороны плитки (container-запросы), а не как
|
||||||
|
проценты от разных сторон. В крупной плитке иконка занимает примерно её
|
||||||
|
половину, в миниатюрах карусели — всю узкую сторону с отступом 5px; на
|
||||||
|
большой плитке фокуса иконка больше не упирается в прежний потолок 96px.
|
||||||
|
- Мини-плеер в Chrome открывался на самом пользователе вместо того, что он
|
||||||
|
видел крупно: сцена в PiP-окне — отдельный экземпляр компонента и выбирала
|
||||||
|
фокус с нуля, доходя до фолбэка «показать себя». Теперь ключ фокуса
|
||||||
|
переживает переезд в мини-окно и обратно. В Safari (video-фолбэк вместо
|
||||||
|
Document PiP) поведение не менялось.
|
||||||
|
|
||||||
## [0.0.10] — 2026-07-28
|
## [0.0.10] — 2026-07-28
|
||||||
|
|
||||||
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
|
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ services:
|
|||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.10}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.11}
|
||||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||||
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface DeviceSettingsDialogProps {
|
interface DeviceSettingsDialogProps extends StageViewProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
|
||||||
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
|
||||||
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||||||
const SHEET_DISMISS_THRESHOLD_PX = 80
|
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||||||
|
|
||||||
@@ -19,20 +19,6 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
|
|||||||
return device.label || `${fallback} ${index + 1}`
|
return device.label || `${fallback} ${index + 1}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
|
|
||||||
function useIsCompactViewport(): boolean {
|
|
||||||
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
|
||||||
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
|
||||||
media.addEventListener('change', handleChange)
|
|
||||||
return () => media.removeEventListener('change', handleChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return isCompact
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
||||||
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
||||||
@@ -47,12 +33,23 @@ function useIsCompactViewport(): boolean {
|
|||||||
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||||||
* Десктоп не меняется.
|
* Десктоп не меняется.
|
||||||
*
|
*
|
||||||
|
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
|
||||||
|
* скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной
|
||||||
|
* кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на
|
||||||
|
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
|
||||||
|
*
|
||||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||||
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
||||||
* устройство «в никуда».
|
* устройство «в никуда».
|
||||||
*/
|
*/
|
||||||
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
export function DeviceSettingsDialog({
|
||||||
|
onClose,
|
||||||
|
layoutMode,
|
||||||
|
onLayoutModeChange,
|
||||||
|
hideOthers,
|
||||||
|
onHideOthersChange,
|
||||||
|
}: DeviceSettingsDialogProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
@@ -143,12 +140,26 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="room-modal-head">
|
<div className="room-modal-head">
|
||||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>
|
||||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
|
||||||
|
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
|
||||||
|
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
|
||||||
|
{isCompact && (
|
||||||
|
<div className="stage-view-section">
|
||||||
|
<StageViewOptions
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
onLayoutModeChange={onLayoutModeChange}
|
||||||
|
hideOthers={hideOthers}
|
||||||
|
onHideOthersChange={onHideOthersChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="room-field">
|
<div className="room-field">
|
||||||
<label htmlFor="device-settings-mic">Микрофон</label>
|
<label htmlFor="device-settings-mic">Микрофон</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { EyeOff, Users } from 'lucide-react'
|
||||||
import { Track, type Participant } from 'livekit-client'
|
import { Track, type Participant } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
CarouselLayout,
|
CarouselLayout,
|
||||||
FocusLayoutContainer,
|
FocusLayoutContainer,
|
||||||
GridLayout,
|
|
||||||
RoomAudioRenderer,
|
RoomAudioRenderer,
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
type TrackReferenceOrPlaceholder,
|
type TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
|
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
|
||||||
|
import { StageGrid } from '@/components/room/StageGrid'
|
||||||
|
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||||
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
|
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
|
||||||
|
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
|
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
|
||||||
@@ -80,10 +83,57 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
|||||||
return holdMs <= 0 ? speakers : steady
|
return holdMs <= 0 ? speakers : steady
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
|
||||||
|
* камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
|
||||||
|
* участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
|
||||||
|
* говорящему с картинкой.
|
||||||
|
*/
|
||||||
|
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
|
||||||
|
return isTrackReference(track) && !track.publication.isMuted
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||||
* участниках, GridLayout при одном/двух).
|
* участниках, равномерная сетка при одном/двух).
|
||||||
|
*
|
||||||
|
* РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
|
||||||
|
* - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
|
||||||
|
* `pickStageFocus`) + карусель остальных сбоку;
|
||||||
|
* - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
|
||||||
|
* - `live-tiles` — сетка только из участников с включённой камерой,
|
||||||
|
* остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
|
||||||
|
* Если камеру не включил никто, сетка была бы пустой, поэтому в этом
|
||||||
|
* случае показываем всех — режим вырождается в `tiles`.
|
||||||
|
*
|
||||||
|
* ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
|
||||||
|
* одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
|
||||||
|
* крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
|
||||||
|
* равноправие участников, а демонстрация по определению неравноправна, ради
|
||||||
|
* неё её и включают. Режим при этом не меняется — он живёт в состоянии
|
||||||
|
* `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
|
||||||
|
* выбранному пользователем виду.
|
||||||
|
*
|
||||||
|
* СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
|
||||||
|
* область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
|
||||||
|
* шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
|
||||||
|
* (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
|
||||||
|
* остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
|
||||||
|
* чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
|
||||||
|
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
|
||||||
|
* см. `StageViewOptions`.
|
||||||
|
*
|
||||||
|
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ
|
||||||
|
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он
|
||||||
|
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
|
||||||
|
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
|
||||||
|
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari
|
||||||
|
* бага не было видно: там Document PiP не используется, а video-фолбэк
|
||||||
|
* (`useRoomPiP`) берёт `<video>` прямо из фокус-плитки основного окна. Лечится
|
||||||
|
* передачей ключа наружу и обратно: `onFocusKeyChange` → состояние в
|
||||||
|
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
|
||||||
|
* — возврат из мини-плеера тоже не сбрасывает фокус.
|
||||||
*
|
*
|
||||||
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
|
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
|
||||||
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
|
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
|
||||||
@@ -111,7 +161,8 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
|||||||
*
|
*
|
||||||
* Проп `variant="pip"` — для рендера
|
* Проп `variant="pip"` — для рендера
|
||||||
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
|
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
|
||||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида.
|
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
|
||||||
|
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
|
||||||
*
|
*
|
||||||
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
|
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
|
||||||
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
|
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
|
||||||
@@ -120,8 +171,31 @@ function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant
|
|||||||
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
|
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
|
||||||
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
|
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
|
||||||
*/
|
*/
|
||||||
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
export function RoomStage({
|
||||||
|
variant = 'full',
|
||||||
|
layoutMode = 'standard',
|
||||||
|
hideOthers = false,
|
||||||
|
onShowOthers,
|
||||||
|
onHideOthers,
|
||||||
|
initialFocusKey = null,
|
||||||
|
onFocusKeyChange,
|
||||||
|
}: {
|
||||||
|
variant?: 'full' | 'pip'
|
||||||
|
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||||
|
layoutMode?: StageLayoutMode
|
||||||
|
/** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
|
||||||
|
hideOthers?: boolean
|
||||||
|
/** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
|
||||||
|
onShowOthers?: () => void
|
||||||
|
/** Скрыть остальных — кнопка над каруселью (только широкий экран). */
|
||||||
|
onHideOthers?: () => void
|
||||||
|
/** Чем инициализировать фокус при монтировании — см. докстринг про мини-плеер. */
|
||||||
|
initialFocusKey?: string | null
|
||||||
|
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
|
||||||
|
onFocusKeyChange?: (key: string | null) => void
|
||||||
|
}) {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const isCompact = useIsCompactViewport()
|
||||||
const tracks = useTracks(STAGE_TRACK_SOURCES, {
|
const tracks = useTracks(STAGE_TRACK_SOURCES, {
|
||||||
onlySubscribed: false,
|
onlySubscribed: false,
|
||||||
})
|
})
|
||||||
@@ -167,7 +241,10 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
|||||||
// `pickStageFocus` меняют результат без изменения самих треков.
|
// `pickStageFocus` меняют результат без изменения самих треков.
|
||||||
const [prevTracks, setPrevTracks] = useState(tracks)
|
const [prevTracks, setPrevTracks] = useState(tracks)
|
||||||
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
|
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
|
||||||
const [focusKey, setFocusKey] = useState<string | null>(null)
|
// Стартовое значение — фокус, доставшийся от предыдущего экземпляра сцены
|
||||||
|
// (см. `initialFocusKey` и докстринг про мини-плеер). Дальше живёт своей
|
||||||
|
// жизнью: `pickStageFocus` пересчитывает его на каждое значимое изменение.
|
||||||
|
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
|
||||||
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
|
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
|
||||||
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
|
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
|
||||||
// Только для основного окна — в PiP плитка одна и закреплять нечего.
|
// Только для основного окна — в PiP плитка одна и закреплять нечего.
|
||||||
@@ -211,10 +288,7 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
|||||||
speakingCameraKeys,
|
speakingCameraKeys,
|
||||||
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
|
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
|
||||||
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
|
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
|
||||||
cameraKeysWithVideo:
|
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
|
||||||
variant === 'pip'
|
|
||||||
? []
|
|
||||||
: cameraTracks.filter((t) => isTrackReference(t) && !t.publication.isMuted).map(stageTrackKey),
|
|
||||||
prevKeys,
|
prevKeys,
|
||||||
prevFocusKey: focusKey,
|
prevFocusKey: focusKey,
|
||||||
pinnedKey: pinnedAlive ? pinnedKey : null,
|
pinnedKey: pinnedAlive ? pinnedKey : null,
|
||||||
@@ -228,6 +302,14 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отдаём фокус наружу (в `RoomPage`), чтобы он пережил размонтирование этой
|
||||||
|
// сцены и достался следующей — см. `initialFocusKey`. Именно эффект, а не
|
||||||
|
// вызов в теле рендера: setState ЧУЖОГО компонента во время рендера React
|
||||||
|
// запрещает.
|
||||||
|
useEffect(() => {
|
||||||
|
onFocusKeyChange?.(focusKey)
|
||||||
|
}, [focusKey, onFocusKeyChange])
|
||||||
|
|
||||||
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
|
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
|
||||||
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
|
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
|
||||||
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
|
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
|
||||||
@@ -273,29 +355,94 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
|
||||||
|
const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
|
||||||
|
const liveCameraTracks = cameraTracks.filter(hasLiveVideo)
|
||||||
|
|
||||||
|
// Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
|
||||||
|
// в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
|
||||||
|
// кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
|
||||||
|
// плитки в фокусе.
|
||||||
|
const sideTracks: TrackReferenceOrPlaceholder[] =
|
||||||
|
effectiveMode === 'tiles'
|
||||||
|
? []
|
||||||
|
: effectiveMode === 'live-tiles'
|
||||||
|
? liveCameraTracks.length > 0
|
||||||
|
? cameraTracks.filter((t) => !hasLiveVideo(t))
|
||||||
|
: []
|
||||||
|
: carouselTracks
|
||||||
|
const showCarousel = !hideOthers && sideTracks.length > 0
|
||||||
|
// Закрепление имеет смысл только там, где есть «крупная плитка» —
|
||||||
|
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
|
||||||
|
const pinProps =
|
||||||
|
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
|
||||||
|
|
||||||
|
function renderMain(): ReactNode {
|
||||||
|
if (effectiveMode === 'tiles') {
|
||||||
|
return (
|
||||||
|
<StageGrid tracks={tracks}>
|
||||||
|
<RoomParticipantTile />
|
||||||
|
</StageGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (effectiveMode === 'live-tiles') {
|
||||||
|
return (
|
||||||
|
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||||
|
<RoomParticipantTile />
|
||||||
|
</StageGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
|
||||||
|
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||||
|
if (sideTracks.length === 0 && !hideOthers) {
|
||||||
|
return (
|
||||||
|
<StageGrid tracks={tracks}>
|
||||||
|
<RoomParticipantTile {...pinProps} />
|
||||||
|
</StageGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||||
|
// (см. её исходник), поэтому вместо неё используем свою обёртку
|
||||||
|
// напрямую с тем же trackRef (аватар в фокус-плитке).
|
||||||
|
return (
|
||||||
|
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stage">
|
<section className="stage">
|
||||||
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
|
{showCarousel ? (
|
||||||
<GridLayout tracks={tracks} className="stage-tiles">
|
|
||||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
|
||||||
</GridLayout>
|
|
||||||
) : (
|
|
||||||
<FocusLayoutContainer className="stage-tiles">
|
<FocusLayoutContainer className="stage-tiles">
|
||||||
<CarouselLayout tracks={carouselTracks}>
|
{/* Обёртка колонки миниатюр: сама карусель не может быть прямым
|
||||||
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
|
ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
|
||||||
</CarouselLayout>
|
этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
|
||||||
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
действовать — порядок переносим на `.stage-side` (см. room.css). */}
|
||||||
(см. её исходник), поэтому вместо неё используем свою обёртку
|
<div className="stage-side">
|
||||||
напрямую с тем же trackRef (аватар в фокус-плитке). */}
|
{/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
|
||||||
{focusTrack && (
|
на широком экране: на мобильном колонка превращается в узкую
|
||||||
<RoomParticipantTile
|
полосу снизу, где кнопке не место, — там переключатель живёт в
|
||||||
trackRef={focusTrack}
|
шторке настроек. Вернуть участников можно кнопкой на сцене. */}
|
||||||
onStopSharing={handleStopSharing}
|
{!isCompact && onHideOthers && (
|
||||||
pinnedKey={pinnedKey}
|
<button type="button" className="stage-side-hide" onClick={onHideOthers}>
|
||||||
onTogglePin={handleTogglePin}
|
<EyeOff className="lucide" aria-hidden="true" />
|
||||||
/>
|
<span>Скрыть</span>
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
<CarouselLayout tracks={sideTracks}>
|
||||||
|
<RoomParticipantTile {...pinProps} />
|
||||||
|
</CarouselLayout>
|
||||||
|
</div>
|
||||||
|
{renderMain()}
|
||||||
</FocusLayoutContainer>
|
</FocusLayoutContainer>
|
||||||
|
) : (
|
||||||
|
// Карусели нет — основная область занимает сцену целиком.
|
||||||
|
<div className="stage-tiles stage-main-only">{renderMain()}</div>
|
||||||
|
)}
|
||||||
|
{hideOthers && sideTracks.length > 0 && (
|
||||||
|
<button type="button" className="stage-show-others" onClick={onShowOthers}>
|
||||||
|
<Users className="lucide" aria-hidden="true" />
|
||||||
|
<span>Показать остальных ({sideTracks.length})</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
||||||
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
|
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||||
|
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||||
@@ -36,7 +38,7 @@ const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
|
|||||||
systemAudio: 'include',
|
systemAudio: 'include',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomToolbarProps {
|
interface RoomToolbarProps extends StageViewProps {
|
||||||
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
|
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
|
||||||
chatVisible: boolean
|
chatVisible: boolean
|
||||||
chatOpen: boolean
|
chatOpen: boolean
|
||||||
@@ -56,10 +58,15 @@ interface RoomToolbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
|
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
|
||||||
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
|
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
|
||||||
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
|
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
|
||||||
* стилизованные по design/mockups/room.html.
|
* стилизованные по design/mockups/room.html.
|
||||||
|
*
|
||||||
|
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
|
||||||
|
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
|
||||||
|
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
|
||||||
|
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
|
||||||
*/
|
*/
|
||||||
export function RoomToolbar({
|
export function RoomToolbar({
|
||||||
chatVisible,
|
chatVisible,
|
||||||
@@ -73,8 +80,13 @@ export function RoomToolbar({
|
|||||||
pipSupported,
|
pipSupported,
|
||||||
pipActive,
|
pipActive,
|
||||||
onTogglePiP,
|
onTogglePiP,
|
||||||
|
layoutMode,
|
||||||
|
onLayoutModeChange,
|
||||||
|
hideOthers,
|
||||||
|
onHideOthersChange,
|
||||||
}: RoomToolbarProps) {
|
}: RoomToolbarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const isCompact = useIsCompactViewport()
|
||||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||||
const camera = useTrackToggle({ source: Track.Source.Camera })
|
const camera = useTrackToggle({ source: Track.Source.Camera })
|
||||||
const screenShare = useTrackToggle({
|
const screenShare = useTrackToggle({
|
||||||
@@ -140,6 +152,15 @@ export function RoomToolbar({
|
|||||||
<span className="label">Демонстрация</span>
|
<span className="label">Демонстрация</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isCompact && (
|
||||||
|
<StageViewMenu
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
onLayoutModeChange={onLayoutModeChange}
|
||||||
|
hideOthers={hideOthers}
|
||||||
|
onHideOthersChange={onHideOthersChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tb-btn"
|
className="tb-btn"
|
||||||
|
|||||||
113
frontend/src/components/room/StageGrid.tsx
Normal file
113
frontend/src/components/room/StageGrid.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
TrackLoop,
|
||||||
|
useGridLayout,
|
||||||
|
usePagination,
|
||||||
|
useSwipe,
|
||||||
|
type GridLayoutDefinition,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскладки равномерной сетки — расширенный набор поверх библиотечного
|
||||||
|
* `GRID_LAYOUTS` (@livekit/components-core, `helper/grid-layouts.ts`).
|
||||||
|
*
|
||||||
|
* Зачем свой набор. Библиотечный требует `minWidth: 560` уже для сетки 2×2,
|
||||||
|
* поэтому на телефоне (~390px) максимальной раскладкой оказывается `1×2` — две
|
||||||
|
* плитки на страницу, и десяток участников превращается в пять страниц
|
||||||
|
* листания. Здесь добавлены два портретных варианта под узкий экран (2×2 и
|
||||||
|
* 2×3): плитка шириной ~190px с именем и аватаром читается нормально, а
|
||||||
|
* страниц становится втрое меньше.
|
||||||
|
*
|
||||||
|
* Как это читает `selectGridLayout` (см. её исходник): раскладки сортируются
|
||||||
|
* по `maxTiles` (при равенстве — по `minWidth`), берётся первая, вмещающая всех
|
||||||
|
* участников, при этом раскладка пропускается, если ДАЛЬШЕ есть другая с тем же
|
||||||
|
* `maxTiles` и подходящей ориентацией контейнера. Отсюда пары «портрет/
|
||||||
|
* ландшафт» на одно и то же число плиток: 1×2 / 2×1 и 2×3 / 3×2 — на широком
|
||||||
|
* экране выигрывает горизонтальный вариант, на узком вертикальный. Если
|
||||||
|
* контейнер не дотягивает до `minWidth`/`minHeight`, функция рекурсивно
|
||||||
|
* спускается к меньшей раскладке, а лишние участники уходят на следующую
|
||||||
|
* страницу (`usePagination`).
|
||||||
|
*/
|
||||||
|
const STAGE_GRID_LAYOUTS: GridLayoutDefinition[] = [
|
||||||
|
{ columns: 1, rows: 1 },
|
||||||
|
{ columns: 1, rows: 2, orientation: 'portrait' },
|
||||||
|
{ columns: 2, rows: 1, orientation: 'landscape' },
|
||||||
|
// 4 плитки помещаются и на телефоне: 390px / 2 ≈ 190px на плитку.
|
||||||
|
{ columns: 2, rows: 2, minWidth: 360 },
|
||||||
|
{ columns: 2, rows: 3, minWidth: 360, minHeight: 480, orientation: 'portrait' },
|
||||||
|
{ columns: 3, rows: 2, minWidth: 700, orientation: 'landscape' },
|
||||||
|
{ columns: 3, rows: 3, minWidth: 700 },
|
||||||
|
{ columns: 4, rows: 4, minWidth: 960 },
|
||||||
|
{ columns: 5, rows: 5, minWidth: 1100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface StageGridProps {
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Равномерная сетка плиток с пагинацией — собственная сборка вместо
|
||||||
|
* библиотечного `GridLayout`.
|
||||||
|
*
|
||||||
|
* Причина ровно одна: `GridLayout` не пробрасывает `gridLayouts` в
|
||||||
|
* `useGridLayout`, то есть набор раскладок у неё жёстко зашит (см.
|
||||||
|
* `STAGE_GRID_LAYOUTS` выше). Всё остальное — те же публичные хуки, что
|
||||||
|
* использует оригинал (`useGridLayout` + `usePagination` + `useSwipe` +
|
||||||
|
* `TrackLoop`) и тот же класс `.lk-grid-layout`, поэтому и стили библиотеки, и
|
||||||
|
* `useVisualStableUpdate` внутри пагинации работают как раньше. Отличается
|
||||||
|
* только элемент управления страницами: библиотечные `PaginationIndicator`/
|
||||||
|
* `PaginationControl` из пакета не экспортируются, вместо них — свой
|
||||||
|
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||||
|
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||||
|
*/
|
||||||
|
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||||
|
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||||
|
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||||
|
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||||
|
// `current` мутабельный, поэтому `RefObject<HTMLDivElement | null>` в такой
|
||||||
|
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||||
|
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||||
|
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||||
|
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||||
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
|
useSwipe(gridRef, {
|
||||||
|
onLeftSwipe: pagination.nextPage,
|
||||||
|
onRightSwipe: pagination.prevPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPages = pagination.totalPageCount > 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={gridEl} className="lk-grid-layout stage-grid" data-lk-pagination={hasPages}>
|
||||||
|
<TrackLoop tracks={pagination.tracks}>{children}</TrackLoop>
|
||||||
|
{hasPages && (
|
||||||
|
<div className="stage-grid-pages">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Предыдущая страница участников"
|
||||||
|
disabled={pagination.currentPage <= 1}
|
||||||
|
onClick={pagination.prevPage}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="lucide" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span aria-live="polite">
|
||||||
|
{pagination.currentPage} / {pagination.totalPageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Следующая страница участников"
|
||||||
|
disabled={pagination.currentPage >= pagination.totalPageCount}
|
||||||
|
onClick={pagination.nextPage}
|
||||||
|
>
|
||||||
|
<ChevronRight className="lucide" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
130
frontend/src/components/room/StageViewOptions.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { LayoutGrid } from 'lucide-react'
|
||||||
|
import { STAGE_LAYOUT_MODE_OPTIONS, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||||
|
|
||||||
|
export interface StageViewProps {
|
||||||
|
layoutMode: StageLayoutMode
|
||||||
|
onLayoutModeChange: (mode: StageLayoutMode) => void
|
||||||
|
/** Карусель остальных участников скрыта — на сцене только основная область. */
|
||||||
|
hideOthers: boolean
|
||||||
|
onHideOthersChange: (hide: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок выбора вида сцены: режим показа (три варианта) + скрытие остальных
|
||||||
|
* участников. Один и тот же блок рендерится в двух местах — в поповере кнопки
|
||||||
|
* «Вид» на десктопе (`StageViewMenu` ниже) и секцией внутри мобильной шторки
|
||||||
|
* настроек (`DeviceSettingsDialog`), поэтому вынесен отдельно.
|
||||||
|
*
|
||||||
|
* «Скрыть остальных» в режиме «Плитки» заблокировано, а не спрятано: все
|
||||||
|
* плитки там равноправны, скрывать нечего, но исчезающий на ровном месте
|
||||||
|
* переключатель читался бы как баг — вместо этого он выключен с пояснением.
|
||||||
|
*/
|
||||||
|
export function StageViewOptions({
|
||||||
|
layoutMode,
|
||||||
|
onLayoutModeChange,
|
||||||
|
hideOthers,
|
||||||
|
onHideOthersChange,
|
||||||
|
}: StageViewProps) {
|
||||||
|
// Радиогруппе нужно имя, уникальное на документ: блок может оказаться на
|
||||||
|
// странице дважды (десктопный поповер и шторка живут в разных ветках
|
||||||
|
// рендера, но полагаться на это не стоит).
|
||||||
|
const groupName = useId()
|
||||||
|
const hideOthersDisabled = layoutMode === 'tiles'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stage-view-options">
|
||||||
|
<fieldset className="stage-view-modes">
|
||||||
|
<legend>Режим показа</legend>
|
||||||
|
{STAGE_LAYOUT_MODE_OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.mode}
|
||||||
|
className={`stage-view-mode${layoutMode === option.mode ? ' is-active' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={groupName}
|
||||||
|
value={option.mode}
|
||||||
|
checked={layoutMode === option.mode}
|
||||||
|
onChange={() => onLayoutModeChange(option.mode)}
|
||||||
|
/>
|
||||||
|
<span className="stage-view-mode-text">
|
||||||
|
<span className="stage-view-mode-title">{option.title}</span>
|
||||||
|
<span className="stage-view-mode-hint">{option.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className={`stage-view-toggle${hideOthersDisabled ? ' is-disabled' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideOthers}
|
||||||
|
disabled={hideOthersDisabled}
|
||||||
|
onChange={(e) => onHideOthersChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Скрыть остальных участников</span>
|
||||||
|
</label>
|
||||||
|
{hideOthersDisabled && (
|
||||||
|
<p className="stage-view-note">В режиме «Плитки» все участники равноправны — скрывать нечего.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в
|
||||||
|
* `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`):
|
||||||
|
* там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией
|
||||||
|
* «Вид» в шторке настроек.
|
||||||
|
*
|
||||||
|
* Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в
|
||||||
|
* `ShellTopbar` (слушатели на document, пока меню открыто).
|
||||||
|
*/
|
||||||
|
export function StageViewMenu(props: StageViewProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tb-menu-wrap" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||||||
|
aria-label="Вид сцены"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="icon-shell">
|
||||||
|
<LayoutGrid className="lucide" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="label">Вид</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="tb-menu" role="dialog" aria-label="Вид сцены">
|
||||||
|
<StageViewOptions {...props} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useIsCompactViewport.ts
Normal file
27
frontend/src/hooks/useIsCompactViewport.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`). */
|
||||||
|
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Живое отслеживание мобильной ширины — та же схема, что системная тема в
|
||||||
|
* `useTheme.ts` (matchMedia + change-листенер).
|
||||||
|
*
|
||||||
|
* Нужен там, где мобильный вариант — это ДРУГАЯ разметка, а не другие стили:
|
||||||
|
* шторка вместо модалки (`DeviceSettingsDialog`) и условный рендеринг кнопок
|
||||||
|
* тулбара (`RoomToolbar`). Прятать элементы новым CSS-правилом поверх
|
||||||
|
* медиазапроса в `room.css` намеренно не стали — там уже была битва
|
||||||
|
* специфичности (см. комментарий в конце `room.css`).
|
||||||
|
*/
|
||||||
|
export function useIsCompactViewport(): boolean {
|
||||||
|
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||||||
|
media.addEventListener('change', handleChange)
|
||||||
|
return () => media.removeEventListener('change', handleChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isCompact
|
||||||
|
}
|
||||||
55
frontend/src/lib/stageLayoutMode.ts
Normal file
55
frontend/src/lib/stageLayoutMode.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const STORAGE_KEY = 'vidconf-stage-layout-mode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим показа участников на сцене конференции — выбирается пользователем
|
||||||
|
* явно (до 0.0.11 раскладка выбиралась автоматически по наличию демонстрации
|
||||||
|
* экрана и числу участников).
|
||||||
|
*
|
||||||
|
* - `standard` — один крупный участник (фокус) + остальные в карусели сбоку;
|
||||||
|
* кто именно в фокусе, по-прежнему решает `pickStageFocus`.
|
||||||
|
* - `tiles` — все участники равными плитками, без выделенного крупного.
|
||||||
|
* - `live-tiles` — равномерная сетка ТОЛЬКО из участников с включённой
|
||||||
|
* камерой, остальные (камера выключена) — в карусели сбоку.
|
||||||
|
*/
|
||||||
|
export type StageLayoutMode = 'standard' | 'tiles' | 'live-tiles'
|
||||||
|
|
||||||
|
/** Порядок и подписи режимов в переключателе (единый источник для тулбара и мобильной шторки). */
|
||||||
|
export const STAGE_LAYOUT_MODE_OPTIONS: readonly {
|
||||||
|
mode: StageLayoutMode
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
}[] = [
|
||||||
|
{ mode: 'standard', title: 'Стандарт', hint: 'Крупно — активный участник, остальные сбоку' },
|
||||||
|
{ mode: 'tiles', title: 'Плитки', hint: 'Все участники равными плитками' },
|
||||||
|
{ mode: 'live-tiles', title: 'Живые плитки', hint: 'Плитками — только с камерой, остальные сбоку' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isStageLayoutMode(value: string | null): value is StageLayoutMode {
|
||||||
|
return value === 'standard' || value === 'tiles' || value === 'live-tiles'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранённый режим показа — читается при входе в комнату.
|
||||||
|
*
|
||||||
|
* Персист сделан своим модулем (как `lib/audioOutputDevice.ts`), а не через
|
||||||
|
* `usePersistentUserChoices` LiveKit: их `LocalUserChoices` — фиксированная
|
||||||
|
* структура про устройства и имя, поля для нашей раскладки там нет и добавить
|
||||||
|
* его нельзя. Схема хранения та же: одна строка в localStorage, любые сбои
|
||||||
|
* доступа к нему гасятся (приватный режим/политики браузера).
|
||||||
|
*/
|
||||||
|
export function loadStageLayoutMode(): StageLayoutMode {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return isStageLayoutMode(stored) ? stored : 'standard'
|
||||||
|
} catch {
|
||||||
|
return 'standard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStageLayoutMode(mode: StageLayoutMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode)
|
||||||
|
} catch {
|
||||||
|
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { RoomToolbar } from '@/components/room/RoomToolbar'
|
|||||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
@@ -150,6 +151,30 @@ export function RoomPage() {
|
|||||||
const pip = useRoomPiP(roomRootRef)
|
const pip = useRoomPiP(roomRootRef)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
|
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
|
||||||
|
// шторке настроек, а сцена их только читает (общий предок).
|
||||||
|
//
|
||||||
|
// Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`,
|
||||||
|
// localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их
|
||||||
|
// и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое
|
||||||
|
// действие по ходу разговора, и войти в новую конференцию сразу без половины
|
||||||
|
// участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со
|
||||||
|
// страницы комнаты.
|
||||||
|
const [layoutMode, setLayoutMode] = useState<StageLayoutMode>(loadStageLayoutMode)
|
||||||
|
const [hideOthers, setHideOthers] = useState(false)
|
||||||
|
|
||||||
|
const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => {
|
||||||
|
setLayoutMode(mode)
|
||||||
|
saveStageLayoutMode(mode)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
||||||
|
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
||||||
|
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
||||||
|
// ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и
|
||||||
|
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
|
||||||
|
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
|
||||||
|
|
||||||
// Сохранённый выбор устройств — читаем через собственный вызов
|
// Сохранённый выбор устройств — читаем через собственный вызов
|
||||||
// usePersistentUserChoices (независимый от того, что использует
|
// usePersistentUserChoices (независимый от того, что использует
|
||||||
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
|
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
|
||||||
@@ -218,7 +243,14 @@ export function RoomPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RoomStage />
|
<RoomStage
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
hideOthers={hideOthers}
|
||||||
|
onShowOthers={() => setHideOthers(false)}
|
||||||
|
onHideOthers={() => setHideOthers(true)}
|
||||||
|
initialFocusKey={stageFocusKey}
|
||||||
|
onFocusKeyChange={setStageFocusKey}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{chatVisible && chatOpen && (
|
{chatVisible && chatOpen && (
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
@@ -242,15 +274,32 @@ export function RoomPage() {
|
|||||||
pipSupported={pip.supported}
|
pipSupported={pip.supported}
|
||||||
pipActive={pip.active}
|
pipActive={pip.active}
|
||||||
onTogglePiP={pip.toggle}
|
onTogglePiP={pip.toggle}
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
onLayoutModeChange={handleLayoutModeChange}
|
||||||
|
hideOthers={hideOthers}
|
||||||
|
onHideOthersChange={setHideOthers}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />}
|
{settingsOpen && (
|
||||||
|
<DeviceSettingsDialog
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
onLayoutModeChange={handleLayoutModeChange}
|
||||||
|
hideOthers={hideOthers}
|
||||||
|
onHideOthersChange={setHideOthers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
||||||
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
||||||
`variant="pip"` — мини-плеер
|
`variant="pip"` — мини-плеер
|
||||||
показывает только активное окно (одну плитку), без карусели/грида
|
показывает только активное окно (одну плитку), без карусели/грида
|
||||||
основного окна. */}
|
основного окна. `initialFocusKey` — то, что было крупно в основном
|
||||||
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)}
|
окне: без него мини-окно открывалось на самом пользователе. */}
|
||||||
|
{pip.pipWindow &&
|
||||||
|
createPortal(
|
||||||
|
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
|
||||||
|
pip.pipWindow.document.body,
|
||||||
|
)}
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,9 @@
|
|||||||
.room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); }
|
.room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); }
|
||||||
|
|
||||||
.room-main { display: flex; flex: 1; min-height: 0; }
|
.room-main { display: flex; flex: 1; min-height: 0; }
|
||||||
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; }
|
/* `position: relative` — якорь для кнопки «Показать остальных» (`.stage-show-others`),
|
||||||
|
которая висит оверлеем над сценой, см. её правило ниже. */
|
||||||
|
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; position: relative; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для
|
* Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для
|
||||||
@@ -115,6 +117,10 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.stage-tiles.lk-focus-layout { grid-template-columns: 1fr; }
|
.stage-tiles.lk-focus-layout { grid-template-columns: 1fr; }
|
||||||
.stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
|
.stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
|
||||||
|
/* Их `.lk-carousel{order:1}` (ставит миниатюры ПОД спикера) больше не
|
||||||
|
действует: прямой ребёнок грида теперь обёртка `.stage-side`, а не сама
|
||||||
|
карусель — переносим порядок на неё, иначе миниатюры уезжают наверх. */
|
||||||
|
.stage-side { order: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -130,6 +136,123 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
.room-single-tile { flex: 1; min-height: 0; display: flex; }
|
.room-single-tile { flex: 1; min-height: 0; display: flex; }
|
||||||
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
|
||||||
|
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
|
||||||
|
* ней стоит кнопка «Скрыть» (см. `RoomStage`). Карусель занимает весь остаток
|
||||||
|
* колонки: ей важны собственные размеры — `CarouselLayout` по ним и решает,
|
||||||
|
* вертикальная она или горизонтальная (см. комментарий в `RoomStage`).
|
||||||
|
* `min-height: 0` обязателен — иначе flex-элемент не даёт себя сжать и
|
||||||
|
* колонка вылезает за высоту сцены.
|
||||||
|
*/
|
||||||
|
.stage-side { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; min-height: 0; }
|
||||||
|
.stage-side .lk-carousel { flex: 1; min-height: 0; }
|
||||||
|
.stage-side-hide {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
background: var(--color-room-tile);
|
||||||
|
color: var(--color-room-text-secondary);
|
||||||
|
font: var(--text-caption);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stage-side-hide:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
|
||||||
|
.stage-side-hide svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Основная область без карусели ----------
|
||||||
|
* `.stage-main-only` — сцена, где карусель не рендерится вовсе: плиточные
|
||||||
|
* режимы («Плитки» всегда, «Живые плитки» когда камеры выключены не у кого),
|
||||||
|
* «Стандарт» с единственным участником и любой режим при включённом «скрыть
|
||||||
|
* остальных». Единственный ребёнок (плитка фокуса или сетка `.stage-grid`)
|
||||||
|
* занимает всё место — тем же приёмом, что `.room-single-tile` выше, но без
|
||||||
|
* `min-height` мини-плеера: в основном окне высота приходит от `.room-main`.
|
||||||
|
*/
|
||||||
|
.stage-main-only { display: flex; }
|
||||||
|
.stage-main-only > * { flex: 1; min-width: 0; min-height: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Равномерная сетка плиток (`StageGrid`) ----------
|
||||||
|
* Класс `.lk-grid-layout` библиотеки на месте (оттуда вся геометрия сетки:
|
||||||
|
* `--lk-col-count`/`--lk-row-count`, gap, padding), здесь — только то, чего у
|
||||||
|
* неё нет: сброс минимальных размеров (сетка может жить внутри grid-ячейки
|
||||||
|
* `.lk-focus-layout`, где без `min-*: 0` она распирала бы колонку) и якорь для
|
||||||
|
* собственного переключателя страниц.
|
||||||
|
*/
|
||||||
|
.stage-grid { position: relative; min-width: 0; min-height: 0; }
|
||||||
|
.stage-grid-pages {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(var(--lk-grid-gap) / 2);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(30, 30, 30, 0.85);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
color: var(--color-room-text-primary);
|
||||||
|
font: var(--text-caption);
|
||||||
|
}
|
||||||
|
.stage-grid-pages button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stage-grid-pages button:hover:not(:disabled) { background: var(--color-room-tile-hover); }
|
||||||
|
.stage-grid-pages button:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.stage-grid-pages svg { width: 16px; height: 16px; }
|
||||||
|
.stage-grid-pages span { padding: 0 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Кнопка «Показать остальных» ----------
|
||||||
|
* Единственный видимый способ вернуть скрытую карусель, поэтому висит прямо на
|
||||||
|
* сцене, а не только в меню «Вид»: пользователь не должен «терять» участников
|
||||||
|
* без понятного способа их вернуть. Внизу по центру — там у плиток пусто
|
||||||
|
* (`.lk-participant-metadata` прижата к левому и правому углам).
|
||||||
|
*/
|
||||||
|
.stage-show-others {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-5);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
background: rgba(30, 30, 30, 0.9);
|
||||||
|
color: var(--color-room-text-primary);
|
||||||
|
font: var(--text-body);
|
||||||
|
font-weight: 600;
|
||||||
|
/* Иначе на узком экране подпись ломается на две строки и кнопка вырастает
|
||||||
|
в высоту вдвое (поймали на 390px). */
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stage-show-others:hover { background: var(--color-room-tile-hover); }
|
||||||
|
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
||||||
.room-toolbar {
|
.room-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -200,6 +323,75 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Кнопка «Вид» и её поповер (десктоп) ----------
|
||||||
|
* Обёртка вокруг кнопки тулбара — якорь для всплывающей панели над ней
|
||||||
|
* (`StageViewMenu`). На мобильном ни кнопки, ни поповера нет вовсе: там блок
|
||||||
|
* настроек вида рендерится секцией внутри шторки (условный рендеринг в React,
|
||||||
|
* а не скрытие через CSS, — см. `RoomToolbar`).
|
||||||
|
*/
|
||||||
|
.tb-menu-wrap { position: relative; display: flex; }
|
||||||
|
.tb-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--space-2));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
width: 280px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
background: var(--color-room-surface-raised);
|
||||||
|
box-shadow: var(--shadow-room-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Блок «Вид сцены» (`StageViewOptions`) ----------
|
||||||
|
* Общий для десктопного поповера и мобильной шторки — отсюда нейтральная
|
||||||
|
* геометрия без привязки к контейнеру (ширину задаёт родитель).
|
||||||
|
*/
|
||||||
|
.stage-view-options { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
/* Секция «Вид» внутри шторки настроек — отбивка как у `.room-field` (свой
|
||||||
|
класс, а не `.room-field`, см. комментарий в `DeviceSettingsDialog`). */
|
||||||
|
.stage-view-section { margin-bottom: var(--space-5); }
|
||||||
|
.stage-view-modes { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; border: none; }
|
||||||
|
.stage-view-modes legend {
|
||||||
|
padding: 0 0 var(--space-2);
|
||||||
|
font: var(--text-caption);
|
||||||
|
color: var(--color-room-text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.stage-view-mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stage-view-mode:hover { background: var(--color-room-tile); }
|
||||||
|
.stage-view-mode.is-active { background: var(--color-room-tile); border-color: var(--color-room-speaker-ring); }
|
||||||
|
.stage-view-mode input { margin-top: 3px; accent-color: var(--color-room-mic-on); flex-shrink: 0; }
|
||||||
|
.stage-view-mode-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.stage-view-mode-title { font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); }
|
||||||
|
.stage-view-mode-hint { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); }
|
||||||
|
|
||||||
|
.stage-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-room-tile-border);
|
||||||
|
font: var(--text-body);
|
||||||
|
color: var(--color-room-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stage-view-toggle input { accent-color: var(--color-room-mic-on); flex-shrink: 0; }
|
||||||
|
.stage-view-toggle.is-disabled { color: var(--color-room-text-tertiary); cursor: default; }
|
||||||
|
.stage-view-note { margin: 0; font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-tertiary); }
|
||||||
|
|
||||||
/* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */
|
/* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
@@ -518,17 +710,50 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
.room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); }
|
.room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); }
|
||||||
|
|
||||||
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ---------- */
|
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ----------
|
||||||
|
* Размер считается от УЗКОЙ стороны плитки, а не от ширины и высоты по
|
||||||
|
* отдельности. Раньше здесь было `width: 40%; height: 40%` — проценты от
|
||||||
|
* РАЗНЫХ сторон, а круглым `.avatar` делает только `border-radius: 50%`
|
||||||
|
* (shell.css), поэтому на неквадратной плитке (а они почти всегда такие)
|
||||||
|
* получался эллипс. Плюс `max-*: 96px` не давал иконке вырасти на большой
|
||||||
|
* плитке фокуса.
|
||||||
|
*
|
||||||
|
* Механика: контейнером container-запросов делаем `.lk-participant-placeholder`
|
||||||
|
* — блок библиотеки, который `position: absolute; inset: 0`, то есть в
|
||||||
|
* точности повторяет плитку и уже имеет обе размерности (обязательное условие
|
||||||
|
* для `container-type: size`). Контейнер именно на нём, а не на самой
|
||||||
|
* `.lk-participant-tile`: `container-type: size` включает size containment, и
|
||||||
|
* на плитке он отрезал бы её содержимое (видео, метаданные) от влияния на
|
||||||
|
* авторазмер — на placeholder-обёртке внутри ничего, кроме аватара, нет,
|
||||||
|
* ломать нечему.
|
||||||
|
*
|
||||||
|
* `cqmin` — минимум из ширины и высоты контейнера, буквально «узкая сторона»:
|
||||||
|
* - основная (крупная) плитка — 50cqmin, примерно половина плитки;
|
||||||
|
* - плитки карусели — во всю узкую сторону минус отступ 5px с каждого края.
|
||||||
|
* `aspect-ratio: 1` + `height: auto` гарантируют круг при любой форме плитки:
|
||||||
|
* диаметр никогда не больше меньшей стороны, дальше иконка уменьшается
|
||||||
|
* пропорционально вместе с плиткой. `min-width: 32px` оставлен — на совсем
|
||||||
|
* крошечной плитке инициалы должны оставаться читаемыми (сжатие плитки ниже
|
||||||
|
* этого порога и так означает, что показывать там нечего).
|
||||||
|
*/
|
||||||
|
.lk-participant-tile .lk-participant-placeholder {
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
.room-tile-avatar {
|
.room-tile-avatar {
|
||||||
width: 40%;
|
width: 50cqmin;
|
||||||
height: 40%;
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
min-height: 32px;
|
min-height: 0;
|
||||||
max-width: 96px;
|
max-width: none;
|
||||||
max-height: 96px;
|
max-height: none;
|
||||||
font-size: clamp(12px, 3vw, 28px);
|
font-size: clamp(12px, 20cqmin, 44px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.lk-carousel .room-tile-avatar {
|
||||||
|
width: calc(100cqmin - 10px);
|
||||||
|
font-size: clamp(12px, 34cqmin, 28px);
|
||||||
|
}
|
||||||
|
|
||||||
.room-loader, .room-error {
|
.room-loader, .room-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user