fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию

Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

Разрывы, после которых возвращаться нельзя (выгнал организатор,
конференция закрыта, вход той же личностью с другого устройства),
уводят в лобби как раньше. От бесконечного цикла "вошёл — сразу
выбросило" защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
This commit is contained in:
2026-08-04 02:52:22 +03:00
parent d11b808e97
commit b44652d6a6
2 changed files with 108 additions and 5 deletions

View File

@@ -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>

View File

@@ -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 && (