diff --git a/opennow-stable/src/main/gfn/cloudmatch.ts b/opennow-stable/src/main/gfn/cloudmatch.ts index 2aae84a19..405111624 100644 --- a/opennow-stable/src/main/gfn/cloudmatch.ts +++ b/opennow-stable/src/main/gfn/cloudmatch.ts @@ -485,7 +485,7 @@ function timezoneOffsetMs(): number { return -new Date().getTimezoneOffset() * 60 * 1000; } -function buildSessionRequestBody(input: SessionCreateRequest): CloudMatchRequest { +function buildSessionRequestBody(input: SessionCreateRequest, deviceHashId: string): CloudMatchRequest { const { width, height } = parseResolution(input.settings.resolution); const cq = input.settings.colorQuality; // IMPORTANT: hdrEnabled is a SEPARATE toggle from color quality. @@ -506,7 +506,9 @@ function buildSessionRequestBody(input: SessionCreateRequest): CloudMatchRequest networkTestSessionId: null, parentSessionId: null, clientIdentification: "GFN-PC", - deviceHashId: crypto.randomUUID(), + // Keep device identity stable across create -> reconnect/resume flows. + // The official client preserves this identity, and resume reliability depends on it. + deviceHashId, clientVersion: "30.0", sdkVersion: "1.0", streamerVersion: 1, @@ -946,9 +948,9 @@ export async function createSession(input: SessionCreateRequest): Promise = { @@ -1472,6 +1486,8 @@ export async function claimSession(input: SessionClaimRequest): Promise; + if (parsed.version !== 1) return null; + return { + version: 1, + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + streamStatus: (typeof parsed.streamStatus === "string" ? parsed.streamStatus : "idle") as StreamStatus, + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + sessionAppId: typeof parsed.sessionAppId === "number" ? parsed.sessionAppId : null, + streamingGameId: typeof parsed.streamingGameId === "string" ? parsed.streamingGameId : null, + streamingStore: typeof parsed.streamingStore === "string" ? parsed.streamingStore : null, + recoveryAppId: typeof parsed.recoveryAppId === "number" ? parsed.recoveryAppId : null, + resumeContext: + parsed.resumeContext && + typeof parsed.resumeContext === "object" && + typeof parsed.resumeContext.sessionId === "string" && + typeof parsed.resumeContext.serverIp === "string" + ? { + sessionId: parsed.resumeContext.sessionId, + serverIp: parsed.resumeContext.serverIp, + streamingBaseUrl: + typeof parsed.resumeContext.streamingBaseUrl === "string" + ? parsed.resumeContext.streamingBaseUrl + : undefined, + signalingServer: + typeof parsed.resumeContext.signalingServer === "string" + ? parsed.resumeContext.signalingServer + : undefined, + signalingUrl: + typeof parsed.resumeContext.signalingUrl === "string" + ? parsed.resumeContext.signalingUrl + : undefined, + appId: + typeof parsed.resumeContext.appId === "number" && Number.isFinite(parsed.resumeContext.appId) + ? parsed.resumeContext.appId + : undefined, + clientId: + typeof parsed.resumeContext.clientId === "string" + ? parsed.resumeContext.clientId + : undefined, + deviceId: + typeof parsed.resumeContext.deviceId === "string" + ? parsed.resumeContext.deviceId + : undefined, + } + : null, + }; + } catch { + return null; + } +} + +function saveRuntimeSnapshot(snapshot: RuntimeSnapshot): void { + try { + localStorage.setItem(RUNTIME_SNAPSHOT_LOCALSTORAGE_KEY, JSON.stringify(snapshot)); + } catch { + // ignore + } +} + +function clearRuntimeSnapshot(): void { + try { + localStorage.removeItem(RUNTIME_SNAPSHOT_LOCALSTORAGE_KEY); + } catch { + // ignore + } +} + type AppPage = "home" | "library" | "settings"; type StreamStatus = "idle" | "queue" | "setup" | "starting" | "connecting" | "streaming"; type StreamLoadingStatus = "queue" | "setup" | "starting" | "connecting"; @@ -187,8 +280,11 @@ type SignalingRecoveryState = { }; const APP_PAGE_ORDER: AppPage[] = ["home", "library", "settings"]; -const RECOVERABLE_STREAM_STATUSES: readonly StreamStatus[] = ["queue", "setup", "starting", "connecting", "streaming"]; +const RECOVERABLE_STREAM_STATUSES: readonly StreamStatus[] = ["streaming"]; const SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS = [0, 3000] as const; +const SIGNALING_RECOVERY_STABLE_RESET_DELAY_MS = 15000; +const SIGNALING_REMOTE_ICE_GRACE_MS = 5000; +const ICE_DISCONNECTED_RECOVERY_GRACE_MS = 7000; const isMac = navigator.platform.toLowerCase().includes("mac"); @@ -1061,6 +1157,7 @@ export function App(): JSX.Element { const [controllerOverlayOpen, setControllerOverlayOpen] = useState(false); const [streamVolume, setStreamVolume] = useState(1); + const [streamMicLevel, setStreamMicLevel] = useState(1); const [isSwitchingGame, setIsSwitchingGame] = useState(false); const [switchingPhase, setSwitchingPhase] = useState(null); const [pendingSwitchGameTitle, setPendingSwitchGameTitle] = useState(null); @@ -1086,56 +1183,30 @@ export function App(): JSX.Element { && controllerConnected && !(settings.controllerMode && currentPage === "library"); - useEffect(() => { - let raf = 0; - const prev = { pressed: false }; - const tick = () => { - try { - if (streamStatus !== "streaming") { - prev.pressed = false; - raf = window.requestAnimationFrame(tick); - return; - } - const pads = navigator.getGamepads ? navigator.getGamepads() : []; - const pad = Array.from(pads).find((p) => p && p.connected) ?? null; - if (!pad) { - prev.pressed = false; - raf = window.requestAnimationFrame(tick); - return; - } - // Meta/Home button only: button 16 (standard) - const metaPressed = Boolean(pad.buttons[16]?.pressed); - if (metaPressed && !prev.pressed) { - setControllerOverlayOpen((v) => { - const opening = !v; - if (settings.controllerUiSounds) { - playControllerUiSound(opening ? "confirm" : "move", true); - } - try { - const act = pad.vibrationActuator; - if (act && typeof act.playEffect === "function") { - void act.playEffect("dual-rumble", { duration: 42, strongMagnitude: 0.35, weakMagnitude: 0.45 }); - } - } catch { - // ignore - } - return !v; - }); - } - prev.pressed = metaPressed; - } catch { - // ignore - } - raf = window.requestAnimationFrame(tick); - }; - raf = window.requestAnimationFrame(tick); - return () => { if (raf) window.cancelAnimationFrame(raf); }; - }, [streamStatus, settings.controllerUiSounds]); - // Refs const videoRef = useRef(null); const audioRef = useRef(null); const clientRef = useRef(null); + const controllerUiSoundsRef = useRef(settings.controllerUiSounds); + const isStreamingRef = useRef(streamStatus === "streaming"); + + useEffect(() => { + controllerUiSoundsRef.current = settings.controllerUiSounds; + }, [settings.controllerUiSounds]); + useEffect(() => { + isStreamingRef.current = streamStatus === "streaming"; + }, [streamStatus]); + + const handleControllerMetaToggle = useCallback(() => { + if (!isStreamingRef.current) return; + setControllerOverlayOpen((currentOpen) => { + const opening = !currentOpen; + if (controllerUiSoundsRef.current) { + playControllerUiSound(opening ? "confirm" : "move", true); + } + return opening; + }); + }, []); useEffect(() => { if (streamStatus === "streaming" && audioRef.current) { @@ -1146,10 +1217,21 @@ export function App(): JSX.Element { const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); + const runtimeSnapshotRef = useRef(loadRuntimeSnapshot()); /** Joins concurrent claim/resume calls for the same Cloud session id (single CloudMatch RESUME + signaling). */ const claimResumePromisesRef = useRef>>(new Map()); const launchAbortRef = useRef(false); const streamStatusRef = useRef(streamStatus); + const stableRecoveryResetTimerRef = useRef(null); + const remoteIceGraceTimerRef = useRef(null); + const remoteIceSeenForSessionRef = useRef(null); + const remoteIceRecoveryGenerationRef = useRef(null); + const awaitingRecoveryRemoteIceRef = useRef(false); + const appUnloadingRef = useRef(false); + const hasConfirmedRemoteIceRef = useRef(false); + const latestIceConnectionStateRef = useRef("new"); + const iceDisconnectedRecoveryTimerRef = useRef(null); + const pendingControlledDisconnectsRef = useRef(0); const signalingRecoveryRef = useRef({ attemptCount: 0, inFlight: null, @@ -1376,6 +1458,24 @@ export function App(): JSX.Element { keepLaunchError?: boolean; keepStreamingContext?: boolean; }): void => { + if (stableRecoveryResetTimerRef.current !== null) { + window.clearTimeout(stableRecoveryResetTimerRef.current); + stableRecoveryResetTimerRef.current = null; + } + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } + if (iceDisconnectedRecoveryTimerRef.current !== null) { + window.clearTimeout(iceDisconnectedRecoveryTimerRef.current); + iceDisconnectedRecoveryTimerRef.current = null; + } + remoteIceSeenForSessionRef.current = null; + remoteIceRecoveryGenerationRef.current = null; + awaitingRecoveryRemoteIceRef.current = false; + hasConfirmedRemoteIceRef.current = false; + latestIceConnectionStateRef.current = "new"; + pendingControlledDisconnectsRef.current = 0; signalingRecoveryRef.current.attemptCount = 0; signalingRecoveryRef.current.inFlight = null; signalingRecoveryRef.current.appId = null; @@ -1401,11 +1501,31 @@ export function App(): JSX.Element { if (settings.discordRichPresence) { void window.openNow.clearDiscordActivity(); } + runtimeSnapshotRef.current = null; + clearRuntimeSnapshot(); }, [diagnosticsStore, resetStatsOverlayToPreference, settings.discordRichPresence]); const resetSignalingRecoveryState = useCallback((options?: { keepExplicitShutdown?: boolean; }): void => { + if (stableRecoveryResetTimerRef.current !== null) { + window.clearTimeout(stableRecoveryResetTimerRef.current); + stableRecoveryResetTimerRef.current = null; + } + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } + if (iceDisconnectedRecoveryTimerRef.current !== null) { + window.clearTimeout(iceDisconnectedRecoveryTimerRef.current); + iceDisconnectedRecoveryTimerRef.current = null; + } + remoteIceSeenForSessionRef.current = null; + remoteIceRecoveryGenerationRef.current = null; + awaitingRecoveryRemoteIceRef.current = false; + hasConfirmedRemoteIceRef.current = false; + latestIceConnectionStateRef.current = "new"; + pendingControlledDisconnectsRef.current = 0; signalingRecoveryRef.current.generation += 1; signalingRecoveryRef.current.attemptCount = 0; signalingRecoveryRef.current.inFlight = null; @@ -1416,6 +1536,24 @@ export function App(): JSX.Element { }, []); const markExplicitSignalingShutdown = useCallback((): void => { + if (stableRecoveryResetTimerRef.current !== null) { + window.clearTimeout(stableRecoveryResetTimerRef.current); + stableRecoveryResetTimerRef.current = null; + } + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } + if (iceDisconnectedRecoveryTimerRef.current !== null) { + window.clearTimeout(iceDisconnectedRecoveryTimerRef.current); + iceDisconnectedRecoveryTimerRef.current = null; + } + remoteIceSeenForSessionRef.current = null; + remoteIceRecoveryGenerationRef.current = null; + awaitingRecoveryRemoteIceRef.current = false; + hasConfirmedRemoteIceRef.current = false; + latestIceConnectionStateRef.current = "new"; + pendingControlledDisconnectsRef.current = 0; signalingRecoveryRef.current.generation += 1; signalingRecoveryRef.current.explicitShutdown = true; signalingRecoveryRef.current.inFlight = null; @@ -1426,11 +1564,141 @@ export function App(): JSX.Element { return state.generation === generation && !state.explicitShutdown; }, []); + const scheduleStableRecoveryReset = useCallback((sessionId: string): void => { + if (stableRecoveryResetTimerRef.current !== null) { + window.clearTimeout(stableRecoveryResetTimerRef.current); + stableRecoveryResetTimerRef.current = null; + } + + stableRecoveryResetTimerRef.current = window.setTimeout(() => { + stableRecoveryResetTimerRef.current = null; + const activeSessionId = sessionRef.current?.sessionId; + if ( + streamStatusRef.current !== "streaming" + || !activeSessionId + || activeSessionId !== sessionId + ) { + return; + } + console.log( + `[Recovery] Stream remained stable for ${SIGNALING_RECOVERY_STABLE_RESET_DELAY_MS}ms; resetting recovery budget`, + ); + resetSignalingRecoveryState({ keepExplicitShutdown: true }); + }, SIGNALING_RECOVERY_STABLE_RESET_DELAY_MS); + }, [resetSignalingRecoveryState]); + + const disconnectSignalingControlled = useCallback(async (): Promise => { + pendingControlledDisconnectsRef.current += 1; + await window.openNow.disconnectSignaling().catch(() => {}); + }, []); + // Session ref sync useEffect(() => { sessionRef.current = session; }, [session]); + useEffect(() => { + const streamIsActive = streamStatus !== "idle" || session !== null || navbarActiveSession !== null; + if (!streamIsActive) { + runtimeSnapshotRef.current = null; + clearRuntimeSnapshot(); + return; + } + + const snapshot: RuntimeSnapshot = { + version: 1, + updatedAt: Date.now(), + streamStatus, + sessionId: session?.sessionId ?? navbarActiveSession?.sessionId ?? null, + sessionAppId: + (Number.isFinite(signalingRecoveryRef.current.appId ?? NaN) ? signalingRecoveryRef.current.appId : null) ?? + (navbarActiveSession ? navbarActiveSession.appId : null), + streamingGameId: streamingGame?.id ?? null, + streamingStore: streamingStore ?? null, + recoveryAppId: signalingRecoveryRef.current.appId, + resumeContext: session + ? { + sessionId: session.sessionId, + serverIp: session.serverIp, + streamingBaseUrl: session.streamingBaseUrl, + signalingServer: session.signalingServer, + signalingUrl: session.signalingUrl, + appId: Number.isFinite(signalingRecoveryRef.current.appId ?? NaN) ? signalingRecoveryRef.current.appId ?? undefined : undefined, + clientId: session.clientId, + deviceId: session.deviceId, + } + : (navbarActiveSession?.sessionId && navbarActiveSession.serverIp) + ? { + sessionId: navbarActiveSession.sessionId, + serverIp: navbarActiveSession.serverIp, + streamingBaseUrl: navbarActiveSession.streamingBaseUrl, + signalingUrl: navbarActiveSession.signalingUrl, + appId: Number.isFinite(navbarActiveSession.appId) ? navbarActiveSession.appId : undefined, + } + : null, + }; + + runtimeSnapshotRef.current = snapshot; + saveRuntimeSnapshot(snapshot); + }, [navbarActiveSession, session, streamStatus, streamingGame?.id, streamingStore]); + + const persistRuntimeSnapshotNow = useCallback((): void => { + const latestSession = sessionRef.current; + const latestNavbarSession = navbarActiveSession; + const hasActiveContext = + streamStatusRef.current !== "idle" || latestSession !== null || latestNavbarSession !== null; + if (!hasActiveContext) { + runtimeSnapshotRef.current = null; + clearRuntimeSnapshot(); + return; + } + + const snapshot: RuntimeSnapshot = { + version: 1, + updatedAt: Date.now(), + streamStatus: streamStatusRef.current, + sessionId: latestSession?.sessionId ?? latestNavbarSession?.sessionId ?? null, + sessionAppId: + (Number.isFinite(signalingRecoveryRef.current.appId ?? NaN) ? signalingRecoveryRef.current.appId : null) ?? + (latestNavbarSession ? latestNavbarSession.appId : null), + streamingGameId: streamingGame?.id ?? null, + streamingStore: streamingStore ?? null, + recoveryAppId: signalingRecoveryRef.current.appId, + resumeContext: latestSession + ? { + sessionId: latestSession.sessionId, + serverIp: latestSession.serverIp, + streamingBaseUrl: latestSession.streamingBaseUrl, + signalingServer: latestSession.signalingServer, + signalingUrl: latestSession.signalingUrl, + appId: Number.isFinite(signalingRecoveryRef.current.appId ?? NaN) ? signalingRecoveryRef.current.appId ?? undefined : undefined, + clientId: latestSession.clientId, + deviceId: latestSession.deviceId, + } + : (latestNavbarSession?.sessionId && latestNavbarSession.serverIp) + ? { + sessionId: latestNavbarSession.sessionId, + serverIp: latestNavbarSession.serverIp, + streamingBaseUrl: latestNavbarSession.streamingBaseUrl, + signalingUrl: latestNavbarSession.signalingUrl, + appId: Number.isFinite(latestNavbarSession.appId) ? latestNavbarSession.appId : undefined, + } + : null, + }; + + runtimeSnapshotRef.current = snapshot; + saveRuntimeSnapshot(snapshot); + }, [navbarActiveSession, streamingGame?.id, streamingStore]); + + useEffect(() => { + const onBeforeUnload = (): void => { + appUnloadingRef.current = true; + persistRuntimeSnapshotNow(); + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [persistRuntimeSnapshotNow]); + useEffect(() => { adReportStateRef.current = {}; adMetricsRef.current = {}; @@ -1471,6 +1739,14 @@ export function App(): JSX.Element { }; }, [controllerUiActive]); + useEffect(() => { + const lowFx = streamStatus === "streaming" && controllerOverlayOpen; + document.body.classList.toggle("controller-overlay-lowfx", lowFx); + return () => { + document.body.classList.remove("controller-overlay-lowfx"); + }; + }, [controllerOverlayOpen, streamStatus]); + useEffect(() => { if (!controllerUiActive || !controllerConnected) { document.body.classList.remove("controller-hide-cursor"); @@ -1752,7 +2028,17 @@ export function App(): JSX.Element { } try { const activeSessions = await window.openNow.getActiveSessions(token, streamingBaseUrl); - const candidate = activeSessions.find((entry) => entry.status === 3 || entry.status === 2) ?? null; + const snapshot = runtimeSnapshotRef.current; + const resumableSessions = activeSessions.filter((entry) => entry.status === 3 || entry.status === 2); + const candidate = + (snapshot?.sessionId + ? resumableSessions.find((entry) => entry.sessionId === snapshot.sessionId) + : undefined) ?? + (snapshot?.sessionAppId !== null && snapshot?.sessionAppId !== undefined + ? resumableSessions.find((entry) => entry.appId === snapshot.sessionAppId) + : undefined) ?? + resumableSessions[0] ?? + null; setNavbarActiveSession(candidate); } catch (error) { console.warn("Failed to refresh active sessions:", error); @@ -2195,6 +2481,12 @@ export function App(): JSX.Element { clientRef.current?.toggleMicrophone(); }, []); + const handleStreamMicLevelChange = useCallback((level: number) => { + const next = Math.max(0, Math.min(1, Number.isFinite(level) ? level : 1)); + setStreamMicLevel(next); + clientRef.current?.setMicrophoneLevel(next); + }, []); + const handleMouseSensitivityChange = useCallback((value: number) => { void updateSetting("mouseSensitivity", value); }, [updateSetting]); @@ -2228,10 +2520,12 @@ export function App(): JSX.Element { }, [settingsLoaded]); const handleExitApp = useCallback(() => { + appUnloadingRef.current = true; + persistRuntimeSnapshotNow(); void window.openNow.quitApp().catch((error) => { console.warn("Failed to quit application:", error); }); - }, []); + }, [persistRuntimeSnapshotNow]); const handleMicrophoneModeChange = useCallback((value: import("@shared/gfn").MicrophoneMode) => { // Keep UI responsive while still surfacing persistence failures. @@ -2353,6 +2647,12 @@ export function App(): JSX.Element { // ignore parse/storage errors } + const persistedRuntimeSnapshot = loadRuntimeSnapshot(); + runtimeSnapshotRef.current = persistedRuntimeSnapshot; + if (persistedRuntimeSnapshot?.recoveryAppId !== null && persistedRuntimeSnapshot?.recoveryAppId !== undefined) { + signalingRecoveryRef.current.appId = persistedRuntimeSnapshot.recoveryAppId; + } + setProviders(providerList); setAuthSession(persistedSession); setSavedAccounts(accounts); @@ -2565,15 +2865,37 @@ export function App(): JSX.Element { const resolveSessionClaimAppId = useCallback((existingSession: ActiveSessionInfo): string => { const trackedAppId = signalingRecoveryRef.current.appId; + const persistedAppId = runtimeSnapshotRef.current?.sessionAppId ?? runtimeSnapshotRef.current?.recoveryAppId; if (Number.isFinite(existingSession.appId) && existingSession.appId > 0) { return String(existingSession.appId); } if (trackedAppId && Number.isFinite(trackedAppId)) { return String(trackedAppId); } + if (persistedAppId && Number.isFinite(persistedAppId)) { + return String(persistedAppId); + } throw new Error("Active session is missing app metadata required for resume."); }, []); + const resolveResumeIdentity = useCallback((sessionId: string): { clientId?: string; deviceId?: string } => { + const liveSession = sessionRef.current; + if (liveSession?.sessionId === sessionId) { + return { + clientId: liveSession.clientId, + deviceId: liveSession.deviceId, + }; + } + const persisted = runtimeSnapshotRef.current?.resumeContext; + if (persisted?.sessionId === sessionId) { + return { + clientId: persisted.clientId, + deviceId: persisted.deviceId, + }; + } + return {}; + }, []); + const applyClaimedSessionAndConnect = useCallback(async ( claimed: SessionInfo, expectedRecoveryGeneration?: number, @@ -2630,7 +2952,8 @@ export function App(): JSX.Element { }); clientRef.current?.dispose(); clientRef.current = null; - await window.openNow.disconnectSignaling().catch(() => {}); + await disconnectSignalingControlled(); + awaitingRecoveryRemoteIceRef.current = expectedRecoveryGeneration !== undefined; setSession(claimed); sessionRef.current = claimed; @@ -2642,7 +2965,7 @@ export function App(): JSX.Element { signalingServer: claimed.signalingServer, signalingUrl: claimed.signalingUrl, }); - }, [isRecoveryGenerationCurrent]); + }, [disconnectSignalingControlled, isRecoveryGenerationCurrent]); const claimAndConnectSession = useCallback(async (existingSession: ActiveSessionInfo): Promise => { const sid = existingSession.sessionId; @@ -2684,6 +3007,7 @@ export function App(): JSX.Element { streamingBaseUrl: effectiveStreamingBaseUrl, serverIp: existingSession.serverIp, sessionId: existingSession.sessionId, + ...resolveResumeIdentity(existingSession.sessionId), appId: resolveSessionClaimAppId(existingSession), settings: { resolution: settings.resolution, @@ -2710,7 +3034,7 @@ export function App(): JSX.Element { claimResumePromisesRef.current.set(sid, resumePromiseHolder.promise); await resumePromiseHolder.promise; - }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]); + }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveResumeIdentity, resolveSessionClaimAppId, settings]); const attemptSessionRecovery = useCallback(async (reason: string): Promise => { const recoveryState = signalingRecoveryRef.current; @@ -2749,7 +3073,7 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; setStreamStatus("connecting"); - await window.openNow.disconnectSignaling().catch(() => {}); + await disconnectSignalingControlled(); let lastError: Error | null = null; while (recoveryState.attemptCount < SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length) { @@ -2799,6 +3123,32 @@ export function App(): JSX.Element { null; } + if (!candidate) { + const persisted = runtimeSnapshotRef.current?.resumeContext; + if ( + persisted && + persisted.sessionId === currentSessionId && + persisted.serverIp + ) { + candidate = { + sessionId: persisted.sessionId, + appId: + Number.isFinite(persisted.appId ?? NaN) + ? (persisted.appId as number) + : (previousAppId ?? 0), + status: 2, + serverIp: persisted.serverIp, + streamingBaseUrl: persisted.streamingBaseUrl, + signalingUrl: persisted.signalingUrl, + }; + console.log("[Recovery] Falling back to persisted resume context", { + sessionId: persisted.sessionId, + serverIp: persisted.serverIp, + appId: persisted.appId ?? previousAppId ?? null, + }); + } + } + if (!candidate) { const hasQueueOnlyMatch = activeSessions.some((entry) => entry.sessionId === currentSessionId && entry.status === 1); if (hasQueueOnlyMatch) { @@ -2816,6 +3166,8 @@ export function App(): JSX.Element { streamingBaseUrl: effectiveStreamingBaseUrl, serverIp: candidate.serverIp, sessionId: candidate.sessionId, + ...resolveResumeIdentity(candidate.sessionId), + recoveryMode: true, appId: resolveSessionClaimAppId(candidate), settings: { resolution: settings.resolution, @@ -2847,7 +3199,6 @@ export function App(): JSX.Element { console.log("[Recovery] Recovery generation changed before connect completed"); return false; } - recoveryState.attemptCount = 0; return true; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -2872,9 +3223,11 @@ export function App(): JSX.Element { }, [ applyClaimedSessionAndConnect, authSession, + disconnectSignalingControlled, effectiveStreamingBaseUrl, findGameContextForSession, isRecoveryGenerationCurrent, + resolveResumeIdentity, resolveSessionClaimAppId, settings, ]); @@ -2885,11 +3238,46 @@ export function App(): JSX.Element { console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); try { if (event.type === "offer") { + pendingControlledDisconnectsRef.current = 0; const activeSession = sessionRef.current; if (!activeSession) { console.warn("[App] Received offer but no active session in sessionRef!"); return; } + const shouldEnforceRemoteIceGrace = awaitingRecoveryRemoteIceRef.current; + remoteIceSeenForSessionRef.current = null; + hasConfirmedRemoteIceRef.current = false; + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } + const expectedSessionId = activeSession.sessionId; + const recoveryGenerationAtOffer = signalingRecoveryRef.current.generation; + if (shouldEnforceRemoteIceGrace) { + remoteIceGraceTimerRef.current = window.setTimeout(() => { + remoteIceGraceTimerRef.current = null; + if (sessionRef.current?.sessionId !== expectedSessionId) { + return; + } + if (remoteIceSeenForSessionRef.current === expectedSessionId) { + return; + } + if (remoteIceRecoveryGenerationRef.current === recoveryGenerationAtOffer) { + return; + } + if (!RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current)) { + return; + } + awaitingRecoveryRemoteIceRef.current = false; + remoteIceRecoveryGenerationRef.current = recoveryGenerationAtOffer; + console.warn( + `[Recovery] No remote ICE received within ${SIGNALING_REMOTE_ICE_GRACE_MS}ms after offer; forcing targeted recovery`, + ); + void attemptSessionRecovery("No remote ICE received after offer").catch((error) => { + console.error("[Recovery] ICE-timeout recovery failed:", error); + }); + }, SIGNALING_REMOTE_ICE_GRACE_MS); + } console.log("[App] Active session for offer:", JSON.stringify({ sessionId: activeSession.sessionId, serverIp: activeSession.serverIp, @@ -2920,8 +3308,47 @@ export function App(): JSX.Element { onMicStateChange: (state) => { console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); }, + onIceConnectionStateChange: (iceState) => { + latestIceConnectionStateRef.current = iceState; + if (iceDisconnectedRecoveryTimerRef.current !== null) { + window.clearTimeout(iceDisconnectedRecoveryTimerRef.current); + iceDisconnectedRecoveryTimerRef.current = null; + } + if (appUnloadingRef.current) { + return; + } + if (streamStatusRef.current !== "streaming") { + return; + } + if (iceState === "failed") { + console.warn("[Recovery] ICE failed; attempting targeted recovery"); + void attemptSessionRecovery("ICE failed").catch((error) => { + console.error("[Recovery] ICE-failed recovery failed:", error); + }); + return; + } + if (iceState === "disconnected") { + iceDisconnectedRecoveryTimerRef.current = window.setTimeout(() => { + iceDisconnectedRecoveryTimerRef.current = null; + if (appUnloadingRef.current || streamStatusRef.current !== "streaming") { + return; + } + if (latestIceConnectionStateRef.current !== "disconnected") { + return; + } + console.warn("[Recovery] ICE remained disconnected; attempting targeted recovery"); + void attemptSessionRecovery("ICE disconnected timeout").catch((error) => { + console.error("[Recovery] ICE-disconnected recovery failed:", error); + }); + }, ICE_DISCONNECTED_RECOVERY_GRACE_MS); + } + }, + onControllerMetaPress: () => { + handleControllerMetaToggle(); + }, }); clientRef.current.inputPaused = controllerOverlayOpenRef.current; + clientRef.current.setMicrophoneLevel(streamMicLevel); if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); } @@ -2937,7 +3364,7 @@ export function App(): JSX.Element { }); setLaunchError(null); setStreamStatus("streaming"); - resetSignalingRecoveryState({ keepExplicitShutdown: true }); + scheduleStableRecoveryReset(activeSession.sessionId); console.log( "[Stream] Offer applied; use [WebRTC] logs for ICE/video dimensions. signalingServer=%s media=%s", activeSession.signalingServer, @@ -2947,8 +3374,55 @@ export function App(): JSX.Element { ); } } else if (event.type === "remote-ice") { + remoteIceSeenForSessionRef.current = sessionRef.current?.sessionId ?? null; + hasConfirmedRemoteIceRef.current = true; + awaitingRecoveryRemoteIceRef.current = false; + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } await clientRef.current?.addRemoteCandidate(event.candidate); } else if (event.type === "disconnected") { + if (appUnloadingRef.current) { + console.log("[Recovery] Ignoring signaling disconnect during app shutdown"); + return; + } + const iceState = latestIceConnectionStateRef.current; + if ( + iceState === "connected" || + iceState === "completed" || + iceState === "checking" + ) { + console.log(`[Recovery] Ignoring signaling disconnect while ICE state is ${iceState}`); + return; + } + // Official-style behavior: if the attach never reached a confirmed remote ICE + // handshake, do not auto-recover. Fail this attempt and require explicit resume. + if (!hasConfirmedRemoteIceRef.current) { + console.warn("[Recovery] Skipping auto-recovery: disconnected before remote ICE handshake"); + clientRef.current?.dispose(); + clientRef.current = null; + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: "Session Connection Lost", + description: "Resume attach failed before media handshake. Try resuming once again.", + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + return; + } + if (remoteIceGraceTimerRef.current !== null) { + window.clearTimeout(remoteIceGraceTimerRef.current); + remoteIceGraceTimerRef.current = null; + } + remoteIceSeenForSessionRef.current = null; + awaitingRecoveryRemoteIceRef.current = false; + if (pendingControlledDisconnectsRef.current > 0) { + pendingControlledDisconnectsRef.current -= 1; + console.log("[Recovery] Ignoring controlled signaling disconnect"); + return; + } console.warn("Signaling disconnected:", event.reason); const recovered = await attemptSessionRecovery(event.reason).catch((error) => { console.error("[Recovery] Signaling recovery failed:", error); @@ -2977,6 +3451,10 @@ export function App(): JSX.Element { console.error("Signaling error:", event.message); } } catch (error) { + if (appUnloadingRef.current) { + console.log("[Recovery] Suppressing signaling handler errors during app shutdown"); + return; + } if ( signalingRecoveryRef.current.explicitShutdown || !RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current) @@ -3000,7 +3478,7 @@ export function App(): JSX.Element { }); return () => unsubscribe(); - }, [attemptSessionRecovery, diagnosticsStore, refreshNavbarActiveSession, resetLaunchRuntime, resetSignalingRecoveryState, settings]); + }, [attemptSessionRecovery, diagnosticsStore, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings]); // Play game handler const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { @@ -3288,7 +3766,7 @@ export function App(): JSX.Element { } console.error("Launch failed:", error); setLaunchError(toLaunchErrorState(error, loadingStep)); - await window.openNow.disconnectSignaling().catch(() => {}); + await disconnectSignalingControlled(); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); @@ -3568,7 +4046,7 @@ export function App(): JSX.Element { } catch (error) { console.error("Navbar resume failed:", error); setLaunchError(toLaunchErrorState(error, loadingStep)); - await window.openNow.disconnectSignaling().catch(() => {}); + await disconnectSignalingControlled(); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime({ keepLaunchError: true }); @@ -3647,7 +4125,7 @@ export function App(): JSX.Element { launchAbortRef.current = true; } markExplicitSignalingShutdown(); - await window.openNow.disconnectSignaling(); + await disconnectSignalingControlled(); const current = sessionRef.current; if (current) { @@ -3722,7 +4200,7 @@ export function App(): JSX.Element { const handleDismissLaunchError = useCallback(async () => { markExplicitSignalingShutdown(); - await window.openNow.disconnectSignaling().catch(() => {}); + await disconnectSignalingControlled(); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); @@ -4162,6 +4640,8 @@ export function App(): JSX.Element { inStreamMenu streamMenuVolume={streamVolume} onStreamMenuVolumeChange={handleStreamVolumeChange} + streamMenuMicLevel={streamMicLevel} + onStreamMenuMicLevelChange={handleStreamMicLevelChange} onStreamMenuToggleMicrophone={handleToggleStreamMicrophone} onStreamMenuToggleFullscreen={() => { void toggleSessionFullscreen(); diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 3afd22e88..81bff8aaa 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -67,6 +67,8 @@ interface ControllerLibraryPageProps { /** In-stream: gamepad-friendly stream actions (see Current row). */ streamMenuVolume?: number; onStreamMenuVolumeChange?: (volume01: number) => void; + streamMenuMicLevel?: number; + onStreamMenuMicLevelChange?: (level01: number) => void; onStreamMenuToggleMicrophone?: () => void; onStreamMenuToggleFullscreen?: () => void; streamMenuMicOn?: boolean; @@ -131,6 +133,16 @@ const SPOTLIGHT_RECENT_COUNT = 5; /** Decode off main thread; lazy-load shelf art so clock/timer rerenders don’t contend with image work */ const SHELF_IMAGE_PROPS = { decoding: "async" as const, loading: "lazy" as const }; +const SHELF_IMAGE_WINDOW_RADIUS = 8; +const SHELF_CONTENT_WINDOW_RADIUS = 14; + +function isWithinImageWindow(index: number, activeIndex: number, radius: number = SHELF_IMAGE_WINDOW_RADIUS): boolean { + return Math.abs(index - activeIndex) <= radius; +} + +function isWithinContentWindow(index: number, activeIndex: number, radius: number = SHELF_CONTENT_WINDOW_RADIUS): boolean { + return Math.abs(index - activeIndex) <= radius; +} /** XMB-style horizontal shelf: align active tile center with the shelf viewport center (track’s parent). */ function computeShelfTranslateXToCenter(track: HTMLElement | null, activeIndex: number): number { @@ -225,6 +237,8 @@ export function ControllerLibraryPage({ inStreamMenu = false, streamMenuVolume = 1, onStreamMenuVolumeChange, + streamMenuMicLevel = 1, + onStreamMenuMicLevelChange, onStreamMenuToggleMicrophone, onStreamMenuToggleFullscreen, streamMenuMicOn = false, @@ -247,6 +261,7 @@ export function ControllerLibraryPage({ const [categoryIndex, setCategoryIndex] = useState(initialCategoryIndex); const [endSessionConfirm, setEndSessionConfirm] = useState(false); const [editingStreamVolume, setEditingStreamVolume] = useState(false); + const [editingStreamMicLevel, setEditingStreamMicLevel] = useState(false); const itemsContainerRef = useRef(null); const overlayNavWriteRef = useRef(null); const overlayNavRestoredRef = useRef(false); @@ -570,6 +585,11 @@ export function ControllerLibraryPage({ const streamExtras = inStreamMenu ? [ { id: "toggleMic", label: "Microphone", value: streamMenuMicOn ? "On" : "Off" }, + { + id: "streamMicLevel", + label: "Mic level", + value: `${Math.round((streamMenuMicLevel ?? 1) * 100)}%`, + }, { id: "streamVolume", label: "Stream volume", @@ -597,6 +617,7 @@ export function ControllerLibraryPage({ inStreamMenu, endSessionConfirm, streamMenuMicOn, + streamMenuMicLevel, streamMenuVolume, streamMenuIsFullscreen, ]); @@ -1194,12 +1215,9 @@ export function ControllerLibraryPage({ return; } - let offset = 0; - for (let i = 0; i < selectedIndex; i++) { - const childStyle = window.getComputedStyle(children[i]); - offset += children[i].offsetHeight + parseFloat(childStyle.marginBottom); - } - offset += children[selectedIndex].offsetHeight / 2; + // Use offsetTop/offsetHeight to avoid per-item style reads on every navigation move. + const activeChild = children[selectedIndex]; + const offset = activeChild.offsetTop + (activeChild.offsetHeight / 2); setListTranslateY(-offset); setListTranslateX(0); }, [ @@ -1227,6 +1245,24 @@ export function ControllerLibraryPage({ } }, [onToggleFavoriteGame, playUiSound, selectedGame]); + const controllerEventHandlersRef = useRef<{ + onDirection: (event: Event) => void; + onShoulder: (event: Event) => void; + onActivate: () => void; + onSecondaryActivate: () => void; + onTertiaryActivate: () => void; + onCancel: (event: Event) => void; + onKeyboard: (event: KeyboardEvent) => void; + }>({ + onDirection: () => {}, + onShoulder: () => {}, + onActivate: () => {}, + onSecondaryActivate: () => {}, + onTertiaryActivate: () => {}, + onCancel: () => {}, + onKeyboard: () => {}, + }); + useEffect(() => { const applyDirection = (direction: Direction): void => { // When editing Theme RGB channels, use left/right to adjust value @@ -1280,6 +1316,21 @@ export function ControllerLibraryPage({ } return; } + if (topCategory === "current" && inStreamMenu && editingStreamMicLevel && onStreamMenuMicLevelChange) { + const step = 0.05; + const cur = streamMenuMicLevel ?? 1; + if (direction === "left") { + onStreamMenuMicLevelChange(Math.max(0, cur - step)); + playUiSound("move"); + return; + } + if (direction === "right") { + onStreamMenuMicLevelChange(Math.min(1, cur + step)); + playUiSound("move"); + return; + } + return; + } if (isLoading && topCategory !== "settings" && topCategory !== "current") return; if (optionsOpen && optionsEntries.length > 0) { @@ -1565,6 +1616,7 @@ export function ControllerLibraryPage({ playUiSound("move"); setSelectedSettingIndex(nextIndex); if (topCategory === "current" && inStreamMenu) setEditingStreamVolume(false); + if (topCategory === "current" && inStreamMenu) setEditingStreamMicLevel(false); } return; } @@ -1574,6 +1626,7 @@ export function ControllerLibraryPage({ playUiSound("move"); setSelectedSettingIndex(nextIndex); if (topCategory === "current" && inStreamMenu) setEditingStreamVolume(false); + if (topCategory === "current" && inStreamMenu) setEditingStreamMicLevel(false); } return; } @@ -1634,6 +1687,7 @@ export function ControllerLibraryPage({ setEditingBandwidth(false); setEditingThemeChannel(null); setEditingStreamVolume(false); + setEditingStreamMicLevel(false); playUiSound("move"); }; @@ -1645,7 +1699,7 @@ export function ControllerLibraryPage({ if (!direction) return; if (gamesHubOpen) return; if (topCategory === "settings" && settingsSubcategory !== "root") return; - if (editingBandwidth || editingThemeChannel || editingStreamVolume) return; + if (editingBandwidth || editingThemeChannel || editingStreamVolume || editingStreamMicLevel) return; cycleTopCategory(direction === "prev" ? -1 : 1); }; @@ -1832,6 +1886,11 @@ export function ControllerLibraryPage({ playUiSound("confirm"); return; } + if (topCategory === "current" && inStreamMenu && editingStreamMicLevel) { + setEditingStreamMicLevel(false); + playUiSound("confirm"); + return; + } if (topCategory === "current") { const item = displayItems[selectedSettingIndex]; if (item?.id === "resume" && currentStreamingGame && onResumeGame) { @@ -2041,14 +2100,19 @@ export function ControllerLibraryPage({ playUiSound("move"); return; } - if (topCategory === "current" && inStreamMenu) { - const item = displayItems[selectedSettingIndex]; - if (item?.id === "streamVolume" && onStreamMenuVolumeChange) { - setEditingStreamVolume(true); - playUiSound("move"); - } - return; + if (topCategory === "current" && inStreamMenu) { + const item = displayItems[selectedSettingIndex]; + if (item?.id === "streamVolume" && onStreamMenuVolumeChange) { + setEditingStreamVolume(true); + setEditingStreamMicLevel(false); + playUiSound("move"); + } else if (item?.id === "streamMicLevel" && onStreamMenuMicLevelChange) { + setEditingStreamMicLevel(true); + setEditingStreamVolume(false); + playUiSound("move"); } + return; + } if (topCategory === "current") { return; } @@ -2155,6 +2219,12 @@ export function ControllerLibraryPage({ e.preventDefault(); return; } + if (inStreamMenu && editingStreamMicLevel) { + setEditingStreamMicLevel(false); + playUiSound("move"); + e.preventDefault(); + return; + } // Circle/B button goes back from subcategory to root. // Prevent default to signal the App-level back handler that we've handled it. if (topCategory === "settings" && settingsSubcategory !== "root") { @@ -2323,6 +2393,12 @@ export function ControllerLibraryPage({ playUiSound("move"); return; } + if (inStreamMenu && editingStreamMicLevel) { + e.preventDefault(); + setEditingStreamMicLevel(false); + playUiSound("move"); + return; + } // Top-level back is intentionally a no-op. e.preventDefault(); @@ -2330,21 +2406,14 @@ export function ControllerLibraryPage({ } }; - window.addEventListener("opennow:controller-direction", handler); - window.addEventListener("opennow:controller-shoulder", shoulderHandler); - window.addEventListener("opennow:controller-activate", activateHandler); - window.addEventListener("opennow:controller-secondary-activate", secondaryActivateHandler); - window.addEventListener("opennow:controller-tertiary-activate", tertiaryActivateHandler); - window.addEventListener("opennow:controller-cancel", cancelHandler); - window.addEventListener("keydown", kbdHandler); - return () => { - window.removeEventListener("opennow:controller-direction", handler); - window.removeEventListener("opennow:controller-shoulder", shoulderHandler); - window.removeEventListener("opennow:controller-activate", activateHandler); - window.removeEventListener("opennow:controller-secondary-activate", secondaryActivateHandler); - window.removeEventListener("opennow:controller-tertiary-activate", tertiaryActivateHandler); - window.removeEventListener("opennow:controller-cancel", cancelHandler); - window.removeEventListener("keydown", kbdHandler); + controllerEventHandlersRef.current = { + onDirection: handler as (event: Event) => void, + onShoulder: shoulderHandler as (event: Event) => void, + onActivate: activateHandler, + onSecondaryActivate: secondaryActivateHandler, + onTertiaryActivate: tertiaryActivateHandler, + onCancel: cancelHandler, + onKeyboard: kbdHandler, }; }, [ isLoading, @@ -2416,6 +2485,9 @@ export function ControllerLibraryPage({ inStreamMenu, endSessionConfirm, editingStreamVolume, + editingStreamMicLevel, + streamMenuMicLevel, + onStreamMenuMicLevelChange, streamMenuVolume, onStreamMenuVolumeChange, onStreamMenuToggleMicrophone, @@ -2423,6 +2495,33 @@ export function ControllerLibraryPage({ controllerType, ]); + useEffect(() => { + const directionListener = (event: Event) => controllerEventHandlersRef.current.onDirection(event); + const shoulderListener = (event: Event) => controllerEventHandlersRef.current.onShoulder(event); + const activateListener = () => controllerEventHandlersRef.current.onActivate(); + const secondaryActivateListener = () => controllerEventHandlersRef.current.onSecondaryActivate(); + const tertiaryActivateListener = () => controllerEventHandlersRef.current.onTertiaryActivate(); + const cancelListener = (event: Event) => controllerEventHandlersRef.current.onCancel(event); + const keyboardListener = (event: KeyboardEvent) => controllerEventHandlersRef.current.onKeyboard(event); + + window.addEventListener("opennow:controller-direction", directionListener); + window.addEventListener("opennow:controller-shoulder", shoulderListener); + window.addEventListener("opennow:controller-activate", activateListener); + window.addEventListener("opennow:controller-secondary-activate", secondaryActivateListener); + window.addEventListener("opennow:controller-tertiary-activate", tertiaryActivateListener); + window.addEventListener("opennow:controller-cancel", cancelListener); + window.addEventListener("keydown", keyboardListener); + return () => { + window.removeEventListener("opennow:controller-direction", directionListener); + window.removeEventListener("opennow:controller-shoulder", shoulderListener); + window.removeEventListener("opennow:controller-activate", activateListener); + window.removeEventListener("opennow:controller-secondary-activate", secondaryActivateListener); + window.removeEventListener("opennow:controller-tertiary-activate", tertiaryActivateListener); + window.removeEventListener("opennow:controller-cancel", cancelListener); + window.removeEventListener("keydown", keyboardListener); + }; + }, []); + const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { return controllerType === "ps" @@ -2565,18 +2664,52 @@ export function ControllerLibraryPage({ {editingThemeChannel === themeChannelForRow ? " • Editing" : ""} + ) : item.id === "streamMicLevel" && inStreamMenu ? ( +
+ + onStreamMenuMicLevelChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100))) + } + aria-label="Microphone level" + style={editingStreamMicLevel ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + + {`${Math.round((streamMenuMicLevel ?? 1) * 100)}%`} + {editingStreamMicLevel + ? " • Editing ←/→" + : controllerType === "ps" + ? " • □ to adjust" + : " • X to adjust"} + +
) : item.id === "streamVolume" && inStreamMenu ? ( - - {`${Math.round((streamMenuVolume ?? 1) * 100)}%`} - {editingStreamVolume - ? " • Editing ←/→" - : controllerType === "ps" - ? " • □ to adjust" - : " • X to adjust"} - +
+ + onStreamMenuVolumeChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100))) + } + aria-label="Stream volume" + style={editingStreamVolume ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + + {`${Math.round((streamMenuVolume ?? 1) * 100)}%`} + {editingStreamVolume + ? " • Editing ←/→" + : controllerType === "ps" + ? " • □ to adjust" + : " • X to adjust"} + +
) : ( {item.value} )} @@ -2603,8 +2736,12 @@ export function ControllerLibraryPage({ themeRgbForTrack.b, maxBitrateMbpsForTrack, inStreamMenu, + streamMenuMicLevel, + onStreamMenuMicLevelChange, streamMenuVolume, + onStreamMenuVolumeChange, editingStreamVolume, + editingStreamMicLevel, controllerType, ]); @@ -2804,6 +2941,9 @@ export function ControllerLibraryPage({ )) : categorizedGames.map((game, idx) => { const isActive = idx === selectedIndex; + const shouldRenderContent = isWithinContentWindow(idx, selectedIndex); + const shouldRenderImage = isWithinImageWindow(idx, selectedIndex); + const eagerLoadImage = Math.abs(idx - selectedIndex) <= 2; return (
- {favoriteGameIdSet.has(game.id) ? : null} -
- {game.imageUrl ? :
} -
+ {shouldRenderContent ? ( + <> + {favoriteGameIdSet.has(game.id) ? : null} +
+ {game.imageUrl && shouldRenderImage ? ( + + ) :
} +
+ + ) :
}
); })} @@ -3005,6 +3157,9 @@ export function ControllerLibraryPage({ {!mediaLoading && !mediaError && mediaAssetItems.map((item, idx) => { const isActive = idx === selectedMediaIndex; + const shouldRenderContent = isWithinContentWindow(idx, selectedMediaIndex); + const shouldRenderImage = isWithinImageWindow(idx, selectedMediaIndex); + const eagerLoadImage = Math.abs(idx - selectedMediaIndex) <= 1; const thumb = mediaThumbById[item.id]; const dateLabel = new Date(item.createdAtMs).toLocaleDateString(); const durationMs = item.durationMs ?? 0; @@ -3013,14 +3168,28 @@ export function ControllerLibraryPage({ return (
-
- {thumb ? :
} -
-
{item.gameTitle || item.fileName}
-
- {durationLabel} - {dateLabel} -
+ {shouldRenderContent ? ( + <> +
+ {thumb && shouldRenderImage ? ( + + ) :
} +
+
{item.gameTitle || item.fileName}
+
+ {durationLabel} + {dateLabel} +
+ + ) : ( +
+ )}
); })} diff --git a/opennow-stable/src/renderer/src/controllerNavigation.ts b/opennow-stable/src/renderer/src/controllerNavigation.ts index e09f8dfd5..4b4596143 100644 --- a/opennow-stable/src/renderer/src/controllerNavigation.ts +++ b/opennow-stable/src/renderer/src/controllerNavigation.ts @@ -11,6 +11,7 @@ interface UseControllerNavigationOptions { onActivateInput?: () => boolean; onSecondaryActivateInput?: () => boolean; onTertiaryActivateInput?: () => boolean; + onMetaToggleInput?: () => boolean; } const INTERACTIVE_SELECTOR = [ @@ -55,34 +56,81 @@ function isElementDisabled(el: HTMLElement): boolean { return false; } +let focusScopeCache: { version: number; root: ParentNode } | null = null; +let interactiveElementsCache: { version: number; root: ParentNode; items: HTMLElement[] } | null = null; +let controllerDomVersion = 0; +let activeControllerFocusEl: HTMLElement | null = null; +let activeRangeEditingEl: HTMLInputElement | null = null; + +function invalidateControllerDomCaches(): void { + controllerDomVersion += 1; + focusScopeCache = null; + interactiveElementsCache = null; +} + function getFocusScopeRoot(): ParentNode { + if (focusScopeCache && focusScopeCache.version === controllerDomVersion) { + return focusScopeCache.root; + } const overlay = document.querySelector(".controller-overlay"); - if (overlay) return overlay as ParentNode; + if (overlay) { + const root = overlay as ParentNode; + focusScopeCache = { version: controllerDomVersion, root }; + return root; + } const exitDialog = document.querySelector(".sv-exit"); - if (exitDialog) return exitDialog; + if (exitDialog) { + focusScopeCache = { version: controllerDomVersion, root: exitDialog }; + return exitDialog; + } const navbarModal = document.querySelector(".navbar-modal"); - if (navbarModal) return navbarModal; + if (navbarModal) { + focusScopeCache = { version: controllerDomVersion, root: navbarModal }; + return navbarModal; + } const loginDropdown = document.querySelector(".login-dropdown"); - if (loginDropdown?.parentElement) return loginDropdown.parentElement; + if (loginDropdown?.parentElement) { + focusScopeCache = { version: controllerDomVersion, root: loginDropdown.parentElement }; + return loginDropdown.parentElement; + } const regionDropdown = document.querySelector(".region-dropdown"); - if (regionDropdown?.parentElement) return regionDropdown.parentElement; + if (regionDropdown?.parentElement) { + focusScopeCache = { version: controllerDomVersion, root: regionDropdown.parentElement }; + return regionDropdown.parentElement; + } const streamLoading = document.querySelector(".sload"); - if (streamLoading) return streamLoading; + if (streamLoading) { + focusScopeCache = { version: controllerDomVersion, root: streamLoading }; + return streamLoading; + } + focusScopeCache = { version: controllerDomVersion, root: document }; return document; } function listInteractiveElements(): HTMLElement[] { const scopeRoot = getFocusScopeRoot(); + if ( + interactiveElementsCache + && interactiveElementsCache.version === controllerDomVersion + && interactiveElementsCache.root === scopeRoot + ) { + return interactiveElementsCache.items; + } const candidates = Array.from(scopeRoot.querySelectorAll(INTERACTIVE_SELECTOR)) .filter(isElementInteractive) .filter((el) => el.tabIndex >= 0) .filter((el) => !isElementDisabled(el) && isElementVisible(el)); + interactiveElementsCache = { + version: controllerDomVersion, + root: scopeRoot, + items: candidates, + }; return candidates; } @@ -95,10 +143,19 @@ function getElementCenter(el: HTMLElement): { x: number; y: number } { } function setControllerFocus(el: HTMLElement): void { + if (activeControllerFocusEl && activeControllerFocusEl !== el) { + activeControllerFocusEl.classList.remove("controller-focus"); + } + if (activeControllerFocusEl === el && el.classList.contains("controller-focus")) { + el.focus({ preventScroll: true }); + el.scrollIntoView({ block: "nearest", inline: "nearest" }); + return; + } document.querySelectorAll(".controller-focus").forEach((node) => { - node.classList.remove("controller-focus"); + if (node !== el) node.classList.remove("controller-focus"); }); el.classList.add("controller-focus"); + activeControllerFocusEl = el; el.focus({ preventScroll: true }); el.scrollIntoView({ block: "nearest", inline: "nearest" }); } @@ -127,12 +184,24 @@ function adjustRangeInput(input: HTMLInputElement, direction: Direction): boolea } function setRangeEditMode(input: HTMLInputElement | null): void { - document.querySelectorAll(".controller-range-editing").forEach((node) => { - node.classList.remove("controller-range-editing"); - }); + if (activeRangeEditingEl && activeRangeEditingEl !== input) { + activeRangeEditingEl.classList.remove("controller-range-editing"); + } if (input) { - input.classList.add("controller-range-editing"); + if (!input.classList.contains("controller-range-editing")) { + input.classList.add("controller-range-editing"); + } + activeRangeEditingEl = input; + return; } + if (activeRangeEditingEl) { + activeRangeEditingEl.classList.remove("controller-range-editing"); + } else { + document.querySelectorAll(".controller-range-editing").forEach((node) => { + node.classList.remove("controller-range-editing"); + }); + } + activeRangeEditingEl = null; } function moveFocus(direction: Direction): void { @@ -273,6 +342,7 @@ export function useControllerNavigation({ onActivateInput, onSecondaryActivateInput, onTertiaryActivateInput, + onMetaToggleInput, }: UseControllerNavigationOptions): boolean { const [controllerConnected, setControllerConnected] = useState(false); const connectedRef = useRef(false); @@ -292,6 +362,7 @@ export function useControllerNavigation({ b: false, lb: false, rb: false, + meta: false, }); useEffect(() => { @@ -323,7 +394,7 @@ export function useControllerNavigation({ state.pressed = false; state.repeatCount = 0; } - actionStateRef.current = { a: false, x: false, y: false, b: false, lb: false, rb: false }; + actionStateRef.current = { a: false, x: false, y: false, b: false, lb: false, rb: false, meta: false }; frameRef.current = window.requestAnimationFrame(tick); return; } @@ -340,6 +411,7 @@ export function useControllerNavigation({ const b = Boolean(pad.buttons[1]?.pressed); const lb = Boolean(pad.buttons[4]?.pressed); const rb = Boolean(pad.buttons[5]?.pressed); + const meta = Boolean(pad.buttons[16]?.pressed); const handleDirection = (direction: Direction, pressed: boolean): void => { const state = directionStateRef.current[direction]; @@ -379,7 +451,7 @@ export function useControllerNavigation({ if (a && !actionStateRef.current.a) { if (onActivateInput?.()) { - actionStateRef.current = { a, x, y, b, lb, rb }; + actionStateRef.current = { a, x, y, b, lb, rb, meta }; frameRef.current = window.requestAnimationFrame(tick); return; } @@ -400,8 +472,11 @@ export function useControllerNavigation({ if (rb && !actionStateRef.current.rb) { onNavigatePage?.("next"); } + if (meta && !actionStateRef.current.meta) { + onMetaToggleInput?.(); + } - actionStateRef.current = { a, x, y, b, lb, rb }; + actionStateRef.current = { a, x, y, b, lb, rb, meta }; frameRef.current = window.requestAnimationFrame(tick); }; @@ -410,12 +485,30 @@ export function useControllerNavigation({ if (frameRef.current !== null) { window.cancelAnimationFrame(frameRef.current); } + activeControllerFocusEl = null; + activeRangeEditingEl = null; setRangeEditMode(null); document.querySelectorAll(".controller-focus").forEach((node) => { node.classList.remove("controller-focus"); }); }; - }, [enabled, onActivateInput, onBackAction, onDirectionInput, onNavigatePage, onSecondaryActivateInput, onTertiaryActivateInput]); + }, [enabled, onActivateInput, onBackAction, onDirectionInput, onMetaToggleInput, onNavigatePage, onSecondaryActivateInput, onTertiaryActivateInput]); + + useEffect(() => { + if (!enabled) return; + const observer = new MutationObserver(() => { + invalidateControllerDomCaches(); + }); + observer.observe(document.body, { subtree: true, childList: true, attributes: true }); + const invalidate = () => invalidateControllerDomCaches(); + window.addEventListener("resize", invalidate); + window.addEventListener("scroll", invalidate, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", invalidate); + window.removeEventListener("scroll", invalidate, true); + }; + }, [enabled]); return controllerConnected; } diff --git a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts index 7d6cba41f..7131beb38 100644 --- a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts +++ b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts @@ -25,6 +25,7 @@ export class MicrophoneManager { private pc: RTCPeerConnection | null = null; private micSender: RTCRtpSender | null = null; private deviceId: string = ""; + private micLevel = 1; private onStateChangeCallback: ((state: MicStateChange) => void) | null = null; private sampleRate: number = 48000; // Official client uses 48kHz @@ -198,6 +199,7 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; + await this.applyTrackMicLevel(track); // Add track to peer connection if available if (this.pc) { @@ -267,6 +269,7 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; + await this.applyTrackMicLevel(track); if (this.pc && track) { await this.addTrackToPeerConnection(track); } @@ -435,6 +438,17 @@ export class MicrophoneManager { } } + setMicLevel(level01: number): void { + this.micLevel = Math.max(0, Math.min(1, Number.isFinite(level01) ? level01 : 1)); + const track = this.micStream?.getAudioTracks()[0] ?? null; + if (!track) return; + void this.applyTrackMicLevel(track); + } + + getMicLevel(): number { + return this.micLevel; + } + /** * Check if microphone is currently enabled (unmuted) */ @@ -491,6 +505,25 @@ export class MicrophoneManager { return this.micStream?.getAudioTracks()[0] ?? null; } + private async applyTrackMicLevel(track: MediaStreamTrack): Promise { + const applyConstraints = (track as MediaStreamTrack & { + applyConstraints?: (constraints?: MediaTrackConstraints) => Promise; + }).applyConstraints; + if (typeof applyConstraints !== "function") { + return; + } + try { + const volumeConstraints = { + advanced: [{ volume: this.micLevel }], + } as unknown as MediaTrackConstraints; + await applyConstraints.call(track, { + ...volumeConstraints, + }); + } catch { + // Ignore unsupported volume constraints on some browsers/devices. + } + } + /** * Update state and notify callback */ diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index a405fefa0..076dba36e 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -183,6 +183,10 @@ interface ClientOptions { onStats?: (stats: StreamDiagnostics) => void; onTimeWarning?: (warning: StreamTimeWarning) => void; onMicStateChange?: (state: MicStateChange) => void; + onIceConnectionStateChange?: (state: RTCIceConnectionState) => void; + onPeerConnectionStateChange?: (state: RTCPeerConnectionState) => void; + /** Optional host callback for Meta/Home button edge presses (button 16). */ + onControllerMetaPress?: (event: { controllerId: number; gamepad: Gamepad }) => void; } function timestampUs(sourceTimestampMs?: number): bigint { @@ -600,6 +604,8 @@ export class GfnWebRtcClient { } | null = null; private renderFpsCounter = { frames: 0, lastUpdate: 0, fps: 0 }; private connectedGamepads: Set = new Set(); + private gamepadMetaPressed: Map = new Map(); + private lastEmittedDiagnostics: StreamDiagnostics | null = null; private previousGamepadStates: Map = new Map(); private lastRumbleWeak: number[] = [0, 0, 0, 0]; private lastRumbleStrong: number[] = [0, 0, 0, 0]; @@ -926,10 +932,25 @@ export class GfnWebRtcClient { this.options.onLog(message); } - private emitStats(): void { - if (this.options.onStats) { - this.options.onStats({ ...this.diagnostics }); + private diagnosticsChangedSinceLastEmit(): boolean { + if (!this.lastEmittedDiagnostics) return true; + const current = this.diagnostics as unknown as Record; + const previous = this.lastEmittedDiagnostics as unknown as Record; + const keys = Object.keys(current); + for (const key of keys) { + if (!Object.is(current[key], previous[key])) { + return true; + } } + return false; + } + + private emitStats(force = false): void { + if (!this.options.onStats) return; + if (!force && !this.diagnosticsChangedSinceLastEmit()) return; + const snapshot = { ...this.diagnostics }; + this.lastEmittedDiagnostics = snapshot; + this.options.onStats(snapshot); } private resetDecoderRecoveryState(): void { @@ -951,6 +972,7 @@ export class GfnWebRtcClient { private resetDiagnostics(): void { this.lastStatsSample = null; + this.lastEmittedDiagnostics = null; this.currentCodec = ""; this.currentResolution = ""; this.isHdr = false; @@ -1912,7 +1934,7 @@ export class GfnWebRtcClient { } private pollGamepads(): void { - if (this.isStreamInputBlocked()) return; + const streamInputBlocked = this.isStreamInputBlocked(); const gamepads = navigator.getGamepads(); if (!gamepads) { return; @@ -1927,6 +1949,16 @@ export class GfnWebRtcClient { if (gamepad && gamepad.connected) { connectedCount++; this.updateGamepadBitmap(i, gamepad); + const metaPressed = Boolean(gamepad.buttons[16]?.pressed); + const prevMetaPressed = this.gamepadMetaPressed.get(i) ?? false; + if (metaPressed && !prevMetaPressed) { + try { + this.options.onControllerMetaPress?.({ controllerId: i, gamepad }); + } catch { + // Host callbacks must never break stream input polling. + } + } + this.gamepadMetaPressed.set(i, metaPressed); // Track connected gamepads and update bitmap if (!this.connectedGamepads.has(i)) { @@ -1939,6 +1971,9 @@ export class GfnWebRtcClient { } // Read and encode gamepad state + if (streamInputBlocked) { + continue; + } const gamepadInput = this.readGamepadState(gamepad, i); const stateChanged = this.hasGamepadStateChanged(i, gamepadInput); @@ -1974,6 +2009,7 @@ export class GfnWebRtcClient { // Gamepad disconnected — clear bit from bitmap this.stopGamepadRumble(i, gamepad ?? undefined); this.connectedGamepads.delete(i); + this.gamepadMetaPressed.delete(i); this.previousGamepadStates.delete(i); this.clearGamepadBitmap(i); this.log(`Gamepad ${i} disconnected, bitmap now: 0x${this.gamepadBitmap.toString(16)}`); @@ -3904,6 +3940,7 @@ export class GfnWebRtcClient { this.diagnostics.connectionState = pc.connectionState; this.emitStats(); this.log(`Peer connection state: ${pc.connectionState}`); + this.options.onPeerConnectionStateChange?.(pc.connectionState); }; pc.ondatachannel = (event) => { @@ -3939,6 +3976,7 @@ export class GfnWebRtcClient { pc.oniceconnectionstatechange = () => { this.log(`ICE connection state: ${pc.iceConnectionState}`); + this.options.onIceConnectionStateChange?.(pc.iceConnectionState); }; pc.onicegatheringstatechange = () => { @@ -4252,6 +4290,15 @@ export class GfnWebRtcClient { this.log(`Microphone ${enabled ? "enabled" : "disabled"}`); } + setMicrophoneLevel(level01: number): void { + if (!this.micManager) return; + this.micManager.setMicLevel(level01); + } + + getMicrophoneLevel(): number { + return this.micManager?.getMicLevel() ?? 1; + } + /** * Check if microphone is currently enabled (unmuted) */ diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index f5862ca9f..049e1c5c2 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1507,6 +1507,26 @@ body.controller-mode.controller-hide-cursor * { transform-origin: top left !important; } +/* Lower-cost compositor path while in-stream overlay is open. */ +body.controller-overlay-lowfx .controller-overlay--in-stream { + background: rgba(5, 10, 14, 0.5) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +body.controller-overlay-lowfx .controller-overlay--in-stream .xmb-bg-orb, +body.controller-overlay-lowfx .controller-overlay--in-stream .xmb-hero::before, +body.controller-overlay-lowfx .controller-overlay--in-stream .xmb-hero::after { + animation: none !important; + filter: none !important; +} + +body.controller-overlay-lowfx .controller-overlay--in-stream .xmb-frost, +body.controller-overlay-lowfx .controller-overlay--in-stream .xmb-top-bar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + .cis-warning { flex-shrink: 0; padding: 8px 14px; @@ -8103,6 +8123,14 @@ button.game-card-store-chip.owned.active:hover { background: linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.35)); } +.xmb-ps5-tile-frame--virtualized { + width: 100%; + height: 100%; + min-height: 160px; + opacity: 0.35; + box-shadow: none; +} + .xmb-ps5-tile-cover--placeholder { width: 100%; height: 100%; @@ -8265,6 +8293,11 @@ button.game-card-store-chip.owned.active:hover { repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.05) 0 8px, rgba(255, 255, 255, 0.01) 8px 16px); } +.xmb-ps5-media-frame--virtualized { + opacity: 0.42; + box-shadow: none; +} + .xmb-ps5-media-caption { margin-top: 10px; min-height: 2.75em; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 27ba7215c..737b26845 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -673,8 +673,12 @@ export interface SessionClaimRequest { streamingBaseUrl?: string; sessionId: string; serverIp: string; + clientId?: string; + deviceId?: string; appId?: string; settings?: StreamSettings; + /** True when claim is triggered by automatic reconnect recovery logic */ + recoveryMode?: boolean; } export interface SignalingConnectRequest {