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:
@@ -71,6 +71,14 @@ interface RoomToolbarProps extends StageViewProps {
|
||||
onLowerHandById: (identity: string) => void
|
||||
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
|
||||
overlayVisible?: boolean
|
||||
/**
|
||||
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
|
||||
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
|
||||
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
|
||||
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
|
||||
* вкладки — см. докстринг `handleDisconnected`.
|
||||
*/
|
||||
onLeave?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +124,7 @@ export function RoomToolbar({
|
||||
hideOthers,
|
||||
onHideOthersChange,
|
||||
overlayVisible,
|
||||
onLeave,
|
||||
}: RoomToolbarProps) {
|
||||
const toast = useToast()
|
||||
const isCompact = useIsCompactViewport()
|
||||
@@ -306,7 +315,7 @@ export function RoomToolbar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
|
||||
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
|
||||
<span className="icon-shell">
|
||||
<LogOut className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { PictureInPicture2 } from 'lucide-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 '@/styles/room.css'
|
||||
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
||||
@@ -28,6 +28,23 @@ const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
|
||||
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
|
||||
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
|
||||
|
||||
/**
|
||||
* Сколько раз подряд страница возвращается в конференцию сама после разрыва,
|
||||
* которого пользователь не просил. Ограничение — защита от бесконечного цикла
|
||||
* «вошёл → сразу выбросило»: например, если конференция кончилась ровно в
|
||||
* момент разрыва или сервер по какой-то причине не принимает участника.
|
||||
* Исчерпав бюджет, страница ведёт себя как раньше — уводит в лобби.
|
||||
*/
|
||||
const AUTO_REJOIN_LIMIT = 5
|
||||
|
||||
/**
|
||||
* Соединение, прожившее дольше этого времени, считается состоявшимся: бюджет
|
||||
* авто-перезаходов обнуляется. Без этого редкие разрывы за длинную встречу
|
||||
* (телефон засыпал пять раз за час) постепенно съели бы лимит и шестое
|
||||
* пробуждение выбросило бы участника в лобби.
|
||||
*/
|
||||
const AUTO_REJOIN_BUDGET_RESET_MS = 30_000
|
||||
|
||||
interface RoomJoinState {
|
||||
livekitUrl: string
|
||||
token: string
|
||||
@@ -78,13 +95,88 @@ export function RoomPage() {
|
||||
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
|
||||
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: стабильная ссылка на обработчик — иначе на каждом рендере
|
||||
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
|
||||
// внутренний useEffect @livekit/components-react (он в списке зависимостей
|
||||
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
|
||||
const handleDisconnected = useCallback(() => {
|
||||
navigate(status === 'authenticated' ? '/lobby' : '/join')
|
||||
}, [navigate, status])
|
||||
//
|
||||
// Разрыв ≠ выход. До 0.0.27 любое отключение уводило со страницы, и на
|
||||
// мобильном это ломало обычный сценарий: экран телефона гаснет, 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(() => {
|
||||
if (joinState || !slug) return
|
||||
@@ -362,6 +454,7 @@ export function RoomPage() {
|
||||
audio={false}
|
||||
video={false}
|
||||
options={roomOptions}
|
||||
onConnected={handleConnected}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
|
||||
@@ -444,6 +537,7 @@ export function RoomPage() {
|
||||
hideOthers={hideOthers}
|
||||
onHideOthersChange={setHideOthers}
|
||||
overlayVisible={fullscreenControlsVisible}
|
||||
onLeave={handleLeaveClick}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen && (
|
||||
|
||||
Reference in New Issue
Block a user