Compare commits
2 Commits
d11b808e97
...
65fbcf952c
| Author | SHA1 | Date | |
|---|---|---|---|
| 65fbcf952c | |||
| b44652d6a6 |
@@ -122,7 +122,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.26
|
VIDCONF_VERSION=0.0.27
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -3,6 +3,36 @@
|
|||||||
Формат основан на [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.27] — 2026-08-04
|
||||||
|
|
||||||
|
Разрыв связи больше не выбрасывает участника из конференции.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
|
||||||
|
мог оказаться в лобби вместо комнаты. Причина установлена по логам
|
||||||
|
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
|
||||||
|
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
|
||||||
|
соединения (замерено: 37 с после блокировки экрана, 23 с после
|
||||||
|
сворачивания браузера — то есть достаточно просто убрать вкладку в
|
||||||
|
фон, гасить экран не обязательно). Восстановить сессию после этого
|
||||||
|
нельзя — участника на сервере уже нет; нужен полный повторный вход,
|
||||||
|
и `livekit-client` пытается сделать его сам, но его бюджет повторов
|
||||||
|
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
|
||||||
|
приходило событие `Disconnected`, и страница комнаты уводила
|
||||||
|
пользователя в лобби.
|
||||||
|
|
||||||
|
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
|
||||||
|
состояние входа, и работает уже имевшийся путь авто-перезахода —
|
||||||
|
резолв конференции, свежий токен, вход заново. Намеренный выход
|
||||||
|
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
|
||||||
|
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
|
||||||
|
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
|
||||||
|
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
|
||||||
|
выгнал организатор, конференция закрыта, вход той же личностью с
|
||||||
|
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
|
||||||
|
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
|
||||||
|
прожившее дольше 30 с, счётчик обнуляет.
|
||||||
|
|
||||||
## [0.0.26] — 2026-08-04
|
## [0.0.26] — 2026-08-04
|
||||||
|
|
||||||
Мобильная комната: свайп шторки настроек и полноэкранный режим.
|
Мобильная комната: свайп шторки настроек и полноэкранный режим.
|
||||||
|
|||||||
@@ -89,7 +89,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.26}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.27}
|
||||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ interface RoomToolbarProps extends StageViewProps {
|
|||||||
onLowerHandById: (identity: string) => void
|
onLowerHandById: (identity: string) => void
|
||||||
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
|
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
|
||||||
overlayVisible?: boolean
|
overlayVisible?: boolean
|
||||||
|
/**
|
||||||
|
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
|
||||||
|
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
|
||||||
|
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
|
||||||
|
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
|
||||||
|
* вкладки — см. докстринг `handleDisconnected`.
|
||||||
|
*/
|
||||||
|
onLeave?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +124,7 @@ export function RoomToolbar({
|
|||||||
hideOthers,
|
hideOthers,
|
||||||
onHideOthersChange,
|
onHideOthersChange,
|
||||||
overlayVisible,
|
overlayVisible,
|
||||||
|
onLeave,
|
||||||
}: RoomToolbarProps) {
|
}: RoomToolbarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isCompact = useIsCompactViewport()
|
const isCompact = useIsCompactViewport()
|
||||||
@@ -306,7 +315,7 @@ export function RoomToolbar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
|
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
|
||||||
<span className="icon-shell">
|
<span className="icon-shell">
|
||||||
<LogOut className="lucide" aria-hidden="true" />
|
<LogOut className="lucide" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { PictureInPicture2 } from 'lucide-react'
|
import { PictureInPicture2 } from 'lucide-react'
|
||||||
import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
|
import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
|
||||||
import type { RoomOptions } from 'livekit-client'
|
import { DisconnectReason, type RoomOptions } from 'livekit-client'
|
||||||
import '@livekit/components-styles'
|
import '@livekit/components-styles'
|
||||||
import '@/styles/room.css'
|
import '@/styles/room.css'
|
||||||
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
||||||
@@ -28,6 +28,23 @@ const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
|
|||||||
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
|
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
|
||||||
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
|
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько раз подряд страница возвращается в конференцию сама после разрыва,
|
||||||
|
* которого пользователь не просил. Ограничение — защита от бесконечного цикла
|
||||||
|
* «вошёл → сразу выбросило»: например, если конференция кончилась ровно в
|
||||||
|
* момент разрыва или сервер по какой-то причине не принимает участника.
|
||||||
|
* Исчерпав бюджет, страница ведёт себя как раньше — уводит в лобби.
|
||||||
|
*/
|
||||||
|
const AUTO_REJOIN_LIMIT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Соединение, прожившее дольше этого времени, считается состоявшимся: бюджет
|
||||||
|
* авто-перезаходов обнуляется. Без этого редкие разрывы за длинную встречу
|
||||||
|
* (телефон засыпал пять раз за час) постепенно съели бы лимит и шестое
|
||||||
|
* пробуждение выбросило бы участника в лобби.
|
||||||
|
*/
|
||||||
|
const AUTO_REJOIN_BUDGET_RESET_MS = 30_000
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
token: string
|
token: string
|
||||||
@@ -78,13 +95,88 @@ export function RoomPage() {
|
|||||||
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
|
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Пользователь нажал «Выйти» — ЕДИНСТВЕННЫЙ признак, по которому уход со
|
||||||
|
// страницы считается намеренным. Отдельный флаг нужен потому, что причина
|
||||||
|
// `CLIENT_INITIATED` приходит не только от кнопки: livekit-client вешает
|
||||||
|
// обработчик на событие `freeze` (Page Lifecycle) и при заморозке вкладки
|
||||||
|
// сам зовёт `room.disconnect()` — с той же самой причиной. Android Chrome
|
||||||
|
// фоновую вкладку замораживает, так что без этого флага заснувший телефон
|
||||||
|
// выглядел бы ровно как «пользователь вышел» и уезжал бы в лобби.
|
||||||
|
const leaveRequestedRef = useRef(false)
|
||||||
|
// Когда установилось текущее соединение — чтобы отличить «разрыв после
|
||||||
|
// нормальной работы» от «выбрасывает сразу после входа» (см. AUTO_REJOIN_*).
|
||||||
|
const connectedAtRef = useRef(0)
|
||||||
|
const autoRejoinCountRef = useRef(0)
|
||||||
|
|
||||||
|
const handleLeaveClick = useCallback(() => {
|
||||||
|
leaveRequestedRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConnected = useCallback(() => {
|
||||||
|
connectedAtRef.current = Date.now()
|
||||||
|
// Мы снова в комнате — намерение выйти, если оно откуда-то осталось
|
||||||
|
// невыполненным, к этому соединению уже не относится.
|
||||||
|
leaveRequestedRef.current = false
|
||||||
|
}, [])
|
||||||
|
|
||||||
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
|
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
|
||||||
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
|
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
|
||||||
// внутренний useEffect @livekit/components-react (он в списке зависимостей
|
// внутренний useEffect @livekit/components-react (он в списке зависимостей
|
||||||
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
|
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
|
||||||
const handleDisconnected = useCallback(() => {
|
//
|
||||||
navigate(status === 'authenticated' ? '/lobby' : '/join')
|
// Разрыв ≠ выход. До 0.0.27 любое отключение уводило со страницы, и на
|
||||||
}, [navigate, status])
|
// мобильном это ломало обычный сценарий: экран телефона гаснет, Chrome
|
||||||
|
// срезает фоновой вкладке ресурсы, ICE перестаёт отвечать — и LiveKit
|
||||||
|
// закрывает участника через 5 с после потери соединения
|
||||||
|
// (`PEER_CONNECTION_DISCONNECTED`, замерено на проде: 37 с после блокировки
|
||||||
|
// экрана, 23 с после сворачивания браузера). Восстановить сессию после
|
||||||
|
// этого нельзя — участника на сервере уже нет (`could not restart
|
||||||
|
// participant`), нужен полный повторный вход. livekit-client такой вход
|
||||||
|
// пытается сделать сам, но его бюджет повторов (10 попыток, ~44 с) в
|
||||||
|
// фоновой вкладке успевает сгореть — и тогда приходит `Disconnected`,
|
||||||
|
// после которого пользователь оказывался в лобби вместо конференции.
|
||||||
|
//
|
||||||
|
// Теперь непреднамеренный разрыв просто сбрасывает `joinState`, а дальше
|
||||||
|
// работает уже написанный путь авто-перезахода (эффект ниже): страница
|
||||||
|
// молча резолвит конференцию, берёт свежий токен и входит заново. Никакого
|
||||||
|
// второго механизма восстановления не появляется.
|
||||||
|
const handleDisconnected = useCallback(
|
||||||
|
(reason?: DisconnectReason) => {
|
||||||
|
const leaveForGood = () => navigate(status === 'authenticated' ? '/lobby' : '/join')
|
||||||
|
|
||||||
|
if (leaveRequestedRef.current) {
|
||||||
|
leaveForGood()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрывы, после которых возвращаться некуда или нельзя: участника
|
||||||
|
// выгнал организатор, конференцию закрыли, либо этой же личностью вошли
|
||||||
|
// с другого устройства (перезаход устроил бы пинг-понг между ними).
|
||||||
|
if (
|
||||||
|
reason === DisconnectReason.PARTICIPANT_REMOVED ||
|
||||||
|
reason === DisconnectReason.ROOM_DELETED ||
|
||||||
|
reason === DisconnectReason.ROOM_CLOSED ||
|
||||||
|
reason === DisconnectReason.DUPLICATE_IDENTITY
|
||||||
|
) {
|
||||||
|
leaveForGood()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionMs = connectedAtRef.current ? Date.now() - connectedAtRef.current : 0
|
||||||
|
if (sessionMs > AUTO_REJOIN_BUDGET_RESET_MS) {
|
||||||
|
autoRejoinCountRef.current = 0
|
||||||
|
}
|
||||||
|
if (autoRejoinCountRef.current >= AUTO_REJOIN_LIMIT) {
|
||||||
|
leaveForGood()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRejoinCountRef.current += 1
|
||||||
|
connectedAtRef.current = 0
|
||||||
|
setJoinState(null)
|
||||||
|
},
|
||||||
|
[navigate, status],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (joinState || !slug) return
|
if (joinState || !slug) return
|
||||||
@@ -362,6 +454,7 @@ export function RoomPage() {
|
|||||||
audio={false}
|
audio={false}
|
||||||
video={false}
|
video={false}
|
||||||
options={roomOptions}
|
options={roomOptions}
|
||||||
|
onConnected={handleConnected}
|
||||||
onDisconnected={handleDisconnected}
|
onDisconnected={handleDisconnected}
|
||||||
>
|
>
|
||||||
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
|
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
|
||||||
@@ -444,6 +537,7 @@ export function RoomPage() {
|
|||||||
hideOthers={hideOthers}
|
hideOthers={hideOthers}
|
||||||
onHideOthersChange={setHideOthers}
|
onHideOthersChange={setHideOthers}
|
||||||
overlayVisible={fullscreenControlsVisible}
|
overlayVisible={fullscreenControlsVisible}
|
||||||
|
onLeave={handleLeaveClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
|
|||||||
Reference in New Issue
Block a user