diff --git a/src/components/adb-client/adb-emulator-view.tsx b/src/components/adb-client/adb-emulator-view.tsx index 6d78d7d8..101ddcdf 100644 --- a/src/components/adb-client/adb-emulator-view.tsx +++ b/src/components/adb-client/adb-emulator-view.tsx @@ -37,6 +37,11 @@ import { } from '@/services/adb-streamer/utils/constants'; import {Card, CardContent} from "@/components/ui/card.tsx"; import * as Tabs from '@radix-ui/react-tabs'; +import { DisconnectionBanner, DisconnectionInfo } from '@/components/webrtc-client/emulator-error'; + +const INITIAL_BACKOFF_MS = 1000; +const MAX_BACKOFF_MS = 30000; +const MAX_RECONNECT_ATTEMPTS = 5; // Silent logger for adb-emulator diagnostics (disabled by default). // To re-enable debugging set DEBUG_ADB_EMULATOR = true and the logger will forward to console. @@ -183,6 +188,18 @@ const AdbEmulatorView: React.FC = () => { const hoverHelperRef = useRef(new LocalScrcpyHoverHelper()); const audioFramesFedRef = useRef(0); + // Reconnect state refs + const userDisconnectRef = useRef(false); + const reconnectAttemptsRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const countdownIntervalRef = useRef | null>(null); + // Stores params from the last successful start() call so reconnect can replay them + const lastStartParamsRef = useRef<{ maxFps: number; bitRate: number; audioEncoder?: string; audioCodec?: string; videoEncoder?: string; videoCodec?: string; device?: string } | null>(null); + type StartParams = NonNullable; + // Keep a ref to the latest start() to avoid stale closures inside timer callbacks + const startRef = useRef<(params: StartParams) => Promise>(async () => {}); + const handleUnexpectedDisconnectRef = useRef<() => void>(() => {}); + // Safe enqueue that checks controller ref and closed flag. Avoids throwing when stream closed. const safeEnqueueRef = (controllerRefObj: React.MutableRefObject, closedRefObj: React.MutableRefObject, chunk: any, name = '') => { const controller = controllerRefObj.current; @@ -376,6 +393,8 @@ const AdbEmulatorView: React.FC = () => { const [framesSkipped, setFramesSkipped] = useState(0); const [isStreaming, setIsStreaming] = useState(false); const [isWsOpen, setIsWsOpen] = useState(false); + const [reconnectInfo, setReconnectInfo] = useState(null); + const [hasAttemptedConnect, setHasAttemptedConnect] = useState(false); const [controlLeft, setControlLeft] = useState(false); const [controlRight, setControlRight] = useState(false); @@ -583,6 +602,11 @@ const AdbEmulatorView: React.FC = () => { }, []); const start = useCallback(async ({maxFps, bitRate, audioEncoder: overrideAudioEncoder, audioCodec: overrideAudioCodec, videoEncoder: overrideVideoEncoder, videoCodec: overrideVideoCodec, device: overrideDevice}: { maxFps: number; bitRate: number; audioEncoder?: string; audioCodec?: string; videoEncoder?: string; videoCodec?: string; device?: string }) => { + // Save params for potential auto-reconnect + lastStartParamsRef.current = { maxFps, bitRate, audioEncoder: overrideAudioEncoder, audioCodec: overrideAudioCodec, videoEncoder: overrideVideoEncoder, videoCodec: overrideVideoCodec, device: overrideDevice }; + // A new explicit start always resets the user-disconnect flag + userDisconnectRef.current = false; + await dispose(); // ensure a device is selected in the store before attempting to start @@ -1054,6 +1078,10 @@ const AdbEmulatorView: React.FC = () => { audioControllerRef.current = null; setIsWsOpen(false); wsRef.current = null; + // Trigger auto-reconnect if this was not a user-initiated disconnect + if (!userDisconnectRef.current) { + handleUnexpectedDisconnectRef.current(); + } }, onmessage: (...args: any[]) => { const evt = args[args.length - 1]; @@ -1120,6 +1148,60 @@ const AdbEmulatorView: React.FC = () => { }, 1000); }, [adbStore, dispose]); + // Keep startRef pointing to the latest start() to avoid stale closures in reconnect timers + useEffect(() => { startRef.current = start as any; }, [start]); + + // Backoff reconnect handler for unexpected disconnections – uses only refs to avoid stale closures + const handleUnexpectedDisconnect = useCallback(() => { + if (userDisconnectRef.current) return; + + const attempt = reconnectAttemptsRef.current + 1; + reconnectAttemptsRef.current = attempt; + + if (attempt > MAX_RECONNECT_ATTEMPTS) { + setReconnectInfo({ countdown: 0, attempt, maxAttempts: MAX_RECONNECT_ATTEMPTS, failed: true }); + setIsStreaming(false); + return; + } + + const delayMs = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1), MAX_BACKOFF_MS); + // Math.pow is safe here because MAX_RECONNECT_ATTEMPTS caps the exponent at 4 (max delay ≤ 16s before the 30s ceiling) + let remainingSecs = Math.ceil(delayMs / 1000); + + setReconnectInfo({ countdown: remainingSecs, attempt, maxAttempts: MAX_RECONNECT_ATTEMPTS, failed: false }); + + const interval = setInterval(() => { + remainingSecs = Math.max(0, remainingSecs - 1); + setReconnectInfo((prev: DisconnectionInfo | null) => (prev && !prev.failed ? { ...prev, countdown: remainingSecs } : prev)); + }, 1000); + countdownIntervalRef.current = interval; + + reconnectTimerRef.current = setTimeout(() => { + clearInterval(interval); + countdownIntervalRef.current = null; + if (userDisconnectRef.current || !lastStartParamsRef.current) { + setReconnectInfo(null); + return; + } + setReconnectInfo((prev: DisconnectionInfo | null) => (prev ? { ...prev, countdown: 0 } : null)); + startRef.current(lastStartParamsRef.current!).then(() => { + reconnectAttemptsRef.current = 0; + setReconnectInfo(null); + }).catch(() => { + setReconnectInfo(null); + handleUnexpectedDisconnectRef.current(); + }); + }, delayMs); + }, []); // all external state accessed via refs or stable setters + + useEffect(() => { handleUnexpectedDisconnectRef.current = handleUnexpectedDisconnect; }, [handleUnexpectedDisconnect]); + + // Clean up pending reconnect timers on unmount + useEffect(() => () => { + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); + }, []); + const hasDevice = !!adbStore.device; @@ -1375,6 +1457,13 @@ const AdbEmulatorView: React.FC = () => { }, []); const disconnect = useCallback(async () => { + // Mark as user-initiated so auto-reconnect doesn't trigger + userDisconnectRef.current = true; + // Cancel any pending reconnect timers + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } + if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } + reconnectAttemptsRef.current = 0; + setReconnectInfo(null); try { await dispose(); } finally { @@ -1523,6 +1612,7 @@ const AdbEmulatorView: React.FC = () => { toast.error(`Failed to load metadata: ${msg}`); } finally { setIsLoadingMeta(false); + setHasAttemptedConnect(true); } }, [adbStore, fileStore, toast]); @@ -1590,7 +1680,9 @@ const AdbEmulatorView: React.FC = () => {
{adbStore.devices?.length ? `${adbStore.devices.length} device(s) available` : 'No devices loaded'}
@@ -1599,10 +1691,40 @@ const AdbEmulatorView: React.FC = () => { - {/** Optional banner when no device is present */} + {/** Reconnect banner shown when streaming connection drops unexpectedly */} + {reconnectInfo && ( + { + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } + if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } + setReconnectInfo(null); + reconnectAttemptsRef.current = 0; + if (lastStartParamsRef.current) { + startRef.current(lastStartParamsRef.current).catch(() => { + handleUnexpectedDisconnectRef.current(); + }); + } + }} + onDismiss={() => { + userDisconnectRef.current = true; + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } + if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } + reconnectAttemptsRef.current = 0; + setReconnectInfo(null); + }} + /> + )} + + {/** Banner shown when no device is present */} {!hasDevice && (
- No devices available. Connect a device to enable streaming. + {isLoadingMeta + ? 'Loading device metadata…' + : hasAttemptedConnect + ? 'No devices found. Check that the emulator URL is correct and the backend server is running.' + : 'No devices loaded. Enter the connection URL above and click Connect to load device metadata.' + }
)} diff --git a/src/components/ui/styles/emulator.css b/src/components/ui/styles/emulator.css index a3b4ae9a..a8a664b1 100644 --- a/src/components/ui/styles/emulator.css +++ b/src/components/ui/styles/emulator.css @@ -334,3 +334,20 @@ html.dark .emulator-page .emulator-container { order: 1; } } + +/* Loading spinner used inside emulator buttons and placeholders */ +@keyframes emu-spin { + to { transform: rotate(360deg); } +} + +.emu-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: emu-spin 0.75s linear infinite; + vertical-align: middle; + flex-shrink: 0; +} diff --git a/src/components/webrtc-client/emulator-error.tsx b/src/components/webrtc-client/emulator-error.tsx index 87efbf3b..45b386dc 100644 --- a/src/components/webrtc-client/emulator-error.tsx +++ b/src/components/webrtc-client/emulator-error.tsx @@ -1,6 +1,13 @@ // File: src/components/webrtc-client/emulator-error.tsx import React, { useCallback, useState } from "react"; +export type DisconnectionInfo = { + countdown: number; + attempt: number; + maxAttempts: number; + failed: boolean; +}; + export function useEmulatorError() { const [hasEmulatorError, setHasEmulatorError] = useState(false); const [emulatorErrorMessage, setEmulatorErrorMessage] = useState(""); @@ -86,3 +93,60 @@ export const EmulatorErrorBanner: React.FC = ({ message, onDismiss, ); }; + +type DisconnectionBannerProps = { + info: DisconnectionInfo; + onReconnectNow: () => void; + onDismiss: () => void; +}; + +export const DisconnectionBanner: React.FC = ({ info, onReconnectNow, onDismiss }) => { + const { countdown, attempt, maxAttempts, failed } = info; + return ( +
+
+
+ {failed ? "Connection lost" : "Connection lost — reconnecting…"} +
+
+ {failed + ? `Auto-reconnect failed after ${maxAttempts} attempt${maxAttempts !== 1 ? "s" : ""}. Please reconnect manually.` + : countdown > 0 + ? `Attempt ${attempt} of ${maxAttempts} — retrying in ${countdown}s` + : `Attempt ${attempt} of ${maxAttempts} — connecting…`} +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/components/webrtc-client/webrtc-emulator-view.tsx b/src/components/webrtc-client/webrtc-emulator-view.tsx index 54aa013e..8f2fa1ac 100644 --- a/src/components/webrtc-client/webrtc-emulator-view.tsx +++ b/src/components/webrtc-client/webrtc-emulator-view.tsx @@ -1,10 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { storage } from '@/services/adb-streamer/storage'; import "../ui/styles/emulator.css"; import { Card, CardContent } from "@/components/ui/card.tsx"; import { Alert, AlertTitle } from "@/components/ui/alert.tsx"; import { Emulator } from "android-emulator-webrtc/emulator"; -import { useEmulatorError, EmulatorErrorBanner } from "./emulator-error"; +import { useEmulatorError, EmulatorErrorBanner, DisconnectionBanner, DisconnectionInfo } from "./emulator-error"; + +const INITIAL_BACKOFF_MS = 1000; +const MAX_BACKOFF_MS = 30000; +const MAX_RECONNECT_ATTEMPTS = 5; type Props = { emuUrl?: string; @@ -56,6 +60,21 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey const { hasEmulatorError, emulatorErrorMessage, onEmulatorError: reportEmulatorError, dismiss, retry } = useEmulatorError(); + // Reconnect state + const [reconnectInfo, setReconnectInfo] = useState(null); + const userDisconnectRef = useRef(false); + const reconnectAttemptsRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const countdownIntervalRef = useRef | null>(null); + // Keep a ref to the current connect function to avoid stale closures inside timers + const connectRef = useRef<() => void>(() => {}); + const handleUnexpectedDisconnectRef = useRef<() => void>(() => {}); + + const clearReconnectTimers = useCallback(() => { + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } + if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } + }, []); + useEffect(() => { if (propEmuUrl && propEmuUrl !== emuUrl) { setEmuUrl(propEmuUrl); @@ -71,6 +90,7 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey const handleStateChange = (s?: string) => { const low = (s ?? "").toLowerCase(); + const prevStatus = status; if (!s) { setStatus("idle"); return; @@ -81,7 +101,13 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey else setStatus("idle"); if (low === "connecting") setShowEmulator(true); - if (low === "disconnected") setShowEmulator(false); + if (low === "disconnected") { + setShowEmulator(false); + // Trigger backoff reconnect only for unexpected disconnections + if (!userDisconnectRef.current && (prevStatus === "connected" || prevStatus === "connecting")) { + handleUnexpectedDisconnectRef.current(); + } + } }; const handleEmulatorError = (err: any) => { @@ -112,6 +138,11 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey return; } + // Reset reconnect state on explicit connect + userDisconnectRef.current = false; + reconnectAttemptsRef.current = 0; + setReconnectInfo(null); + setErrorMessage(null); setShowEmulator(true); setStatus("connecting"); @@ -135,6 +166,11 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey }; const disconnect = () => { + userDisconnectRef.current = true; + // Cancel any pending reconnect + clearReconnectTimers(); + reconnectAttemptsRef.current = 0; + setReconnectInfo(null); setShowEmulator(false); setEmuProps(null); setStatus("idle"); @@ -147,6 +183,52 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey setTimeout(() => connect(), 50); }; + // Keep refs current so timer callbacks always have the latest functions + useEffect(() => { connectRef.current = connect; }); + + // Backoff reconnect for unexpected disconnections – uses only refs to avoid stale closures + const handleUnexpectedDisconnect = useCallback(() => { + if (userDisconnectRef.current) return; + + const attempt = reconnectAttemptsRef.current + 1; + reconnectAttemptsRef.current = attempt; + + if (attempt > MAX_RECONNECT_ATTEMPTS) { + setReconnectInfo({ countdown: 0, attempt, maxAttempts: MAX_RECONNECT_ATTEMPTS, failed: true }); + return; + } + + const delayMs = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1), MAX_BACKOFF_MS); + // Math.pow is safe here because MAX_RECONNECT_ATTEMPTS caps the exponent at 4 (max delay ≤ 16s before the 30s ceiling) + let remainingSecs = Math.ceil(delayMs / 1000); + + setReconnectInfo({ countdown: remainingSecs, attempt, maxAttempts: MAX_RECONNECT_ATTEMPTS, failed: false }); + + const interval = setInterval(() => { + remainingSecs = Math.max(0, remainingSecs - 1); + setReconnectInfo((prev: DisconnectionInfo | null) => (prev && !prev.failed ? { ...prev, countdown: remainingSecs } : prev)); + }, 1000); + countdownIntervalRef.current = interval; + + reconnectTimerRef.current = setTimeout(() => { + clearInterval(interval); + countdownIntervalRef.current = null; + if (userDisconnectRef.current) { setReconnectInfo(null); return; } + // Attempt reconnect + try { + connectRef.current(); + } catch { + setReconnectInfo(null); + handleUnexpectedDisconnectRef.current(); + } + }, delayMs); + }, []); // all external state accessed via refs + + useEffect(() => { handleUnexpectedDisconnectRef.current = handleUnexpectedDisconnect; }, [handleUnexpectedDisconnect]); + + // Clean up any pending reconnect timers on unmount + useEffect(() => () => { clearReconnectTimers(); }, [clearReconnectTimers]); + useEffect(() => { if (!emuProps) return; const volume = Math.max(0, Math.min(100, volumePct)) / 100; @@ -187,7 +269,9 @@ export default function WebRtcEmulatorView({ emuUrl: propEmuUrl = "", storageKey