diff --git a/frontend/src/components/room/RoomToolbar.tsx b/frontend/src/components/room/RoomToolbar.tsx index 6dc3342..c9a68e6 100644 --- a/frontend/src/components/room/RoomToolbar.tsx +++ b/frontend/src/components/room/RoomToolbar.tsx @@ -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({ )} - + diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index f88a180..605f811 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -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(initialState) const [error, setError] = useState(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} /> {settingsOpen && (