Compare commits
3 Commits
v0.0.6
...
b11feac529
| Author | SHA1 | Date | |
|---|---|---|---|
| b11feac529 | |||
| e07217657f | |||
| 35a1455645 |
@@ -41,15 +41,12 @@ export function AuthLayout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
self-hosted
|
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
|
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
|
||||||
остальных страницах). Из брендовой панели строку «© 2026 VidConf ·
|
остальных страницах). Строку «© 2026 VidConf · self-hosted» из
|
||||||
self-hosted» при этом ужали до «self-hosted»: год там был
|
брендовой панели при этом убрали совсем (решение оператора): год в ней
|
||||||
захардкожен, а два копирайта на одном экране — дубль. */}
|
был захардкожен, а копирайт теперь живёт в футере. */}
|
||||||
<main className="form-panel">
|
<main className="form-panel">
|
||||||
{children}
|
{children}
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
|||||||
@@ -1,23 +1,51 @@
|
|||||||
|
import { useEffect, 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 { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface DeviceSettingsDialogProps {
|
interface DeviceSettingsDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
||||||
|
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||||
|
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||||||
|
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||||||
|
|
||||||
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
||||||
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
|
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): 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` (сама подписана на
|
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
|
||||||
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
|
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
|
||||||
* (localStorage, читается заново при следующем входе в комнату — см.
|
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
||||||
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
|
||||||
|
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
|
||||||
|
* `deviceId` при следующем подключении.
|
||||||
|
*
|
||||||
|
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
|
||||||
|
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||||||
|
* Десктоп не меняется.
|
||||||
*
|
*
|
||||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||||
@@ -29,6 +57,15 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
||||||
|
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
const speakerSelectable = isAudioOutputSelectable()
|
||||||
|
const isCompact = useIsCompactViewport()
|
||||||
|
|
||||||
|
useModalDismiss(onClose)
|
||||||
|
|
||||||
|
const dragStartYRef = useRef<number | null>(null)
|
||||||
|
const [dragOffset, setDragOffset] = useState(0)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
async function handleMicChange(deviceId: string) {
|
async function handleMicChange(deviceId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -49,15 +86,62 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSpeakerChange(deviceId: string) {
|
||||||
|
try {
|
||||||
|
await speaker.setActiveMediaDevice(deviceId)
|
||||||
|
saveAudioOutputDeviceId(deviceId)
|
||||||
|
} catch {
|
||||||
|
toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
dragStartYRef.current = e.touches[0].clientY
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
if (dragStartYRef.current === null) return
|
||||||
|
const delta = e.touches[0].clientY - dragStartYRef.current
|
||||||
|
if (delta > 0) setDragOffset(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchEnd() {
|
||||||
|
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dragStartYRef.current = null
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragOffset(0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="room-modal-overlay"
|
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="device-settings-title"
|
aria-labelledby="device-settings-title"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={
|
||||||
|
isCompact && dragOffset
|
||||||
|
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCompact && (
|
||||||
|
<div
|
||||||
|
className="room-sheet-handle"
|
||||||
|
onTouchStart={handleHandleTouchStart}
|
||||||
|
onTouchMove={handleHandleTouchMove}
|
||||||
|
onTouchEnd={handleHandleTouchEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="room-modal-head">
|
<div className="room-modal-head">
|
||||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
<h2 id="device-settings-title">Настройки устройств</h2>
|
||||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||||
@@ -104,6 +188,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{speakerSelectable ? (
|
||||||
|
<div className="room-field">
|
||||||
|
<label htmlFor="device-settings-speaker">Динамики</label>
|
||||||
|
<select
|
||||||
|
id="device-settings-speaker"
|
||||||
|
value={speaker.activeDeviceId}
|
||||||
|
onChange={(e) => void handleSpeakerChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Определяется…
|
||||||
|
</option>
|
||||||
|
{speaker.devices.map((device, index) => (
|
||||||
|
<option key={device.deviceId} value={device.deviceId}>
|
||||||
|
{deviceLabel(device, index, 'Динамик')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="room-field">
|
||||||
|
<span className="room-field-label">Динамики</span>
|
||||||
|
<p className="room-field-hint">
|
||||||
|
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы — этот
|
||||||
|
браузер не позволяет управлять им со страницы.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
33
frontend/src/lib/audioOutputDevice.ts
Normal file
33
frontend/src/lib/audioOutputDevice.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const STORAGE_KEY = 'vidconf-audio-output-device'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поддержка выбора устройства аудиовывода — определяем по наличию
|
||||||
|
* `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще
|
||||||
|
* (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть.
|
||||||
|
*/
|
||||||
|
export function isAudioOutputSelectable(): boolean {
|
||||||
|
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices`
|
||||||
|
* LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера),
|
||||||
|
* поля для аудиовыхода там нет.
|
||||||
|
*/
|
||||||
|
export function loadAudioOutputDeviceId(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) ?? ''
|
||||||
|
} catch {
|
||||||
|
// localStorage недоступен (приватный режим/политики браузера) — без
|
||||||
|
// сохранённого выбора, устройство по умолчанию.
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAudioOutputDeviceId(deviceId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, deviceId)
|
||||||
|
} catch {
|
||||||
|
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage'
|
|||||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
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'
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
@@ -163,6 +164,12 @@ export function RoomPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||||||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||||||
|
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
|
||||||
|
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
|
||||||
|
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
|
||||||
|
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
|
||||||
|
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||||
|
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||||
}),
|
}),
|
||||||
[userChoices],
|
[userChoices],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,11 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
|
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
|
||||||
}
|
}
|
||||||
.brand-copy { z-index: 1; }
|
/* `margin-block: auto` держит блок по центру свободной высоты панели. Нужно
|
||||||
|
с тех пор, как из панели убрали нижнюю строку копирайта (она переехала в
|
||||||
|
общий футер): у `.brand-panel` осталось два потомка вместо трёх, и
|
||||||
|
`justify-content: space-between` иначе прижал бы текст к самому низу. */
|
||||||
|
.brand-copy { z-index: 1; margin-block: auto; }
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font: var(--text-caption);
|
font: var(--text-caption);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
@@ -378,6 +378,48 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
|
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
|
||||||
|
.room-field-label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); }
|
||||||
|
.room-field-hint { font: var(--text-body); color: var(--color-room-text-secondary); margin: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Шторка «Настройки устройств» на мобильном ----------
|
||||||
|
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
|
||||||
|
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
|
||||||
|
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
|
||||||
|
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
|
||||||
|
* смещение пальца и сам решает, закрывать или вернуть панель на место;
|
||||||
|
* `transition` тут — только пружина возврата, во время самого драга
|
||||||
|
* компонент подставляет инлайновый `transition: none`).
|
||||||
|
*/
|
||||||
|
.room-sheet-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.room-sheet-panel {
|
||||||
|
background: var(--color-room-surface-raised);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
box-shadow: var(--shadow-room-panel);
|
||||||
|
padding: var(--space-4) var(--space-6) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: transform 160ms ease-out;
|
||||||
|
}
|
||||||
|
.room-sheet-handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-room-tile-border);
|
||||||
|
margin: 0 auto var(--space-4);
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ---------- Чип «Вы демонстрируете экран» ----------
|
* ---------- Чип «Вы демонстрируете экран» ----------
|
||||||
|
|||||||
Reference in New Issue
Block a user