diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 30153590b..f3a0cab17 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -1,7 +1,17 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode, GameLanguage, AspectRatio, KeyboardLayout } from "@shared/gfn"; +import type { + VideoCodec, + ColorQuality, + VideoAccelerationPreference, + MicrophoneMode, + GameLanguage, + AspectRatio, + KeyboardLayout, + ControllerThemeRgb, + ControllerThemeStyle, +} from "@shared/gfn"; import { DEFAULT_KEYBOARD_LAYOUT, getDefaultStreamPreferences, normalizeStreamPreferences } from "@shared/gfn"; export interface Settings { @@ -69,6 +79,10 @@ export interface Settings { controllerUiSounds: boolean; /** Enable animated background visuals for controller-mode loading screens */ controllerBackgroundAnimations: boolean; + /** Controller-mode library background visual preset */ + controllerThemeStyle: ControllerThemeStyle; + /** Controller-mode library background tint */ + controllerThemeColor: ControllerThemeRgb; /** Auto-load controller library at startup when controller mode is enabled */ autoLoadControllerLibrary: boolean; /** Automatically enter fullscreen when controller-mode triggers it */ @@ -103,6 +117,28 @@ const LEGACY_STOP_SHORTCUTS = new Set(["META+SHIFT+Q", "CMD+SHIFT+Q"]); const LEGACY_ANTI_AFK_SHORTCUTS = new Set(["META+SHIFT+F10", "CMD+SHIFT+F10", "CTRL+SHIFT+F10"]); const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); +const CONTROLLER_THEME_STYLES_SET = new Set(["aurora", "nebula", "grid", "minimal", "pulse"]); + +function clampThemeByte(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : NaN; + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(255, n)); +} + +function normalizeControllerThemeColor(raw: unknown, fallback: ControllerThemeRgb): ControllerThemeRgb { + if (!raw || typeof raw !== "object") return { ...fallback }; + const o = raw as Record; + return { + r: clampThemeByte(o.r), + g: clampThemeByte(o.g), + b: clampThemeByte(o.b), + }; +} + +function normalizeControllerThemeStyle(raw: unknown): ControllerThemeStyle { + return CONTROLLER_THEME_STYLES_SET.has(raw as ControllerThemeStyle) ? (raw as ControllerThemeStyle) : "aurora"; +} + const DEFAULT_SETTINGS: Settings = { resolution: "1920x1080", aspectRatio: "16:9", @@ -134,6 +170,8 @@ const DEFAULT_SETTINGS: Settings = { controllerMode: false, controllerUiSounds: false, controllerBackgroundAnimations: false, + controllerThemeStyle: "aurora", + controllerThemeColor: { r: 124, g: 241, b: 177 }, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], @@ -183,6 +221,19 @@ export class SettingsManager { let migrated = this.migrateLegacyShortcutDefaults(merged); migrated = this.enforceCompatibility(merged) || migrated; + const themeStyleBefore = merged.controllerThemeStyle; + const themeColorBefore = { ...merged.controllerThemeColor }; + merged.controllerThemeStyle = normalizeControllerThemeStyle(merged.controllerThemeStyle); + merged.controllerThemeColor = normalizeControllerThemeColor(merged.controllerThemeColor, DEFAULT_SETTINGS.controllerThemeColor); + if ( + merged.controllerThemeStyle !== themeStyleBefore || + merged.controllerThemeColor.r !== themeColorBefore.r || + merged.controllerThemeColor.g !== themeColorBefore.g || + merged.controllerThemeColor.b !== themeColorBefore.b + ) { + migrated = true; + } + // Migrate legacy boolean accelerator setting to percentage slider. if (typeof (parsed as { mouseAcceleration?: unknown }).mouseAcceleration === "boolean") { merged.mouseAcceleration = (parsed as { mouseAcceleration?: boolean }).mouseAcceleration ? 100 : 1; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 60cf80088..04fa22ee9 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -48,7 +48,8 @@ import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./ import { useControllerNavigation } from "./controllerNavigation"; import { useElapsedSeconds } from "./utils/useElapsedSeconds"; import { usePlaytime } from "./utils/usePlaytime"; -import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore"; +import { createStreamDiagnosticsStore, useStreamDiagnosticsSelector } from "./utils/streamDiagnosticsStore"; +import { playControllerUiSound } from "./utils/controllerUiSound"; import { loadStoredCodecResults, saveStoredCodecResults, testCodecSupport, type CodecTestResult } from "./lib/codecDiagnostics"; import { chooseAccountLinked, getEpicOwnershipLaunchError } from "./lib/launchOwnership"; @@ -58,6 +59,7 @@ import { Navbar } from "./components/Navbar"; import { HomePage } from "./components/HomePage"; import { LibraryPage } from "./components/LibraryPage"; import { ControllerLibraryPage } from "./components/ControllerLibraryPage"; +import { ControllerInStreamShell } from "./components/controllerInStream/ControllerInStreamShell"; import { SettingsPage } from "./components/SettingsPage"; import { StreamLoading } from "./components/StreamLoading"; import { ControllerStreamLoading } from "./components/ControllerStreamLoading"; @@ -447,29 +449,6 @@ function warningMessage(code: StreamTimeWarning["code"]): string { return "Maximum session time approaching"; } -function formatRemainingPlaytimeFromSubscription( - subscription: SubscriptionInfo | null, - consumedHours = 0, -): string { - if (!subscription) { - return "--"; - } - if (subscription.isUnlimited) { - return "Unlimited"; - } - - const baseHours = Number.isFinite(subscription.remainingHours) ? subscription.remainingHours : 0; - const safeHours = Math.max(0, baseHours - Math.max(0, consumedHours)); - const totalMinutes = Math.round(safeHours * 60); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - if (hours > 0) { - return `${hours}h ${minutes.toString().padStart(2, "0")}m`; - } - return `${minutes}m`; -} - function normalizeMembershipTier(tier: string | null | undefined): string | null { if (!tier) { return null; @@ -845,6 +824,8 @@ export function App(): JSX.Element { controllerMode: false, controllerUiSounds: false, controllerBackgroundAnimations: false, + controllerThemeStyle: "aurora", + controllerThemeColor: { r: 124, g: 241, b: 177 }, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], @@ -868,6 +849,7 @@ export function App(): JSX.Element { const diagnosticsStoreRef = useRef | null>(null); const diagnosticsStore = diagnosticsStoreRef.current ?? (diagnosticsStoreRef.current = createStreamDiagnosticsStore(defaultDiagnostics())); + const streamMenuMicOn = useStreamDiagnosticsSelector(diagnosticsStore, (d) => d.micEnabled); // Stream State const [session, setSession] = useState(null); @@ -1078,6 +1060,7 @@ export function App(): JSX.Element { }, [authSession, currentPage, settings.controllerMode, streamStatus]); const [controllerOverlayOpen, setControllerOverlayOpen] = useState(false); + const [streamVolume, setStreamVolume] = useState(1); const [isSwitchingGame, setIsSwitchingGame] = useState(false); const [switchingPhase, setSwitchingPhase] = useState(null); const [pendingSwitchGameTitle, setPendingSwitchGameTitle] = useState(null); @@ -1123,7 +1106,21 @@ export function App(): JSX.Element { // Meta/Home button only: button 16 (standard) const metaPressed = Boolean(pad.buttons[16]?.pressed); if (metaPressed && !prev.pressed) { - setControllerOverlayOpen((v) => !v); + 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 { @@ -1133,16 +1130,24 @@ export function App(): JSX.Element { }; raf = window.requestAnimationFrame(tick); return () => { if (raf) window.cancelAnimationFrame(raf); }; - }, [streamStatus]); + }, [streamStatus, settings.controllerUiSounds]); // Refs const videoRef = useRef(null); const audioRef = useRef(null); const clientRef = useRef(null); + + useEffect(() => { + if (streamStatus === "streaming" && audioRef.current) { + setStreamVolume(audioRef.current.volume); + } + }, [streamStatus]); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); + /** 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 signalingRecoveryRef = useRef({ @@ -1172,6 +1177,7 @@ export function App(): JSX.Element { useEffect(() => { controllerOverlayOpenRef.current = controllerOverlayOpen; + // Host-only pause: WebRTC client merges this with window blur/hidden (must not be cleared on focus). if (clientRef.current) { clientRef.current.inputPaused = controllerOverlayOpen; } @@ -2179,6 +2185,16 @@ export function App(): JSX.Element { } }, [settingsLoaded]); + const handleStreamVolumeChange = useCallback((v: number) => { + const n = Math.max(0, Math.min(1, v)); + setStreamVolume(n); + if (audioRef.current) audioRef.current.volume = n; + }, []); + + const handleToggleStreamMicrophone = useCallback(() => { + clientRef.current?.toggleMicrophone(); + }, []); + const handleMouseSensitivityChange = useCallback((value: number) => { void updateSetting("mouseSensitivity", value); }, [updateSetting]); @@ -2603,6 +2619,19 @@ export function App(): JSX.Element { return; } + // Mirror attemptSessionRecovery: tear down WebRTC + signaling before connecting to a new edge. + // Avoids stale PeerConnection/video vs migrated CloudMatch connectionInfo (intermittent black screen on resume). + const reconnectSource = expectedRecoveryGeneration !== undefined ? "recovery" : "resume"; + console.log(`[Stream] ${reconnectSource}: teardown WebRTC + signaling before reconnect`, { + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + mediaConnectionInfo: claimed.mediaConnectionInfo, + }); + clientRef.current?.dispose(); + clientRef.current = null; + await window.openNow.disconnectSignaling().catch(() => {}); + setSession(claimed); sessionRef.current = claimed; setQueuePosition(undefined); @@ -2616,42 +2645,71 @@ export function App(): JSX.Element { }, [isRecoveryGenerationCurrent]); const claimAndConnectSession = useCallback(async (existingSession: ActiveSessionInfo): Promise => { - const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; - if (!token) { - throw new Error("Missing token for session resume"); - } - if (!existingSession.serverIp) { - throw new Error("Active session is missing server address. Start the game again to create a new session."); + const sid = existingSession.sessionId; + const inflight = claimResumePromisesRef.current.get(sid); + if (inflight) { + console.log("[Resume] claimAndConnectSession: deduped — joining in-flight claim for session", sid); + await inflight; + return; } - const matchedContext = findGameContextForSession(existingSession); - if (matchedContext) { - setStreamingGame(matchedContext.game); - setStreamingStore(matchedContext.variant?.store ?? null); - } else { - setStreamingStore(null); - } + const resumePromiseHolder: { promise?: Promise } = {}; + resumePromiseHolder.promise = (async (): Promise => { + try { + const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; + if (!token) { + throw new Error("Missing token for session resume"); + } + if (!existingSession.serverIp) { + throw new Error("Active session is missing server address. Start the game again to create a new session."); + } - const claimed = await window.openNow.claimSession({ - token, - streamingBaseUrl: effectiveStreamingBaseUrl, - serverIp: existingSession.serverIp, - sessionId: existingSession.sessionId, - appId: resolveSessionClaimAppId(existingSession), - settings: { - resolution: settings.resolution, - fps: settings.fps, - maxBitrateMbps: settings.maxBitrateMbps, - codec: settings.codec, - colorQuality: settings.colorQuality, - keyboardLayout: settings.keyboardLayout, - gameLanguage: settings.gameLanguage, - enableL4S: settings.enableL4S, - enableCloudGsync: settings.enableCloudGsync, - }, - }); + console.log("[Resume] claimAndConnectSession: invoking claimSession", { + sessionId: existingSession.sessionId, + serverIp: existingSession.serverIp, + status: existingSession.status, + appId: existingSession.appId, + }); - await applyClaimedSessionAndConnect(claimed); + const matchedContext = findGameContextForSession(existingSession); + if (matchedContext) { + setStreamingGame(matchedContext.game); + setStreamingStore(matchedContext.variant?.store ?? null); + } else { + setStreamingStore(null); + } + + const claimed = await window.openNow.claimSession({ + token, + streamingBaseUrl: effectiveStreamingBaseUrl, + serverIp: existingSession.serverIp, + sessionId: existingSession.sessionId, + appId: resolveSessionClaimAppId(existingSession), + settings: { + resolution: settings.resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + keyboardLayout: settings.keyboardLayout, + gameLanguage: settings.gameLanguage, + enableL4S: settings.enableL4S, + enableCloudGsync: settings.enableCloudGsync, + }, + }); + + await applyClaimedSessionAndConnect(claimed); + } finally { + const map = claimResumePromisesRef.current; + const p = resumePromiseHolder.promise; + if (p && map.get(sid) === p) { + map.delete(sid); + } + } + })(); + + claimResumePromisesRef.current.set(sid, resumePromiseHolder.promise); + await resumePromiseHolder.promise; }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]); const attemptSessionRecovery = useCallback(async (reason: string): Promise => { @@ -2863,6 +2921,7 @@ export function App(): JSX.Element { console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); }, }); + clientRef.current.inputPaused = controllerOverlayOpenRef.current; if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); } @@ -2879,6 +2938,13 @@ export function App(): JSX.Element { setLaunchError(null); setStreamStatus("streaming"); resetSignalingRecoveryState({ keepExplicitShutdown: true }); + console.log( + "[Stream] Offer applied; use [WebRTC] logs for ICE/video dimensions. signalingServer=%s media=%s", + activeSession.signalingServer, + activeSession.mediaConnectionInfo + ? `${activeSession.mediaConnectionInfo.ip}:${activeSession.mediaConnectionInfo.port}` + : "n/a", + ); } } else if (event.type === "remote-ice") { await clientRef.current?.addRemoteCandidate(event.candidate); @@ -3857,6 +3923,24 @@ export function App(): JSX.Element { return null; }, [gameTitleByAppId, navbarActiveSession, session?.sessionId, streamingGame?.title]); + const controllerCloudResumeCoverUrl = useMemo(() => { + if (!navbarActiveSession) return null; + const ctx = findGameContextForSession(navbarActiveSession); + return ctx?.game?.imageUrl ?? null; + }, [findGameContextForSession, navbarActiveSession]); + + const controllerCloudSessionResumable = useMemo( + () => + Boolean( + streamStatus === "idle" + && selectedProvider + && navbarActiveSession?.serverIp + && !isResumingNavbarSession + && !isTerminatingNavbarSession + ), + [isResumingNavbarSession, isTerminatingNavbarSession, navbarActiveSession, selectedProvider, streamStatus], + ); + const effectiveAdState = getEffectiveAdState(session, subscriptionInfo, authSession); const activeQueueAd = useMemo( () => getActiveQueueAd(effectiveAdState, activeQueueAdId), @@ -3925,17 +4009,11 @@ export function App(): JSX.Element { const showControllerLaunchLoading = !isSwitchingGame && settings.controllerMode - && (showLaunchErrorOverlay || (streamStatus !== "idle" && streamStatus !== "streaming" && streamStatus !== "connecting")); + && (showLaunchErrorOverlay || (streamStatus !== "idle" && streamStatus !== "streaming")); const showDesktopLaunchLoading = !isSwitchingGame && !settings.controllerMode && (showLaunchErrorOverlay || (streamStatus !== "idle" && streamStatus !== "streaming")); - const consumedHours = - streamStatus === "streaming" - ? Math.floor(sessionElapsedSeconds / 60) / 60 - : 0; - const remainingPlaytimeText = formatRemainingPlaytimeFromSubscription(subscriptionInfo, consumedHours); - // Show stream lifecycle (waiting/connecting/streaming/failure) if (showLaunchOverlay) { const loadingStatus = launchError ? launchError.stage : toLoadingStatus(streamStatus); @@ -4004,14 +4082,13 @@ export function App(): JSX.Element { void releasePointerLockIfNeeded(); }} allowEscapeToExitFullscreen={settings.allowEscapeToExitFullscreen} + hideConnectingOverlay={settings.controllerMode} /> )} - {isSwitchingGame && settings.controllerMode && streamStatus !== "connecting" && ( + {isSwitchingGame && settings.controllerMode && ( )} {isSwitchingGame && !settings.controllerMode && ( @@ -4063,7 +4137,14 @@ export function App(): JSX.Element { /> )} {streamStatus === "streaming" && controllerOverlayOpen && ( -
+ setCurrentPage("settings")} currentStreamingGame={streamingGame} onResumeGame={() => setControllerOverlayOpen(false)} + inStreamMenu + streamMenuVolume={streamVolume} + onStreamMenuVolumeChange={handleStreamVolumeChange} + onStreamMenuToggleMicrophone={handleToggleStreamMicrophone} + onStreamMenuToggleFullscreen={() => { + void toggleSessionFullscreen(); + }} + streamMenuMicOn={streamMenuMicOn} + streamMenuIsFullscreen={sessionFullscreen || !!document.fullscreenElement} + cloudSessionResumable={controllerCloudSessionResumable} + cloudResumeTitle={activeSessionGameTitle} + cloudResumeCoverUrl={controllerCloudResumeCoverUrl} + onResumeCloudSession={() => { + void handleResumeFromNavbar(); + }} + cloudResumeBusy={isResumingNavbarSession} onCloseGame={async () => { setControllerOverlayOpen(false); - // allow overlay close animation to play await sleep(300); await releasePointerLockIfNeeded(); await handleStopStream(); @@ -4102,6 +4198,8 @@ export function App(): JSX.Element { microphoneDeviceId: settings.microphoneDeviceId, controllerUiSounds: settings.controllerUiSounds, controllerBackgroundAnimations: settings.controllerBackgroundAnimations, + controllerThemeStyle: settings.controllerThemeStyle, + controllerThemeColor: settings.controllerThemeColor, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, @@ -4114,13 +4212,11 @@ export function App(): JSX.Element { aspectRatioOptions={aspectRatioOptions as unknown as string[]} onSettingChange={updateSetting} /> -
+ )} {showControllerLaunchLoading && ( )} {showDesktopLaunchLoading && ( @@ -4258,6 +4351,13 @@ export function App(): JSX.Element { onOpenSettings={() => setCurrentPage("settings")} currentStreamingGame={streamingGame} onResumeGame={handlePlayGame} + cloudSessionResumable={controllerCloudSessionResumable} + cloudResumeTitle={activeSessionGameTitle} + cloudResumeCoverUrl={controllerCloudResumeCoverUrl} + onResumeCloudSession={() => { + void handleResumeFromNavbar(); + }} + cloudResumeBusy={isResumingNavbarSession} onCloseGame={handlePromptedStopStream} pendingSwitchGameCover={pendingSwitchGameCover} userName={authSession?.user.displayName} @@ -4276,6 +4376,8 @@ export function App(): JSX.Element { microphoneDeviceId: settings.microphoneDeviceId, controllerUiSounds: settings.controllerUiSounds, controllerBackgroundAnimations: settings.controllerBackgroundAnimations, + controllerThemeStyle: settings.controllerThemeStyle, + controllerThemeColor: settings.controllerThemeColor, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, diff --git a/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx b/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx new file mode 100644 index 000000000..f7109fc70 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx @@ -0,0 +1,168 @@ +import type { JSX } from "react"; +import type { GameInfo } from "@shared/gfn"; +import { Clock, Calendar, Repeat2 } from "lucide-react"; +import { getStoreDisplayName } from "./GameCard"; +import { formatPlaytime, formatLastPlayed, type PlaytimeStore } from "../utils/usePlaytime"; + +export type GameHubTile = { + id: string; + title: string; + subtitle: string; + disabled?: boolean; +}; + +function sanitizeGenreName(raw: string): string { + return raw + .replace(/_/g, " ") + .replace(/\b\w/g, (ch) => ch.toUpperCase()); +} + +export interface ControllerGameHubProps { + game: GameInfo; + /** Local captures for this title (newest first); hub prefers these over poster art */ + screenshotUrls: string[]; + playtimeData: PlaytimeStore; + selectedVariantId: string; + currentStreamingGame: GameInfo | null | undefined; + librarySortLabel?: string | null; + tiles: GameHubTile[]; + focusIndex: number; + /** In-stream overlay: copy emphasizes switching away from the active title */ + inStreamMenu?: boolean; +} + +export function ControllerGameHub({ + game, + screenshotUrls, + playtimeData, + selectedVariantId, + currentStreamingGame, + librarySortLabel, + tiles, + focusIndex, + inStreamMenu = false, +}: ControllerGameHubProps): JSX.Element { + const record = playtimeData[game.id]; + const totalSecs = record?.totalSeconds ?? 0; + const lastPlayedAt = record?.lastPlayedAt ?? null; + const sessionCount = record?.sessionCount ?? 0; + const playtimeLabel = formatPlaytime(totalSecs); + const lastPlayedLabel = formatLastPlayed(lastPlayedAt); + const variant = game.variants.find((v) => v.id === selectedVariantId) || game.variants[0]; + const storeName = getStoreDisplayName(variant?.store || ""); + const genres = game.genres?.slice(0, 4) ?? []; + const tierLabel = game.membershipTierLabel; + const description = + game.longDescription?.trim() || game.description?.trim() || `${game.title} is ready to launch from your library.`; + + const primaryVisualUrl = + screenshotUrls[0] ?? game.screenshotUrl ?? game.imageUrl ?? null; + const heroBackdropUrl = primaryVisualUrl; + + const safeFocus = Math.max(0, Math.min(tiles.length - 1, focusIndex)); + const extraShots = screenshotUrls.slice(1, 8); + + return ( +
+
+ {heroBackdropUrl ? ( +
+ ) : null} +
+
+ +
+ {primaryVisualUrl ? ( +
+
+ +
+ {extraShots.length > 0 ? ( +
+ {extraShots.map((src, i) => ( + + ))} +
+ ) : null} +
+ ) : null} + +
+

{game.title}

+
+ {librarySortLabel ? ( + Sort: {librarySortLabel} + ) : null} + {storeName ? {storeName} : null} + + + {playtimeLabel} + + + + {lastPlayedLabel} + + {sessionCount > 0 ? ( + + + {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + + ) : null} + {genres.map((g) => ( + + {sanitizeGenreName(g)} + + ))} + {tierLabel ? {tierLabel} : null} +
+
+ +

{description}

+ +
+ {tiles.map((tile, idx) => { + const active = idx === safeFocus; + const primary = tile.id === "play"; + return ( +
+
+ {tile.title} + {tile.subtitle} +
+
+ ); + })} +
+ +
+ {currentStreamingGame && currentStreamingGame.id !== game.id ? ( + + {inStreamMenu + ? `In-stream: leaving ${currentStreamingGame.title} switches to ${game.title}` + : `Streaming another title — Play switches to ${game.title}`} + + ) : ( + Select an action above + )} +
+
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 67a8eb2c6..3afd22e88 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -1,11 +1,19 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; -import type { GameInfo, MediaListingEntry, Settings } from "@shared/gfn"; -import { Star, Clock, Calendar, Repeat2 } from "lucide-react"; +import type { GameInfo, MediaListingEntry, Settings, ControllerThemeStyle } from "@shared/gfn"; +import { Star, Clock, Calendar, Repeat2, House, Settings as SettingsIcon, Library, Clapperboard } from "lucide-react"; import { ButtonA, ButtonB, ButtonX, ButtonY, ButtonPSCross, ButtonPSCircle, ButtonPSSquare, ButtonPSTriangle } from "./ControllerButtons"; import { getStoreDisplayName } from "./GameCard"; import { SessionElapsedIndicator, RemainingPlaytimeIndicator, CurrentClock } from "./ElapsedSessionIndicators"; +import { ControllerGameHub } from "./ControllerGameHub"; +import { Ps5LoadingScreen } from "./Ps5LoadingScreen"; import { type PlaytimeStore, formatPlaytime, formatLastPlayed } from "../utils/usePlaytime"; +import { playControllerUiSound } from "../utils/controllerUiSound"; +import { + type ControllerOverlayNavSnapshot, + readControllerOverlayNav, + writeControllerOverlayNav, +} from "../utils/controllerOverlayNavStorage"; interface ControllerLibraryPageProps { games: GameInfo[]; @@ -42,6 +50,8 @@ interface ControllerLibraryPageProps { aspectRatio?: string; posterSizeScale?: number; maxBitrateMbps?: number; + controllerThemeStyle?: ControllerThemeStyle; + controllerThemeColor?: { r: number; g: number; b: number }; }; resolutionOptions?: string[]; fpsOptions?: number[]; @@ -52,16 +62,106 @@ interface ControllerLibraryPageProps { sessionStartedAtMs?: number | null; isStreaming?: boolean; sessionCounterEnabled?: boolean; + /** In-stream Meta/Home overlay: extra confirm for end session, nav restore, etc. */ + inStreamMenu?: boolean; + /** In-stream: gamepad-friendly stream actions (see Current row). */ + streamMenuVolume?: number; + onStreamMenuVolumeChange?: (volume01: number) => void; + onStreamMenuToggleMicrophone?: () => void; + onStreamMenuToggleFullscreen?: () => void; + streamMenuMicOn?: boolean; + streamMenuIsFullscreen?: boolean; + /** When a cloud session can be continued (server ready, app idle), show a PS5-style resume tile in the Games spotlight row. */ + cloudSessionResumable?: boolean; + cloudResumeTitle?: string | null; + cloudResumeCoverUrl?: string | null; + onResumeCloudSession?: () => void; + cloudResumeBusy?: boolean; } type Direction = "up" | "down" | "left" | "right"; -type TopCategory = "current" | "all" | "settings" | "media" | "favorites" | `genre:${string}`; +type TopCategory = "current" | "all" | "settings" | "media"; type SoundKind = "move" | "confirm"; -type SettingsSubcategory = "root" | "Network" | "Audio" | "Video" | "System"; +type SettingsSubcategory = "root" | "Network" | "Audio" | "Video" | "System" | "Theme" | "ThemeColor" | "ThemeStyle"; type MediaSubcategory = "root" | "Videos" | "Screenshots"; +type GameSubcategory = "root" | "all" | "favorites" | `genre:${string}`; +type LibrarySortId = "recent" | "az" | "za" | "favoritesFirst"; + +/** Captured when opening game hub so Back restores navigation (shelf, spotlight, category). */ +type GamesHubReturnSnapshot = { + gameSubcategory: GameSubcategory; + selectedGameSubcategoryIndex: number; + gamesRootPlane: "spotlight" | "categories"; + spotlightIndex: number; + /** Omitted when the hub was not opened from a game selection (e.g. cloud resume tile). */ + restoreSelectedGameId?: string; +}; + +type SpotlightEntry = + | { kind: "cloudResume"; title: string; coverUrl: string | null; busy: boolean } + | { kind: "recent"; game: GameInfo | null }; + +function spotlightEntryHasGame(entry: SpotlightEntry | undefined): entry is { kind: "recent"; game: GameInfo } { + return entry?.kind === "recent" && entry.game != null; +} + +const LIBRARY_SORT_STORAGE_KEY = "opennow:controllerLibrarySort.v1"; + +const LIBRARY_SORT_LABEL: Record = { + recent: "Recent", + az: "A–Z", + za: "Z–A", + favoritesFirst: "Favorites first", +}; + +function readLibrarySortId(): LibrarySortId { + try { + const v = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(LIBRARY_SORT_STORAGE_KEY) : null; + if (v === "recent" || v === "az" || v === "za" || v === "favoritesFirst") return v; + } catch { + } + return "recent"; +} + const CATEGORY_STEP_PX = 160; const CATEGORY_ACTIVE_HALF_WIDTH_PX = 60; const GAME_ACTIVE_CENTER_OFFSET_X_PX = 320; +const PREVIEW_TILE_COUNT = 6; +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 }; + +/** XMB-style horizontal shelf: align active tile center with the shelf viewport center (track’s parent). */ +function computeShelfTranslateXToCenter(track: HTMLElement | null, activeIndex: number): number { + if (!track) return 0; + const viewport = track.parentElement; + if (!(viewport instanceof HTMLElement)) return 0; + const children = Array.from(track.children) as HTMLElement[]; + if (children.length === 0 || activeIndex < 0 || activeIndex >= children.length) return 0; + const activeEl = children[activeIndex]; + const centerInTrack = activeEl.offsetLeft + activeEl.offsetWidth / 2; + const halfVp = viewport.clientWidth / 2; + return halfVp - track.offsetLeft - centerInTrack; +} + +const CONTROLLER_THEME_STYLE_ORDER: readonly ControllerThemeStyle[] = ["aurora", "nebula", "grid", "minimal", "pulse"]; + +const CONTROLLER_THEME_STYLE_LABEL: Record = { + aurora: "Aurora", + nebula: "Nebula", + grid: "Grid", + minimal: "Minimal", + pulse: "Pulse", +}; + +function sanitizeControllerThemeStyle(raw: string | undefined): ControllerThemeStyle { + return CONTROLLER_THEME_STYLE_ORDER.includes(raw as ControllerThemeStyle) ? (raw as ControllerThemeStyle) : "aurora"; +} + +function clampRgbByte(n: number): number { + return Math.max(0, Math.min(255, Math.round(Number.isFinite(n) ? n : 0))); +} function sanitizeGenreName(raw: string): string { return raw @@ -77,25 +177,17 @@ function isEditableTarget(target: EventTarget | null): boolean { function getCategoryLabel(categoryId: string, currentGameTitle?: string): { label: string } { if (categoryId === "current") return { label: currentGameTitle || "Current" }; - if (categoryId === "all") return { label: "All" }; + if (categoryId === "all") return { label: "Games" }; if (categoryId === "settings") return { label: "Settings" }; if (categoryId === "media") return { label: "Media" }; - if (categoryId === "favorites") return { label: "Favorites" }; - const genreName = sanitizeGenreName(categoryId.slice(6)); - const shorthand: Record = { - "massively multiplayer online battle arena": "MOBA", - "massively multiplayer online": "MMO", - "multiplayer online battle arena": "MOBA", - "first person shooter": "FPS", - "role playing game": "RPG", - "real time strategy": "RTS", - "simulation": "Sim", - "virtual reality": "VR", - "third person shooter": "TPS", - }; - const normalized = genreName.toLowerCase(); - const display = shorthand[normalized] ?? genreName; - return { label: display }; + return { label: "Games" }; +} + +function getCategoryIcon(categoryId: string): JSX.Element { + if (categoryId === "current") return ; + if (categoryId === "settings") return ; + if (categoryId === "media") return ; + return ; } @@ -130,20 +222,34 @@ export function ControllerLibraryPage({ sessionStartedAtMs = null, isStreaming = false, sessionCounterEnabled = false, + inStreamMenu = false, + streamMenuVolume = 1, + onStreamMenuVolumeChange, + onStreamMenuToggleMicrophone, + onStreamMenuToggleFullscreen, + streamMenuMicOn = false, + streamMenuIsFullscreen = false, + cloudSessionResumable = false, + cloudResumeTitle = null, + cloudResumeCoverUrl = null, + onResumeCloudSession, + cloudResumeBusy = false, }: ControllerLibraryPageProps): JSX.Element { const [isEntering, setIsEntering] = useState(true); const initialCategoryIndex = (() => { - const hasFavorites = Array.isArray(favoriteGameIds) && favoriteGameIds.length > 0; if (currentStreamingGame) { - // TOP_CATEGORIES: current (game title), settings, all, favorites, ...genres + // TOP_CATEGORIES: current (game title), settings, all, media return 0; } - // TOP_CATEGORIES without `current`: settings, all, favorites, ...genres - return hasFavorites ? 2 : 1; + // TOP_CATEGORIES without `current`: settings, all, media + return 1; })(); const [categoryIndex, setCategoryIndex] = useState(initialCategoryIndex); - const audioContextRef = useRef(null); + const [endSessionConfirm, setEndSessionConfirm] = useState(false); + const [editingStreamVolume, setEditingStreamVolume] = useState(false); const itemsContainerRef = useRef(null); + const overlayNavWriteRef = useRef(null); + const overlayNavRestoredRef = useRef(false); const currentPosterImgRef = useRef(null); const [metaMaxWidth, setMetaMaxWidth] = useState(null); const posterObserverRef = useRef(null); @@ -166,7 +272,18 @@ export function ControllerLibraryPage({ } }; const [listTranslateY, setListTranslateY] = useState(0); + const [listTranslateX, setListTranslateX] = useState(0); + /** Dual-shelf games root: separate pans so “Recently played” and “Library” rows both stay screen-centered */ + const [spotlightShelfTranslateX, setSpotlightShelfTranslateX] = useState(0); + const [gamesRootMenuTranslateX, setGamesRootMenuTranslateX] = useState(0); + const [viewportWidth, setViewportWidth] = useState(() => + typeof window === "undefined" ? 1200 : window.innerWidth, + ); const favoriteGameIdSet = useMemo(() => new Set(favoriteGameIds), [favoriteGameIds]); + const favoriteGames = useMemo( + () => games.filter((game) => favoriteGameIdSet.has(game.id)), + [games, favoriteGameIdSet], + ); const [selectedSettingIndex, setSelectedSettingIndex] = useState(0); const [microphoneDevices, setMicrophoneDevices] = useState<{ deviceId: string; label: string }[]>([]); const [settingsSubcategory, setSettingsSubcategory] = useState("root"); @@ -174,6 +291,9 @@ export function ControllerLibraryPage({ const [mediaSubcategory, setMediaSubcategory] = useState("root"); const [lastRootMediaIndex, setLastRootMediaIndex] = useState(0); const [selectedMediaIndex, setSelectedMediaIndex] = useState(0); + const [gameSubcategory, setGameSubcategory] = useState("root"); + const [lastRootGameIndex, setLastRootGameIndex] = useState(0); + const [selectedGameSubcategoryIndex, setSelectedGameSubcategoryIndex] = useState(0); const [mediaLoading, setMediaLoading] = useState(false); const [mediaError, setMediaError] = useState(null); const [mediaVideos, setMediaVideos] = useState([]); @@ -181,6 +301,31 @@ export function ControllerLibraryPage({ const [mediaThumbById, setMediaThumbById] = useState>({}); const [controllerType, setControllerType] = useState<"ps" | "xbox" | "nintendo" | "generic">("generic"); const [editingBandwidth, setEditingBandwidth] = useState(false); + const [lastSystemMenuIndex, setLastSystemMenuIndex] = useState(0); + const [lastThemeRootIndex, setLastThemeRootIndex] = useState(0); + const [editingThemeChannel, setEditingThemeChannel] = useState(null); + const [ps5Row, setPs5Row] = useState<"top" | "main" | "detail">("main"); + const [detailRailIndex, setDetailRailIndex] = useState(0); + const [librarySortId, setLibrarySortId] = useState(() => readLibrarySortId()); + const [gamesRootPlane, setGamesRootPlane] = useState<"spotlight" | "categories">("spotlight"); + const [spotlightIndex, setSpotlightIndex] = useState(0); + const [optionsOpen, setOptionsOpen] = useState(false); + const [optionsEntries, setOptionsEntries] = useState>([]); + const [optionsFocusIndex, setOptionsFocusIndex] = useState(0); + const [heroTransitionMs, setHeroTransitionMs] = useState(420); + const [gamesHubOpen, setGamesHubOpen] = useState(false); + const [gamesHubFocusIndex, setGamesHubFocusIndex] = useState(0); + /** Local captures for the focused game; loaded when hub opens so Media tab need not be visited first */ + const [gameHubScreenshotUrls, setGameHubScreenshotUrls] = useState([]); + const gamesHubReturnSnapshotRef = useRef(null); + const spotlightTrackRef = useRef(null); + + useEffect(() => { + try { + sessionStorage.setItem(LIBRARY_SORT_STORAGE_KEY, librarySortId); + } catch { + } + }, [librarySortId]); useEffect(() => { if (typeof window === "undefined") { @@ -253,32 +398,7 @@ export function ControllerLibraryPage({ }; const playUiSound = useCallback((kind: SoundKind): void => { - if (!uiSoundsEnabled) return; - const audioContext = audioContextRef.current ?? new AudioContext(); - audioContextRef.current = audioContext; - if (audioContext.state === "suspended") void audioContext.resume(); - - const now = audioContext.currentTime; - const oscillator = audioContext.createOscillator(); - const gain = audioContext.createGain(); - - const profile: Record = { - move: { start: 720, end: 680, duration: 0.04, volume: 0.02, type: "triangle" }, - confirm: { start: 640, end: 860, duration: 0.1, volume: 0.04, type: "sine" }, - }; - - const active = profile[kind]; - oscillator.type = active.type; - oscillator.frequency.setValueAtTime(active.start, now); - oscillator.frequency.exponentialRampToValueAtTime(active.end, now + active.duration); - gain.gain.setValueAtTime(0.0001, now); - gain.gain.exponentialRampToValueAtTime(active.volume, now + 0.012); - gain.gain.exponentialRampToValueAtTime(0.0001, now + active.duration); - - oscillator.connect(gain); - gain.connect(audioContext.destination); - oscillator.start(now); - oscillator.stop(now + active.duration + 0.01); + playControllerUiSound(kind, uiSoundsEnabled); }, [uiSoundsEnabled]); const allGenres = useMemo(() => { @@ -297,12 +417,10 @@ export function ControllerLibraryPage({ categories.push({ id: "current", label: currentStreamingGame.title || "Current Game" }); } categories.push({ id: "settings", label: "Settings" }); - categories.push({ id: "all", label: "All" }); - categories.push({ id: "favorites", label: "Favorites" }); + categories.push({ id: "all", label: "Games" }); categories.push({ id: "media", label: "Media" }); - for (const genre of allGenres) categories.push({ id: `genre:${genre}`, label: sanitizeGenreName(genre) }); return categories; - }, [allGenres, currentStreamingGame]); + }, [currentStreamingGame]); const topCategory = (TOP_CATEGORIES[categoryIndex]?.id ?? "all") as unknown as string; @@ -311,6 +429,85 @@ export function ControllerLibraryPage({ setCategoryIndex((prev) => Math.max(0, Math.min(prev, TOP_CATEGORIES.length - 1))); }, [TOP_CATEGORIES.length]); + useLayoutEffect(() => { + if (!inStreamMenu) { + overlayNavRestoredRef.current = false; + return; + } + if (overlayNavRestoredRef.current) return; + overlayNavRestoredRef.current = true; + const snap = readControllerOverlayNav(); + if (!snap) return; + const maxCat = Math.max(0, TOP_CATEGORIES.length - 1); + setCategoryIndex(Math.max(0, Math.min(snap.categoryIndex, maxCat))); + setGameSubcategory(snap.gameSubcategory as GameSubcategory); + setMediaSubcategory(snap.mediaSubcategory as MediaSubcategory); + setSettingsSubcategory(snap.settingsSubcategory as SettingsSubcategory); + setGamesRootPlane(snap.gamesRootPlane); + setSpotlightIndex(snap.spotlightIndex); + setSelectedGameSubcategoryIndex(snap.selectedGameSubcategoryIndex); + setSelectedSettingIndex(snap.selectedSettingIndex); + setSelectedMediaIndex(snap.selectedMediaIndex); + setPs5Row(snap.ps5Row); + }, [inStreamMenu, TOP_CATEGORIES.length]); + + useEffect(() => { + if (!inStreamMenu) { + overlayNavWriteRef.current = null; + return; + } + overlayNavWriteRef.current = { + categoryIndex, + gameSubcategory: gameSubcategory as string, + mediaSubcategory: mediaSubcategory as string, + settingsSubcategory: settingsSubcategory as string, + gamesRootPlane, + spotlightIndex, + selectedGameSubcategoryIndex, + selectedSettingIndex, + selectedMediaIndex, + ps5Row, + }; + }, [ + inStreamMenu, + categoryIndex, + gameSubcategory, + mediaSubcategory, + settingsSubcategory, + gamesRootPlane, + spotlightIndex, + selectedGameSubcategoryIndex, + selectedSettingIndex, + selectedMediaIndex, + ps5Row, + ]); + + useEffect(() => { + if (!inStreamMenu) return; + return () => { + const snap = overlayNavWriteRef.current; + if (snap) writeControllerOverlayNav(snap); + }; + }, [inStreamMenu]); + + useEffect(() => { + const onNav = (ev: Event): void => { + const ce = ev as CustomEvent<{ target?: string }>; + if (ce.detail?.target !== "media") return; + const mediaIdx = TOP_CATEGORIES.findIndex((c) => c.id === "media"); + if (mediaIdx >= 0) setCategoryIndex(mediaIdx); + setMediaSubcategory("root"); + setSelectedMediaIndex(0); + setPs5Row("main"); + setGamesHubOpen(false); + setGameSubcategory("root"); + setEndSessionConfirm(false); + playUiSound("move"); + }; + window.addEventListener("opennow:controller-navigate", onNav as EventListener); + return () => window.removeEventListener("opennow:controller-navigate", onNav as EventListener); + }, [TOP_CATEGORIES, playUiSound]); + const settingsBySubcategory = useMemo(() => { const micLabel = (() => { const id = (settings as any).microphoneDeviceId as string | undefined; @@ -319,6 +516,9 @@ export function ControllerLibraryPage({ return found?.label ?? id; })(); + const themeRgb = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const themeStyleResolved = sanitizeControllerThemeStyle(settings.controllerThemeStyle); + return { root: [ { id: "network", label: "Network", value: "" }, @@ -346,15 +546,60 @@ export function ControllerLibraryPage({ { id: "autoFullScreen", label: "Auto Full Screen", value: (settings as any).autoFullScreen ? "On" : "Off" }, { id: "autoLoad", label: "Auto-Load Library", value: (settings as any).autoLoadControllerLibrary ? "On" : "Off" }, { id: "backgroundAnimations", label: "Background Animations", value: ((settings as any).controllerBackgroundAnimations ? "On" : "Off") }, + { id: "theme", label: "Theme", value: "" }, { id: "exitControllerMode", label: "Exit Controller Mode", value: "" }, ], + Theme: [ + { id: "themeColor", label: "Color", value: `RGB ${themeRgb.r}, ${themeRgb.g}, ${themeRgb.b}` }, + { id: "themeStyle", label: "Style", value: CONTROLLER_THEME_STYLE_LABEL[themeStyleResolved] }, + ], + ThemeColor: [ + { id: "themeR", label: "Red", value: `${themeRgb.r}` }, + { id: "themeG", label: "Green", value: `${themeRgb.g}` }, + { id: "themeB", label: "Blue", value: `${themeRgb.b}` }, + ], + ThemeStyle: CONTROLLER_THEME_STYLE_ORDER.map((id) => ({ + id, + label: CONTROLLER_THEME_STYLE_LABEL[id], + value: id === themeStyleResolved ? "Active" : "", + })), } as Record>; }, [settings, microphoneDevices]); - const currentGameItems = useMemo(() => [ - { id: "resume", label: "Resume Game", value: "" }, - { id: "closeGame", label: "Close Game", value: "" }, - ], []); + const currentGameItems = useMemo(() => { + const streamExtras = inStreamMenu + ? [ + { id: "toggleMic", label: "Microphone", value: streamMenuMicOn ? "On" : "Off" }, + { + id: "streamVolume", + label: "Stream volume", + value: `${Math.round((streamMenuVolume ?? 1) * 100)}%`, + }, + { id: "openMedia", label: "Media & captures", value: "Open" }, + { + id: "toggleFullscreen", + label: "Fullscreen", + value: streamMenuIsFullscreen ? "On" : "Off", + }, + ] + : []; + return [ + { id: "resume", label: "Resume Game", value: "" }, + ...streamExtras, + { + id: "closeGame", + label: + inStreamMenu && endSessionConfirm ? "End session (confirm)" : "Close Game", + value: "", + }, + ]; + }, [ + inStreamMenu, + endSessionConfirm, + streamMenuMicOn, + streamMenuVolume, + streamMenuIsFullscreen, + ]); const mediaRootItems = useMemo(() => [ { id: "videos", label: "Videos", value: "" }, @@ -367,12 +612,25 @@ export function ControllerLibraryPage({ return []; }, [mediaSubcategory, mediaVideos, mediaScreenshots]); + const gameRootItems = useMemo(() => { + const items: Array<{ id: GameSubcategory; label: string; value: string }> = [ + { id: "all", label: "All Games", value: `${games.length}` }, + { id: "favorites", label: "Favorites", value: `${favoriteGames.length}` }, + ]; + for (const genre of allGenres) { + const count = games.filter((game) => game.genres?.includes(genre)).length; + items.push({ id: `genre:${genre}`, label: sanitizeGenreName(genre), value: `${count}` }); + } + return items; + }, [allGenres, favoriteGames.length, games]); + const displayItems = useMemo(() => { if (topCategory === "current") return currentGameItems; if (topCategory === "settings") return settingsBySubcategory[settingsSubcategory] ?? []; + if (topCategory === "all" && gameSubcategory === "root") return gameRootItems; if (topCategory === "media" && mediaSubcategory === "root") return mediaRootItems; return []; - }, [topCategory, currentGameItems, settingsBySubcategory, settingsSubcategory, mediaSubcategory, mediaRootItems]); + }, [topCategory, currentGameItems, settingsBySubcategory, settingsSubcategory, gameSubcategory, gameRootItems, mediaSubcategory, mediaRootItems]); useEffect(() => { let mounted = true; @@ -451,14 +709,196 @@ export function ControllerLibraryPage({ }, [topCategory, mediaSubcategory]); const categorizedGames = useMemo(() => { - if (topCategory === "settings") return []; - if (topCategory === "favorites") return games.filter((game) => favoriteGameIdSet.has(game.id)); - if (topCategory.startsWith("genre:")) { - const genreName = topCategory.slice(6); + if (topCategory === "settings" || topCategory === "current" || topCategory === "media") return []; + if (gameSubcategory === "root") return []; + if (gameSubcategory === "favorites") return favoriteGames; + if (gameSubcategory.startsWith("genre:")) { + const genreName = gameSubcategory.slice(6); return games.filter((game) => game.genres?.includes(genreName)); } - return games; - }, [games, favoriteGameIdSet, topCategory]); + const lastPlayedMs = (gameId: string) => { + const raw = playtimeData[gameId]?.lastPlayedAt; + if (!raw) return 0; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : 0; + }; + const sortByRecent = (a: GameInfo, b: GameInfo) => { + const aLastPlayed = lastPlayedMs(a.id); + const bLastPlayed = lastPlayedMs(b.id); + if (aLastPlayed !== bLastPlayed) return bLastPlayed - aLastPlayed; + return a.title.localeCompare(b.title); + }; + const base = [...games]; + if (librarySortId === "recent") { + base.sort(sortByRecent); + } else if (librarySortId === "az") { + base.sort((a, b) => a.title.localeCompare(b.title)); + } else if (librarySortId === "za") { + base.sort((a, b) => b.title.localeCompare(a.title)); + } else { + base.sort((a, b) => { + const fa = favoriteGameIdSet.has(a.id); + const fb = favoriteGameIdSet.has(b.id); + if (fa !== fb) return fa ? -1 : 1; + return sortByRecent(a, b); + }); + } + return base; + }, [games, favoriteGames, favoriteGameIdSet, gameSubcategory, topCategory, playtimeData, librarySortId]); + + const gamesSortedByRecent = useMemo(() => { + return [...games].sort((a, b) => { + const lastPlayedMs = (gameId: string) => { + const raw = playtimeData[gameId]?.lastPlayedAt; + if (!raw) return 0; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : 0; + }; + const aLastPlayed = lastPlayedMs(a.id); + const bLastPlayed = lastPlayedMs(b.id); + if (aLastPlayed !== bLastPlayed) return bLastPlayed - aLastPlayed; + return a.title.localeCompare(b.title); + }); + }, [games, playtimeData]); + + const spotlightEntries = useMemo((): SpotlightEntry[] => { + const showResume = Boolean(cloudSessionResumable && onResumeCloudSession); + const recentCap = showResume ? Math.max(0, SPOTLIGHT_RECENT_COUNT - 1) : SPOTLIGHT_RECENT_COUNT; + + const lastPlayedMs = (gameId: string) => { + const raw = playtimeData[gameId]?.lastPlayedAt; + if (!raw) return 0; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : 0; + }; + + const played = + games.length === 0 + ? [] + : games + .filter((g) => lastPlayedMs(g.id) > 0) + .sort((a, b) => { + const d = lastPlayedMs(b.id) - lastPlayedMs(a.id); + if (d !== 0) return d; + return a.title.localeCompare(b.title); + }) + .slice(0, recentCap); + + const recentSlots: SpotlightEntry[] = played.map((g) => ({ kind: "recent" as const, game: g })); + while (recentSlots.length < recentCap) { + recentSlots.push({ kind: "recent", game: null }); + } + + if (!showResume) { + return recentSlots; + } + + const resumeTitle = cloudResumeTitle?.trim() || "Cloud session"; + return [ + { + kind: "cloudResume" as const, + title: resumeTitle, + coverUrl: cloudResumeCoverUrl ?? null, + busy: Boolean(cloudResumeBusy), + }, + ...recentSlots, + ]; + }, [ + games, + playtimeData, + cloudSessionResumable, + onResumeCloudSession, + cloudResumeTitle, + cloudResumeCoverUrl, + cloudResumeBusy, + ]); + + useEffect(() => { + if (spotlightEntries.length === 0) { + setSpotlightIndex(0); + return; + } + setSpotlightIndex((i) => Math.min(i, spotlightEntries.length - 1)); + }, [spotlightEntries.length]); + + const hadCloudResumeSpotlightRef = useRef(false); + useEffect(() => { + const hasResume = spotlightEntries.some((e) => e.kind === "cloudResume"); + if (hasResume && !hadCloudResumeSpotlightRef.current && topCategory === "all" && gameSubcategory === "root") { + setGamesRootPlane("spotlight"); + setSpotlightIndex(0); + } + hadCloudResumeSpotlightRef.current = hasResume; + }, [spotlightEntries, topCategory, gameSubcategory]); + + const gameCategoryPreviewById = useMemo(() => { + const isNonEmptyString = (value: string | undefined): value is string => typeof value === "string" && value.length > 0; + const randomize = (arr: string[]): string[] => { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; + }; + const toFilledPreview = (covers: string[]): string[] => { + const unique = Array.from(new Set(covers.filter(isNonEmptyString))); + if (unique.length === 0) return []; + const randomized = randomize(unique); + return randomized.slice(0, PREVIEW_TILE_COUNT); + }; + + const previews: Record = {}; + previews.all = toFilledPreview(gamesSortedByRecent.map((g) => g.imageUrl).filter(isNonEmptyString)); + previews.favorites = toFilledPreview(favoriteGames.map((g) => g.imageUrl).filter(isNonEmptyString)); + for (const genre of allGenres) { + const key = `genre:${genre}`; + previews[key] = gamesSortedByRecent + .filter((g) => g.genres?.includes(genre)) + .map((g) => g.imageUrl) + .filter(isNonEmptyString); + previews[key] = toFilledPreview(previews[key]); + } + return previews; + }, [allGenres, favoriteGames, gamesSortedByRecent]); + + const parallaxBackdropTiles = useMemo(() => { + const isNonEmptyString = (value: string | undefined): value is string => typeof value === "string" && value.length > 0; + const unique = Array.from(new Set(games.map((g) => g.imageUrl).filter(isNonEmptyString))); + if (unique.length === 0) return [] as Array<{ + src: string; + lane: 0 | 1 | 2; + left: number; + delaySec: number; + scale: number; + xFrom: number; + xTo: number; + rotFrom: number; + rotTo: number; + }>; + const shuffled = [...unique]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled.slice(0, 16).map((src, idx) => { + const lane = (idx % 3) as 0 | 1 | 2; + const drift = 6 + Math.random() * 12; + const rotStart = -6 + Math.random() * 6; + const rotEnd = 1 + Math.random() * 8; + return { + src, + lane, + left: 4 + Math.random() * 88, + delaySec: -(Math.random() * 54), + scale: 0.88 + Math.random() * 0.34, + xFrom: -drift, + xTo: drift, + rotFrom: rotStart, + rotTo: rotEnd, + }; + }); + }, [games]); const selectedIndex = useMemo(() => { const index = categorizedGames.findIndex((game) => game.id === selectedGameId); @@ -467,6 +907,50 @@ export function ControllerLibraryPage({ const selectedGame = useMemo(() => categorizedGames[selectedIndex] ?? null, [categorizedGames, selectedIndex]); + useEffect(() => { + if (!gamesHubOpen || !selectedGame?.title?.trim()) { + setGameHubScreenshotUrls([]); + return; + } + if (typeof window.openNow?.listMediaByGame !== "function") { + setGameHubScreenshotUrls([]); + return; + } + + let cancelled = false; + const titleArg = selectedGame.title.trim(); + + void (async () => { + try { + const listing = await window.openNow.listMediaByGame({ gameTitle: titleArg }); + if (cancelled) return; + + const rows = [...(listing.screenshots ?? [])].sort((a, b) => b.createdAtMs - a.createdAtMs); + const urls: string[] = []; + + for (const s of rows) { + let u = s.thumbnailDataUrl || s.dataUrl; + if (!u && typeof window.openNow?.getMediaThumbnail === "function") { + try { + u = (await window.openNow.getMediaThumbnail({ filePath: s.filePath })) ?? undefined; + } catch { + u = undefined; + } + } + if (u) urls.push(u); + } + + if (!cancelled) setGameHubScreenshotUrls(urls); + } catch { + if (!cancelled) setGameHubScreenshotUrls([]); + } + })(); + + return () => { + cancelled = true; + }; + }, [gamesHubOpen, selectedGame?.id, selectedGame?.title]); + const selectedVariantId = useMemo(() => { if (!selectedGame) return ""; const current = selectedVariantByGameId[selectedGame.id]; @@ -476,7 +960,108 @@ export function ControllerLibraryPage({ const showCurrentDetail = topCategory === "current" && Boolean(currentStreamingGame); const detailVisible = showCurrentDetail; + const gamesShelfBrowseActive = topCategory === "all" && gameSubcategory !== "root"; + const mediaShelfBrowseActive = topCategory === "media" && mediaSubcategory !== "root"; + const topLevelShelfActive = + !gamesShelfBrowseActive && + !mediaShelfBrowseActive && + (topCategory === "settings" || + topCategory === "current" || + (topCategory === "media" && mediaSubcategory === "root") || + (topCategory === "all" && gameSubcategory === "root")); + const gamesDualShelf = + topCategory === "all" && + gameSubcategory === "root" && + (games.length > 0 || Boolean(cloudSessionResumable && onResumeCloudSession)); + const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); + const canEnterDetailRow = mediaShelfBrowseActive; + const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; + const topLevelShelfIndex = + topCategory === "media" + ? selectedMediaIndex + : topCategory === "all" + ? selectedGameSubcategoryIndex + : selectedSettingIndex; + + useEffect(() => { + if (!inStreamMenu || !endSessionConfirm) return; + if (topCategory !== "current") { + setEndSessionConfirm(false); + return; + } + const item = displayItems[topLevelShelfIndex]; + if (item?.id !== "closeGame") setEndSessionConfirm(false); + }, [inStreamMenu, endSessionConfirm, topCategory, displayItems, topLevelShelfIndex]); + const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory, currentStreamingGame?.title).label, [topCategory, currentStreamingGame?.title]); + const selectedTopLevelItemLabel = useMemo(() => { + if (!topLevelShelfActive) return selectedCategoryLabel; + if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return entry.title; + if (spotlightEntryHasGame(entry)) return entry.game.title; + return "Recently played"; + } + const active = displayItems[topLevelShelfIndex]; + if (topCategory === "all" && gameSubcategory === "root" && active?.label) return active.label; + return selectedCategoryLabel; + }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex]); + const detailRailItems = useMemo>(() => { + if (topCategory === "media" && mediaSubcategory !== "root") { + const current = mediaAssetItems[selectedMediaIndex]; + const imageUrl = current?.thumbnailDataUrl || current?.dataUrl || (current ? mediaThumbById[current.id] : undefined); + return [ + { id: "m1", title: "Open folder", subtitle: "Reveal in Explorer / Finder", imageUrl }, + { id: "m2", title: "Media hub", subtitle: "Back to Videos & Screenshots", imageUrl }, + ]; + } + return []; + }, [topCategory, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); + + const gamesHubTiles = useMemo(() => { + if (!selectedGame || topCategory !== "all" || gameSubcategory === "root") return []; + const fav = favoriteGameIdSet.has(selectedGame.id); + const tiles: Array<{ id: string; title: string; subtitle: string; disabled?: boolean }> = [ + { + id: "play", + title: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + subtitle: + inStreamMenu && currentStreamingGame && currentStreamingGame.id !== selectedGame.id + ? `Switch from ${currentStreamingGame.title}` + : currentStreamingGame && currentStreamingGame.id !== selectedGame.id + ? "Switch to this title" + : "Launch now", + }, + { + id: "favorite", + title: fav ? "Remove favorite" : "Add favorite", + subtitle: "Library", + }, + ]; + if (selectedGame.variants.length > 1) { + tiles.push({ id: "version", title: "Version", subtitle: "Cycle stream variant" }); + } + tiles.push({ id: "activities", title: "Activities", subtitle: "Coming soon", disabled: true }); + tiles.push({ id: "progress", title: "Progress", subtitle: "Coming soon", disabled: true }); + return tiles; + }, [topCategory, gameSubcategory, selectedGame, favoriteGameIdSet, currentStreamingGame, inStreamMenu]); + + useEffect(() => { + const n = gamesHubTiles.length; + if (n === 0) return; + setGamesHubFocusIndex((i) => Math.max(0, Math.min(n - 1, i))); + }, [gamesHubTiles.length, selectedGame?.id]); + const focusMotionKey = useMemo(() => { + if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return `spotlight-resume-${entry.busy ? "busy" : "idle"}`; + if (spotlightEntryHasGame(entry)) return `spotlight-${entry.game.id}`; + return `spotlight-empty-${spotlightIndex}`; + } + if (topCategory === "all" && gameSubcategory !== "root") return `game-${selectedGame?.id ?? "none"}`; + if (topCategory === "media" && mediaSubcategory !== "root") return `media-${selectedMediaIndex}-${mediaAssetItems[selectedMediaIndex]?.id ?? "none"}`; + return `menu-${topCategory}-${topLevelShelfIndex}`; + }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); const selectedGameDescription = useMemo(() => { if (!selectedGame) return ""; const description = selectedGame.longDescription?.trim() || selectedGame.description?.trim(); @@ -489,13 +1074,126 @@ export function ControllerLibraryPage({ return "Ready To Switch"; }, [currentStreamingGame, selectedGame]); + useEffect(() => { + setHeroTransitionMs(200); + const t = window.setTimeout(() => setHeroTransitionMs(420), 420); + return () => window.clearTimeout(t); + }, [focusMotionKey]); + + useEffect(() => { + if (topCategory !== "all") { + setGamesRootPlane("spotlight"); + setSpotlightIndex(0); + } + }, [topCategory]); + + useEffect(() => { + setOptionsOpen(false); + setOptionsEntries([]); + setOptionsFocusIndex(0); + }, [topCategory, gameSubcategory, mediaSubcategory, settingsSubcategory]); + + useEffect(() => { + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); + setGamesHubFocusIndex(0); + }, [topCategory]); + + useEffect(() => { + if (gameSubcategory === "root") { + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); + setGamesHubFocusIndex(0); + } + }, [gameSubcategory]); + + useEffect(() => { + if (!gamesShelfBrowseActive) { + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); + setGamesHubFocusIndex(0); + } + }, [gamesShelfBrowseActive]); + + useEffect(() => { + if (!gamesShelfBrowseActive || categorizedGames.length === 0) return; + const idxs = [selectedIndex - 2, selectedIndex - 1, selectedIndex + 1, selectedIndex + 2]; + for (const i of idxs) { + const url = categorizedGames[i]?.imageUrl; + if (typeof url === "string" && url.length > 0) { + const im = new Image(); + im.src = url; + } + } + }, [gamesShelfBrowseActive, selectedIndex, categorizedGames]); + + useEffect(() => { + if (!gamesShelfBrowseActive && !mediaShelfBrowseActive && !topLevelShelfActive) { + setListTranslateX(0); + setSpotlightShelfTranslateX(0); + setGamesRootMenuTranslateX(0); + } + }, [gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive]); + + useEffect(() => { + setPs5Row("main"); + setDetailRailIndex(0); + }, [topCategory, gameSubcategory, mediaSubcategory, settingsSubcategory]); + + useEffect(() => { + if (ps5Row === "detail" && !canEnterDetailRow) { + setPs5Row("main"); + return; + } + if (ps5Row === "top" && !canEnterTopRow) { + setPs5Row("main"); + } + }, [ps5Row, canEnterDetailRow, canEnterTopRow]); + useEffect(() => { + if (typeof window === "undefined") return; + let raf = 0; + const onResize = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => setViewportWidth(window.innerWidth)); + }; + window.addEventListener("resize", onResize); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("resize", onResize); + }; + }, []); useLayoutEffect(() => { + const gamesRoot = topCategory === "all" && gameSubcategory === "root"; + + if (!gamesRoot || !gamesDualShelf) { + setSpotlightShelfTranslateX(0); + setGamesRootMenuTranslateX(0); + } + + if (gamesRoot && gamesDualShelf) { + setSpotlightShelfTranslateX(computeShelfTranslateXToCenter(spotlightTrackRef.current, spotlightIndex)); + setGamesRootMenuTranslateX(computeShelfTranslateXToCenter(itemsContainerRef.current, topLevelShelfIndex)); + setListTranslateY(0); + return; + } + const container = itemsContainerRef.current; if (!container) return; const children = Array.from(container.children) as HTMLElement[]; - if (children.length === 0 || selectedIndex >= children.length) return; + const activeIndex = gamesShelfBrowseActive ? selectedIndex : mediaShelfBrowseActive ? selectedMediaIndex : topLevelShelfIndex; + if (children.length === 0 || activeIndex >= children.length) { + if (gamesShelfBrowseActive || mediaShelfBrowseActive || topLevelShelfActive) setListTranslateX(0); + return; + } + + if (gamesShelfBrowseActive || mediaShelfBrowseActive || topLevelShelfActive) { + setListTranslateX(computeShelfTranslateXToCenter(container, activeIndex)); + setListTranslateY(0); + return; + } + let offset = 0; for (let i = 0; i < selectedIndex; i++) { const childStyle = window.getComputedStyle(children[i]); @@ -503,7 +1201,22 @@ export function ControllerLibraryPage({ } offset += children[selectedIndex].offsetHeight / 2; setListTranslateY(-offset); - }, [selectedIndex, categorizedGames]); + setListTranslateX(0); + }, [ + selectedIndex, + categorizedGames, + gamesShelfBrowseActive, + mediaShelfBrowseActive, + topLevelShelfActive, + topLevelShelfIndex, + selectedMediaIndex, + viewportWidth, + topCategory, + gameSubcategory, + gamesDualShelf, + spotlightIndex, + spotlightEntries, + ]); const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); @@ -516,6 +1229,25 @@ export function ControllerLibraryPage({ useEffect(() => { const applyDirection = (direction: Direction): void => { + // When editing Theme RGB channels, use left/right to adjust value + if (topCategory === "settings" && settingsSubcategory === "ThemeColor" && editingThemeChannel && onSettingChange) { + const step = 8; + const tc = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const channel = editingThemeChannel; + const cur = tc[channel]; + if (direction === "left") { + const next = clampRgbByte(cur - step); + onSettingChange("controllerThemeColor", { ...tc, [channel]: next }); + playUiSound("move"); + return; + } + if (direction === "right") { + const next = clampRgbByte(cur + step); + onSettingChange("controllerThemeColor", { ...tc, [channel]: next }); + playUiSound("move"); + return; + } + } // When editing the bandwidth slider, use left/right to adjust value if (topCategory === "settings" && settingsSubcategory !== "root" && editingBandwidth) { const step = 5; // Mbps per left/right press @@ -533,122 +1265,624 @@ export function ControllerLibraryPage({ return; } } - if (isLoading && topCategory !== "settings" && topCategory !== "current") return; - if (direction === "left") { - playUiSound("move"); - // Cycle main categories (settings always resets to root) - setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); - setSelectedSettingIndex(0); - setSettingsSubcategory("root"); - setSelectedMediaIndex(0); - setMediaSubcategory("root"); - return; - } - if (direction === "right") { - playUiSound("move"); - // Cycle main categories (settings always resets to root) - setCategoryIndex((prev) => (prev + 1) % TOP_CATEGORIES.length); - setSelectedSettingIndex(0); - setSettingsSubcategory("root"); - setSelectedMediaIndex(0); - setMediaSubcategory("root"); - return; - } - if (topCategory === "current" || topCategory === "settings") { - if (direction === "up") { - const nextIndex = Math.max(0, selectedSettingIndex - 1); - if (nextIndex !== selectedSettingIndex) { - playUiSound("move"); - setSelectedSettingIndex(nextIndex); - } + if (topCategory === "current" && inStreamMenu && editingStreamVolume && onStreamMenuVolumeChange) { + const step = 0.05; + const cur = streamMenuVolume ?? 1; + if (direction === "left") { + onStreamMenuVolumeChange(Math.max(0, cur - step)); + playUiSound("move"); return; } - if (direction === "down") { - const nextIndex = Math.min(displayItems.length - 1, selectedSettingIndex + 1); - if (nextIndex !== selectedSettingIndex) { - playUiSound("move"); - setSelectedSettingIndex(nextIndex); - } + if (direction === "right") { + onStreamMenuVolumeChange(Math.min(1, cur + step)); + playUiSound("move"); return; } return; } - if (topCategory === "media") { - const itemCount = mediaSubcategory === "root" ? displayItems.length : mediaAssetItems.length; - if (itemCount === 0) return; + if (isLoading && topCategory !== "settings" && topCategory !== "current") return; + + if (optionsOpen && optionsEntries.length > 0) { if (direction === "up") { - const nextIndex = Math.max(0, selectedMediaIndex - 1); - if (nextIndex !== selectedMediaIndex) { + const ni = Math.max(0, optionsFocusIndex - 1); + if (ni !== optionsFocusIndex) { playUiSound("move"); - setSelectedMediaIndex(nextIndex); + setOptionsFocusIndex(ni); } return; } if (direction === "down") { - const nextIndex = Math.min(itemCount - 1, selectedMediaIndex + 1); - if (nextIndex !== selectedMediaIndex) { + const ni = Math.min(optionsEntries.length - 1, optionsFocusIndex + 1); + if (ni !== optionsFocusIndex) { playUiSound("move"); - setSelectedMediaIndex(nextIndex); + setOptionsFocusIndex(ni); } return; } return; } - if (categorizedGames.length === 0) return; - if (direction === "up") { - const nextIndex = Math.max(0, selectedIndex - 1); - if (nextIndex !== selectedIndex) { + + if ( + gamesHubOpen && + topCategory === "all" && + gameSubcategory !== "root" + ) { + const n = gamesHubTiles.length; + if (n === 0) return; + if (direction === "left") { + setGamesHubFocusIndex((i) => Math.max(0, i - 1)); playUiSound("move"); - throttledOnSelectGame(categorizedGames[nextIndex].id); + return; } - return; - } - if (direction === "down") { - const nextIndex = Math.min(categorizedGames.length - 1, selectedIndex + 1); - if (nextIndex !== selectedIndex) { + if (direction === "right") { + setGamesHubFocusIndex((i) => Math.min(n - 1, i + 1)); playUiSound("move"); - throttledOnSelectGame(categorizedGames[nextIndex].id); + return; } return; } - }; - - const handler = (e: any) => { - if (e.detail?.direction) applyDirection(e.detail.direction); - }; - const activateHandler = () => { - // If currently editing bandwidth, A confirms and exits edit mode - if (topCategory === "settings" && settingsSubcategory !== "root" && editingBandwidth) { - setEditingBandwidth(false); - playUiSound("confirm"); - return; - } - if (topCategory === "current") { - const item = displayItems[selectedSettingIndex]; - if (item?.id === "resume" && currentStreamingGame && onResumeGame) { - onResumeGame(currentStreamingGame); - playUiSound("confirm"); + if (ps5Row === "top") { + if (direction === "left") { + cycleTopCategory(-1); return; } - if (item?.id === "closeGame" && onCloseGame) { - onCloseGame(); - playUiSound("confirm"); + if (direction === "right") { + cycleTopCategory(1); + return; + } + if (direction === "down") { + playUiSound("move"); + setPs5Row("main"); + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf) { + setGamesRootPlane("spotlight"); + } return; } return; } - if (topCategory === "settings") { - const setting = displayItems[selectedSettingIndex]; - // Enter subcategory if at root and selecting network/audio/system - if (settingsSubcategory === "root" && setting && (setting.id === "network" || setting.id === "audio" || setting.id === "video" || setting.id === "system")) { - setLastRootSettingIndex(selectedSettingIndex); - if (setting.id === "network") setSettingsSubcategory("Network"); - if (setting.id === "audio") setSettingsSubcategory("Audio"); - if (setting.id === "video") setSettingsSubcategory("Video"); - if (setting.id === "system") setSettingsSubcategory("System"); - setSelectedSettingIndex(0); - playUiSound("confirm"); + + if (ps5Row === "detail") { + if (!canEnterDetailRow || detailRailItems.length === 0) { + setPs5Row("main"); + return; + } + if (direction === "up") { + playUiSound("move"); + setPs5Row("main"); + return; + } + if (direction === "left") { + const next = Math.max(0, detailRailIndex - 1); + if (next !== detailRailIndex) { + playUiSound("move"); + setDetailRailIndex(next); + } + return; + } + if (direction === "right") { + const next = Math.min(detailRailItems.length - 1, detailRailIndex + 1); + if (next !== detailRailIndex) { + playUiSound("move"); + setDetailRailIndex(next); + } + return; + } + return; + } + + const shelfHasGames = categorizedGames.length > 0; + + if (gamesShelfBrowseActive) { + if (shelfHasGames) { + if (direction === "down") { + if (selectedGame) { + playUiSound("move"); + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: selectedGameId, + }; + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + } + return; + } + if (direction === "left") { + const ni = Math.max(0, selectedIndex - 1); + if (ni !== selectedIndex) { + playUiSound("move"); + throttledOnSelectGame(categorizedGames[ni].id); + } + return; + } + if (direction === "right") { + const ni = Math.min(categorizedGames.length - 1, selectedIndex + 1); + if (ni !== selectedIndex) { + playUiSound("move"); + throttledOnSelectGame(categorizedGames[ni].id); + } + return; + } + if (direction === "up") { + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } + return; + } + } else if (direction === "up") { + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } + return; + } + } + + if (mediaShelfBrowseActive) { + if (direction === "down") { + if (canEnterDetailRow && detailRailItems.length > 0) { + playUiSound("move"); + setPs5Row("detail"); + } + return; + } + const itemCount = mediaAssetItems.length; + if (itemCount > 0 && direction === "left") { + const nextIndex = Math.max(0, selectedMediaIndex - 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + if (itemCount > 0 && direction === "right") { + const nextIndex = Math.min(itemCount - 1, selectedMediaIndex + 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + if (direction === "up") { + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } + return; + } + } + + if (topLevelRowBehaviorActive) { + const isGamesRoot = topCategory === "all" && gameSubcategory === "root"; + const itemCount = displayItems.length; + + if (isGamesRoot && gamesDualShelf && gamesRootPlane === "spotlight" && (direction === "left" || direction === "right")) { + const delta = direction === "left" ? -1 : 1; + const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); + if (next !== spotlightIndex) { + playUiSound("move"); + setSpotlightIndex(next); + } + return; + } + + if (itemCount > 0 && (direction === "left" || direction === "right")) { + const delta = direction === "left" ? -1 : 1; + const next = Math.max(0, Math.min(itemCount - 1, topLevelShelfIndex + delta)); + if (next !== topLevelShelfIndex) { + playUiSound("move"); + if (topCategory === "media") setSelectedMediaIndex(next); + else if (topCategory === "all") setSelectedGameSubcategoryIndex(next); + else setSelectedSettingIndex(next); + } + return; + } + + if (direction === "up" || direction === "down") { + if (isGamesRoot && gamesDualShelf) { + if (direction === "up") { + if (gamesRootPlane === "categories") { + playUiSound("move"); + setGamesRootPlane("spotlight"); + return; + } + if (gamesRootPlane === "spotlight" && canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + return; + } + } + if (direction === "down" && gamesRootPlane === "spotlight") { + playUiSound("move"); + setGamesRootPlane("categories"); + return; + } + } + if (direction === "up" && canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + return; + } + if (direction === "down" && canEnterDetailRow && detailRailItems.length > 0) { + playUiSound("move"); + setPs5Row("detail"); + return; + } + return; + } + } + + if (topCategory === "settings" && settingsSubcategory !== "root" && (direction === "left" || direction === "right")) { + const itemCount = displayItems.length; + if (itemCount === 0) return; + // In settings submenus, left/right move along the submenu list (same as horizontal shelves). + const delta = direction === "left" ? -1 : 1; + const nextIndex = Math.max(0, Math.min(itemCount - 1, selectedSettingIndex + delta)); + if (nextIndex !== selectedSettingIndex) { + playUiSound("move"); + setSelectedSettingIndex(nextIndex); + } + return; + } + + if (direction === "left") { + playUiSound("move"); + // Cycle main categories (settings always resets to root) + setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); + setSelectedSettingIndex(0); + setSettingsSubcategory("root"); + setSelectedMediaIndex(0); + setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); + setEditingBandwidth(false); + setEditingThemeChannel(null); + return; + } + if (direction === "right") { + playUiSound("move"); + // Cycle main categories (settings always resets to root) + setCategoryIndex((prev) => (prev + 1) % TOP_CATEGORIES.length); + setSelectedSettingIndex(0); + setSettingsSubcategory("root"); + setSelectedMediaIndex(0); + setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); + setEditingBandwidth(false); + setEditingThemeChannel(null); + return; + } + if (topCategory === "current" || topCategory === "settings") { + if (direction === "up") { + const nextIndex = Math.max(0, selectedSettingIndex - 1); + if (nextIndex !== selectedSettingIndex) { + playUiSound("move"); + setSelectedSettingIndex(nextIndex); + if (topCategory === "current" && inStreamMenu) setEditingStreamVolume(false); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(displayItems.length - 1, selectedSettingIndex + 1); + if (nextIndex !== selectedSettingIndex) { + playUiSound("move"); + setSelectedSettingIndex(nextIndex); + if (topCategory === "current" && inStreamMenu) setEditingStreamVolume(false); + } + return; + } + return; + } + if (topCategory === "media" && mediaSubcategory === "root") { + const itemCount = mediaSubcategory === "root" ? displayItems.length : mediaAssetItems.length; + if (itemCount === 0) return; + if (direction === "up") { + const nextIndex = Math.max(0, selectedMediaIndex - 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(itemCount - 1, selectedMediaIndex + 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + return; + } + if (topCategory === "all" && gameSubcategory === "root") { + const itemCount = displayItems.length; + if (itemCount === 0) return; + if (direction === "up") { + const nextIndex = Math.max(0, selectedGameSubcategoryIndex - 1); + if (nextIndex !== selectedGameSubcategoryIndex) { + playUiSound("move"); + setSelectedGameSubcategoryIndex(nextIndex); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(itemCount - 1, selectedGameSubcategoryIndex + 1); + if (nextIndex !== selectedGameSubcategoryIndex) { + playUiSound("move"); + setSelectedGameSubcategoryIndex(nextIndex); + } + return; + } + return; + } + }; + + const cycleTopCategory = (delta: number) => { + setCategoryIndex((prev) => (prev + delta + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); + setSelectedSettingIndex(0); + setSettingsSubcategory("root"); + setSelectedMediaIndex(0); + setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); + setEditingBandwidth(false); + setEditingThemeChannel(null); + setEditingStreamVolume(false); + playUiSound("move"); + }; + + const handler = (e: any) => { + if (e.detail?.direction) applyDirection(e.detail.direction); + }; + const shoulderHandler = (e: any) => { + const direction = e?.detail?.direction as "prev" | "next" | undefined; + if (!direction) return; + if (gamesHubOpen) return; + if (topCategory === "settings" && settingsSubcategory !== "root") return; + if (editingBandwidth || editingThemeChannel || editingStreamVolume) return; + cycleTopCategory(direction === "prev" ? -1 : 1); + }; + + const openOptionsMenu = (): void => { + const entries: Array<{ id: string; label: string }> = []; + if (gamesShelfBrowseActive && selectedGame) { + entries.push({ + id: "play", + label: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + }); + entries.push({ + id: "favorite", + label: favoriteGameIdSet.has(selectedGame.id) ? "Remove favorite" : "Add favorite", + }); + if (selectedGame.variants.length > 1) { + entries.push({ id: "variant", label: "Change version" }); + } + } else if (mediaShelfBrowseActive && mediaAssetItems[selectedMediaIndex]) { + entries.push({ id: "openFolder", label: "Open folder" }); + } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex])) { + entries.push({ id: "openLibrary", label: "View in library" }); + } + if (entries.length === 0) return; + entries.push({ id: "close", label: "Back" }); + setOptionsEntries(entries); + setOptionsFocusIndex(0); + setOptionsOpen(true); + playUiSound("move"); + }; + + const activateHandler = () => { + if (optionsOpen && optionsEntries.length > 0) { + const opt = optionsEntries[optionsFocusIndex]; + if (!opt) return; + if (opt.id === "close") { + setOptionsOpen(false); + playUiSound("move"); + return; + } + if (opt.id === "play" && selectedGame) { + onPlayGame(selectedGame); + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); + setOptionsOpen(false); + playUiSound("confirm"); + return; + } + if (opt.id === "favorite" && selectedGame) { + onToggleFavoriteGame(selectedGame.id); + setOptionsOpen(false); + playUiSound("confirm"); + return; + } + if (opt.id === "variant" && selectedGame && selectedGame.variants.length > 1) { + const idx = selectedGame.variants.findIndex((v) => v.id === selectedVariantId); + const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; + onSelectGameVariant(selectedGame.id, next.id); + setOptionsOpen(false); + playUiSound("confirm"); + return; + } + if (opt.id === "openFolder") { + const cur = mediaAssetItems[selectedMediaIndex]; + if (cur && typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: cur.filePath }); + } + setOptionsOpen(false); + playUiSound("confirm"); + return; + } + if (opt.id === "openLibrary") { + const entry = spotlightEntries[spotlightIndex]; + const g = spotlightEntryHasGame(entry) ? entry.game : null; + if (g) { + gamesHubReturnSnapshotRef.current = { + gameSubcategory: "root", + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: g.id, + }; + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory("all"); + throttledOnSelectGame(g.id); + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + setOptionsOpen(false); + playUiSound("confirm"); + } + return; + } + return; + } + + if ( + gamesHubOpen && + topCategory === "all" && + gameSubcategory !== "root" && + selectedGame + ) { + const tile = gamesHubTiles[gamesHubFocusIndex]; + if (!tile || tile.disabled) { + playUiSound("move"); + return; + } + if (tile.id === "play") { + onPlayGame(selectedGame); + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); + setGamesHubFocusIndex(0); + playUiSound("confirm"); + return; + } + if (tile.id === "favorite") { + onToggleFavoriteGame(selectedGame.id); + playUiSound("confirm"); + return; + } + if (tile.id === "version" && selectedGame.variants.length > 1) { + const idx = selectedGame.variants.findIndex((v) => v.id === selectedVariantId); + const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; + onSelectGameVariant(selectedGame.id, next.id); + playUiSound("confirm"); + return; + } + playUiSound("move"); + return; + } + + if (ps5Row === "top") { + setPs5Row("main"); + playUiSound("confirm"); + return; + } + + if (ps5Row === "detail") { + if (!canEnterDetailRow || detailRailItems.length === 0) { + setPs5Row("main"); + return; + } + const selectedDetail = detailRailItems[detailRailIndex]; + if (!selectedDetail) return; + + if (topCategory === "media") { + if (selectedDetail.id === "m1") { + const current = mediaAssetItems[selectedMediaIndex]; + if (current && typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: current.filePath }); + } + playUiSound("confirm"); + return; + } + if (selectedDetail.id === "m2") { + setMediaSubcategory("root"); + setSelectedMediaIndex(lastRootMediaIndex); + setPs5Row("main"); + playUiSound("confirm"); + return; + } + playUiSound("confirm"); + return; + } + + // Placeholder detail cards: confirm and return to the main row. + setPs5Row("main"); + playUiSound("confirm"); + return; + } + + // If currently editing bandwidth, A confirms and exits edit mode + if (topCategory === "settings" && settingsSubcategory !== "root" && editingBandwidth) { + setEditingBandwidth(false); + playUiSound("confirm"); + return; + } + if (topCategory === "settings" && settingsSubcategory === "ThemeColor" && editingThemeChannel) { + setEditingThemeChannel(null); + playUiSound("confirm"); + return; + } + if (topCategory === "current" && inStreamMenu && editingStreamVolume) { + setEditingStreamVolume(false); + playUiSound("confirm"); + return; + } + if (topCategory === "current") { + const item = displayItems[selectedSettingIndex]; + if (item?.id === "resume" && currentStreamingGame && onResumeGame) { + onResumeGame(currentStreamingGame); + playUiSound("confirm"); + return; + } + if (item?.id === "toggleMic" && onStreamMenuToggleMicrophone) { + onStreamMenuToggleMicrophone(); + playUiSound("confirm"); + return; + } + if (item?.id === "openMedia") { + window.dispatchEvent(new CustomEvent("opennow:controller-navigate", { detail: { target: "media" } })); + playUiSound("confirm"); + return; + } + if (item?.id === "toggleFullscreen" && onStreamMenuToggleFullscreen) { + onStreamMenuToggleFullscreen(); + playUiSound("confirm"); + return; + } + if (item?.id === "closeGame" && onCloseGame) { + if (inStreamMenu) { + if (endSessionConfirm) { + setEndSessionConfirm(false); + onCloseGame(); + playUiSound("confirm"); + } else { + setEndSessionConfirm(true); + playUiSound("move"); + } + return; + } + onCloseGame(); + playUiSound("confirm"); + return; + } + return; + } + if (topCategory === "settings") { + const setting = displayItems[selectedSettingIndex]; + // Enter subcategory if at root and selecting network/audio/system + if (settingsSubcategory === "root" && setting && (setting.id === "network" || setting.id === "audio" || setting.id === "video" || setting.id === "system")) { + setLastRootSettingIndex(selectedSettingIndex); + if (setting.id === "network") setSettingsSubcategory("Network"); + if (setting.id === "audio") setSettingsSubcategory("Audio"); + if (setting.id === "video") setSettingsSubcategory("Video"); + if (setting.id === "system") setSettingsSubcategory("System"); + setSelectedSettingIndex(0); + playUiSound("confirm"); return; } if (settingsSubcategory === "root" && setting?.id === "exitApp") { @@ -660,6 +1894,43 @@ export function ControllerLibraryPage({ playUiSound("confirm"); return; } + if (settingsSubcategory === "System" && setting?.id === "theme") { + setLastSystemMenuIndex(selectedSettingIndex); + setSettingsSubcategory("Theme"); + setSelectedSettingIndex(0); + setEditingThemeChannel(null); + playUiSound("confirm"); + return; + } + if (settingsSubcategory === "Theme") { + const item = displayItems[selectedSettingIndex]; + if (item?.id === "themeColor") { + setLastThemeRootIndex(selectedSettingIndex); + setSettingsSubcategory("ThemeColor"); + setSelectedSettingIndex(0); + setEditingThemeChannel(null); + playUiSound("confirm"); + return; + } + if (item?.id === "themeStyle") { + setLastThemeRootIndex(selectedSettingIndex); + setSettingsSubcategory("ThemeStyle"); + const resolvedStyle = sanitizeControllerThemeStyle(settings.controllerThemeStyle); + const idx = CONTROLLER_THEME_STYLE_ORDER.indexOf(resolvedStyle); + setSelectedSettingIndex(idx >= 0 ? idx : 0); + playUiSound("confirm"); + return; + } + return; + } + if (settingsSubcategory === "ThemeStyle") { + const row = displayItems[selectedSettingIndex]; + if (row?.id && onSettingChange) { + onSettingChange("controllerThemeStyle", row.id as ControllerThemeStyle); + playUiSound("confirm"); + } + return; + } // In subcategory, A toggles values like X does if (settingsSubcategory !== "root") { if (setting?.id === "exitControllerMode") { @@ -698,6 +1969,60 @@ export function ControllerLibraryPage({ } playUiSound("confirm"); + } else if (topCategory === "all") { + if (gameSubcategory === "root") { + if (gamesRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") { + if (!entry.busy && onResumeCloudSession) { + onResumeCloudSession(); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + return; + } + if (spotlightEntryHasGame(entry)) { + const g = entry.game; + gamesHubReturnSnapshotRef.current = { + gameSubcategory: "root", + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: g.id, + }; + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory("all"); + throttledOnSelectGame(g.id); + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + playUiSound("confirm"); + return; + } + } + const item = displayItems[selectedGameSubcategoryIndex]; + if (item) { + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory(item.id as GameSubcategory); + setSelectedGameSubcategoryIndex(0); + playUiSound("confirm"); + } + return; + } + if (selectedGame) { + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: selectedGameId, + }; + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + playUiSound("confirm"); + } } else if (selectedGame) { onPlayGame(selectedGame); playUiSound("confirm"); @@ -705,11 +2030,30 @@ export function ControllerLibraryPage({ }; const secondaryActivateHandler = () => { + if (optionsOpen) return; + if (gamesHubOpen) return; + if (gamesShelfBrowseActive && gameSubcategory === "all") { + setLibrarySortId((prev) => { + const order: LibrarySortId[] = ["recent", "favoritesFirst", "az", "za"]; + const i = order.indexOf(prev); + return order[(i + 1) % order.length] ?? "recent"; + }); + playUiSound("move"); + return; + } + if (topCategory === "current" && inStreamMenu) { + const item = displayItems[selectedSettingIndex]; + if (item?.id === "streamVolume" && onStreamMenuVolumeChange) { + setEditingStreamVolume(true); + playUiSound("move"); + } + return; + } if (topCategory === "current") { - // X button does nothing on current game menu items return; } if (topCategory === "settings") { + if (settingsSubcategory === "ThemeStyle" || settingsSubcategory === "Theme") return; // X button cycles through setting values (no-op for exit actions or subcategory items at root) const setting = displayItems[selectedSettingIndex]; if (!setting || !onSettingChange) return; @@ -717,6 +2061,16 @@ export function ControllerLibraryPage({ // Skip X cycling for subcategory items at root if (settingsSubcategory === "root" && (setting.id === "network" || setting.id === "audio" || setting.id === "video" || setting.id === "system")) return; + if ( + settingsSubcategory === "ThemeColor" && + (setting.id === "themeR" || setting.id === "themeG" || setting.id === "themeB") + ) { + const ch = setting.id === "themeR" ? "r" : setting.id === "themeG" ? "g" : "b"; + setEditingThemeChannel(ch); + playUiSound("move"); + return; + } + // Microphone device cycling if (setting.id === "microphone") { const current = (settings as any).microphoneDeviceId as string | undefined; @@ -775,21 +2129,32 @@ export function ControllerLibraryPage({ } return; } - if (selectedGame && selectedGame.variants.length > 1) { - const idx = selectedGame.variants.findIndex(v => v.id === selectedVariantId); - const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; - onSelectGameVariant(selectedGame.id, next.id); - playUiSound("move"); - } }; const tertiaryActivateHandler = () => { - if (topCategory !== "settings" && topCategory !== "current") { - toggleFavoriteForSelected(); - } + if (optionsOpen) return; + openOptionsMenu(); }; const cancelHandler = (e: Event) => { + if (optionsOpen) { + setOptionsOpen(false); + playUiSound("move"); + e.preventDefault(); + return; + } + if (inStreamMenu && endSessionConfirm) { + setEndSessionConfirm(false); + playUiSound("move"); + e.preventDefault(); + return; + } + if (inStreamMenu && editingStreamVolume) { + setEditingStreamVolume(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") { @@ -799,6 +2164,33 @@ export function ControllerLibraryPage({ e.preventDefault(); return; } + if (editingThemeChannel) { + setEditingThemeChannel(null); + playUiSound("move"); + e.preventDefault(); + return; + } + if (settingsSubcategory === "ThemeColor") { + setSettingsSubcategory("Theme"); + setSelectedSettingIndex(lastThemeRootIndex); + playUiSound("move"); + e.preventDefault(); + return; + } + if (settingsSubcategory === "ThemeStyle") { + setSettingsSubcategory("Theme"); + setSelectedSettingIndex(lastThemeRootIndex); + playUiSound("move"); + e.preventDefault(); + return; + } + if (settingsSubcategory === "Theme") { + setSettingsSubcategory("System"); + setSelectedSettingIndex(lastSystemMenuIndex); + playUiSound("move"); + e.preventDefault(); + return; + } setSettingsSubcategory("root"); setSelectedSettingIndex(lastRootSettingIndex); playUiSound("move"); @@ -810,7 +2202,36 @@ export function ControllerLibraryPage({ setSelectedMediaIndex(lastRootMediaIndex); playUiSound("move"); e.preventDefault(); + return; + } + if (topCategory === "all" && gameSubcategory !== "root") { + if (gamesHubOpen) { + playUiSound("move"); + e.preventDefault(); + const snap = gamesHubReturnSnapshotRef.current; + gamesHubReturnSnapshotRef.current = null; + setGamesHubFocusIndex(0); + setGamesHubOpen(false); + if (snap) { + setGameSubcategory(snap.gameSubcategory); + setSelectedGameSubcategoryIndex(snap.selectedGameSubcategoryIndex); + setGamesRootPlane(snap.gamesRootPlane); + setSpotlightIndex(snap.spotlightIndex); + if (snap.restoreSelectedGameId) { + throttledOnSelectGame(snap.restoreSelectedGameId); + } + } + return; + } + setGameSubcategory("root"); + setSelectedGameSubcategoryIndex(lastRootGameIndex); + playUiSound("move"); + e.preventDefault(); + return; } + + // At top-level views, Back/Cancel is intentionally a no-op. + e.preventDefault(); }; const kbdHandler = (e: KeyboardEvent) => { @@ -850,7 +2271,33 @@ export function ControllerLibraryPage({ tertiaryActivateHandler(); return; } + if (e.key.toLowerCase() === "f") { + e.preventDefault(); + secondaryActivateHandler(); + return; + } + if (e.key.toLowerCase() === "o") { + e.preventDefault(); + tertiaryActivateHandler(); + return; + } + if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen) { + e.preventDefault(); + cycleTopCategory(-1); + return; + } + if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen) { + e.preventDefault(); + cycleTopCategory(1); + return; + } if (e.key === "Backspace" || e.key === "Escape") { + if (optionsOpen) { + e.preventDefault(); + setOptionsOpen(false); + playUiSound("move"); + return; + } if (topCategory === "settings" && settingsSubcategory !== "root") { cancelHandler(e); return; @@ -859,20 +2306,32 @@ export function ControllerLibraryPage({ cancelHandler(e); return; } - e.preventDefault(); - if (topCategory === "current" || topCategory === "settings") { - setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); - setSelectedSettingIndex(0); - setSettingsSubcategory("root"); - setSelectedMediaIndex(0); - setMediaSubcategory("root"); - } else { - onOpenSettings?.(); + if (topCategory === "all" && gameSubcategory !== "root") { + cancelHandler(e); + return; + } + + if (inStreamMenu && endSessionConfirm) { + e.preventDefault(); + setEndSessionConfirm(false); + playUiSound("move"); + return; + } + if (inStreamMenu && editingStreamVolume) { + e.preventDefault(); + setEditingStreamVolume(false); + playUiSound("move"); + return; } + + // Top-level back is intentionally a no-op. + e.preventDefault(); + return; } }; 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); @@ -880,13 +2339,89 @@ export function ControllerLibraryPage({ 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); }; - }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, displayItems, mediaAssetItems.length, mediaSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth]); + }, [ + isLoading, + TOP_CATEGORIES.length, + categorizedGames, + selectedIndex, + selectedGame, + selectedGameId, + selectedVariantId, + onPlayGame, + onSelectGameVariant, + onOpenSettings, + onToggleFavoriteGame, + playUiSound, + throttledOnSelectGame, + toggleFavoriteForSelected, + topCategory, + selectedSettingIndex, + selectedMediaIndex, + selectedGameSubcategoryIndex, + displayItems, + mediaAssetItems, + mediaSubcategory, + gameSubcategory, + settings, + settingsBySubcategory, + settingsSubcategory, + lastRootSettingIndex, + lastRootMediaIndex, + lastRootGameIndex, + lastSystemMenuIndex, + lastThemeRootIndex, + onSettingChange, + resolutionOptions, + fpsOptions, + codecOptions, + aspectRatioOptions, + currentStreamingGame, + onResumeGame, + onResumeCloudSession, + onCloseGame, + onExitControllerMode, + onExitApp, + editingBandwidth, + editingThemeChannel, + gamesShelfBrowseActive, + mediaShelfBrowseActive, + topLevelShelfActive, + topLevelRowBehaviorActive, + topLevelShelfIndex, + canEnterDetailRow, + canEnterTopRow, + ps5Row, + detailRailIndex, + detailRailItems, + librarySortId, + optionsOpen, + optionsFocusIndex, + optionsEntries.length, + gamesRootPlane, + spotlightIndex, + spotlightEntries, + gamesDualShelf, + favoriteGameIdSet, + microphoneDevices, + gamesHubOpen, + gamesHubFocusIndex, + gamesHubTiles, + inStreamMenu, + endSessionConfirm, + editingStreamVolume, + streamMenuVolume, + onStreamMenuVolumeChange, + onStreamMenuToggleMicrophone, + onStreamMenuToggleFullscreen, + controllerType, + ]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -906,13 +2441,227 @@ export function ControllerLibraryPage({ : ; }; - const wrapperClassName = `xmb-wrapper ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"}`; - - if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; + const themeStyleSafe = sanitizeControllerThemeStyle(settings.controllerThemeStyle); + const selectedMediaItem = topCategory === "media" && mediaSubcategory !== "root" + ? mediaAssetItems[selectedMediaIndex] ?? null + : null; + const heroBackdropUrl = useMemo(() => { + if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntries.length > 0) { + const cur = spotlightEntries[spotlightIndex]; + if (cur?.kind === "cloudResume" && cur.coverUrl) return cur.coverUrl; + if (spotlightEntryHasGame(cur) && cur.game.imageUrl) return cur.game.imageUrl; + for (const e of spotlightEntries) { + if (e.kind === "cloudResume" && e.coverUrl) return e.coverUrl; + if (e.kind === "recent" && e.game?.imageUrl) return e.game.imageUrl; + } + return null; + } + if (topCategory === "all") return selectedGame?.imageUrl ?? null; + if (topCategory === "current") return currentStreamingGame?.imageUrl ?? null; + if (topCategory === "media") { + if (selectedMediaItem?.thumbnailDataUrl) return selectedMediaItem.thumbnailDataUrl; + if (selectedMediaItem?.dataUrl) return selectedMediaItem.dataUrl; + return selectedMediaItem ? mediaThumbById[selectedMediaItem.id] ?? null : null; + } + if (currentStreamingGame?.imageUrl) return currentStreamingGame.imageUrl; + return selectedGame?.imageUrl ?? null; + }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); + const themeRgbResolved = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const wrapperThemeVars = { + "--xmb-theme-r": String(themeRgbResolved.r), + "--xmb-theme-g": String(themeRgbResolved.g), + "--xmb-theme-b": String(themeRgbResolved.b), + "--xmb-hero-crossfade-ms": `${heroTransitionMs}ms`, + } as React.CSSProperties; + + const wrapperClassName = `xmb-wrapper xmb-theme-${themeStyleSafe} ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"} xmb-layout--ps5-home`; + const wrapperClassNameWithRow = `${wrapperClassName} xmb-row-${ps5Row}`; + + const themeRgbForTrack = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const maxBitrateMbpsForTrack = settings.maxBitrateMbps ?? 75; + const menuShelfTranslateX = gamesDualShelf ? gamesRootMenuTranslateX : listTranslateX; + + const topLevelMenuTrack = useMemo(() => ( +
+ {displayItems.map((item, idx) => { + const isActive = idx === topLevelShelfIndex; + const themeChannelForRow = + item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; + const themeRgbLive = themeRgbForTrack; + const isGameRootTile = topCategory === "all" && gameSubcategory === "root"; + const isCurrentResumeTile = topCategory === "current" && item.id === "resume"; + const previewThumbs = isGameRootTile ? (gameCategoryPreviewById[item.id] ?? []) : []; + return ( +
+ {isCurrentResumeTile ? ( +
+ {currentStreamingGame?.imageUrl ? ( + + ) : ( +
+ )} +
+ Live Snapshot +
+
+ ) : null} + {isGameRootTile ? ( +
+ {previewThumbs.map((src, i) => ( +
+ +
+ ))} + {Array.from({ length: Math.max(0, PREVIEW_TILE_COUNT - previewThumbs.length) }).map((_, i) => ( +
+ ))} +
+ ) : null} +
{item.label}
+ {item.value ? ( +
+ {item.id === "bandwidth" && settingsSubcategory !== "root" ? ( +
+ onSettingChange && onSettingChange("maxBitrateMbps" as any, Number(e.target.value) as any)} + aria-label="Bandwidth Limit (Mbps)" + style={editingBandwidth ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + {`${maxBitrateMbpsForTrack} Mbps`}{editingBandwidth ? " • Editing" : ""} +
+ ) : themeChannelForRow && settingsSubcategory === "ThemeColor" ? ( +
+ + onSettingChange && + onSettingChange("controllerThemeColor", { + ...themeRgbLive, + [themeChannelForRow]: clampRgbByte(Number(e.target.value)), + }) + } + aria-label={`Theme ${item.label}`} + style={editingThemeChannel === themeChannelForRow ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + + {item.value} + {editingThemeChannel === themeChannelForRow ? " • Editing" : ""} + +
+ ) : item.id === "streamVolume" && inStreamMenu ? ( + + {`${Math.round((streamMenuVolume ?? 1) * 100)}%`} + {editingStreamVolume + ? " • Editing ←/→" + : controllerType === "ps" + ? " • □ to adjust" + : " • X to adjust"} + + ) : ( + {item.value} + )} +
+ ) : null} +
+ ); + })} +
+ ), [ + topCategory, + gameSubcategory, + menuShelfTranslateX, + displayItems, + topLevelShelfIndex, + gameCategoryPreviewById, + currentStreamingGame?.imageUrl, + editingBandwidth, + editingThemeChannel, + settingsSubcategory, + onSettingChange, + themeRgbForTrack.r, + themeRgbForTrack.g, + themeRgbForTrack.b, + maxBitrateMbpsForTrack, + inStreamMenu, + streamMenuVolume, + editingStreamVolume, + controllerType, + ]); + + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") { + return ( +
+
+
+
+ +
+ ); + } return ( -
+
+ {inStreamMenu && endSessionConfirm ? ( +
+ End session? Press Enter again to confirm, or Back to cancel. +
+ ) : null}
+ {parallaxBackdropTiles.length > 0 ? ( +
+ {parallaxBackdropTiles.map((tile, idx) => { + return ( +
+ ); + })} +
+ ) : null} + {heroBackdropUrl ? ( +
+ ) : null}
@@ -943,8 +2692,6 @@ export function ControllerLibraryPage({
-
-
+
{getCategoryIcon(cat.id)}
{label}
); })}
- {topCategory !== "settings" && topCategory !== "current" && topCategory !== "media" && ( -
- {categorizedGames.map((game, idx) => { - const isActive = idx === selectedIndex; - const record = playtimeData[game.id]; - const totalSecs = record?.totalSeconds ?? 0; - const lastPlayedAt = record?.lastPlayedAt ?? null; - const sessionCount = record?.sessionCount ?? 0; - const playtimeLabel = formatPlaytime(totalSecs); - const lastPlayedLabel = formatLastPlayed(lastPlayedAt); - const genres = game.genres?.slice(0, 2) ?? []; - const tierLabel = game.membershipTierLabel; - - return ( -
- {favoriteGameIdSet.has(game.id) && ( - - )} -
- {game.title} + {topCategory === "all" && gameSubcategory !== "root" && gamesHubOpen && selectedGame ? ( + + ) : null} + + {topCategory === "all" && gameSubcategory !== "root" && !gamesHubOpen && ( +
+ {!isLoading && categorizedGames.length === 0 ? ( +
+

No games here

+

Try another category or refresh your library.

-
-
{game.title}
- -
- {(() => { - const vId = selectedVariantByGameId[game.id] || game.variants[0]?.id; - const variant = game.variants.find(v => v.id === vId) || game.variants[0]; - const storeName = getStoreDisplayName(variant?.store || ""); - return storeName ? ( - {storeName} - ) : null; - })()} - - - - {playtimeLabel} - - - - - {lastPlayedLabel} - -
- - {isActive && ( -
- {sessionCount > 0 && ( - - - {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + ) : selectedGame ? ( +
+

{selectedGame.title}

+
+ Game hub + Options +
+
+ {gameSubcategory === "all" ? ( + Sort: {LIBRARY_SORT_LABEL[librarySortId]} + ) : null} + {(() => { + const record = playtimeData[selectedGame.id]; + const totalSecs = record?.totalSeconds ?? 0; + const lastPlayedAt = record?.lastPlayedAt ?? null; + const sessionCount = record?.sessionCount ?? 0; + const playtimeLabel = formatPlaytime(totalSecs); + const lastPlayedLabel = formatLastPlayed(lastPlayedAt); + const vId = selectedVariantByGameId[selectedGame.id] || selectedGame.variants[0]?.id; + const variant = selectedGame.variants.find((v) => v.id === vId) || selectedGame.variants[0]; + const storeName = getStoreDisplayName(variant?.store || ""); + const genres = selectedGame.genres?.slice(0, 3) ?? []; + const tierLabel = selectedGame.membershipTierLabel; + return ( + <> + {storeName ? {storeName} : null} + + + {playtimeLabel} - )} - {genres.map((g) => ( - {sanitizeGenreName(g)} - ))} - {tierLabel && ( - {tierLabel} - )} -
- )} + + + {lastPlayedLabel} + + {sessionCount > 0 ? ( + + + {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + + ) : null} + {genres.map((g) => ( + + {sanitizeGenreName(g)} + + ))} + {tierLabel ? {tierLabel} : null} + + ); + })()}
- ); - })} -
- )} - - {(topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root")) && ( -
- {displayItems.map((item, idx) => { - const isActive = idx === (topCategory === "media" ? selectedMediaIndex : selectedSettingIndex); - const isSubcategoryItem = settingsSubcategory === "root" && (item.id === "network" || item.id === "audio" || item.id === "video" || item.id === "system"); - const isMediaSubcategoryItem = topCategory === "media" && mediaSubcategory === "root" && (item.id === "videos" || item.id === "screenshots"); - return ( -
+ Games +
+
+
-
-
{item.label}
- {item.value && ( -
- {item.id === 'bandwidth' && settingsSubcategory !== 'root' ? ( -
- onSettingChange && onSettingChange("maxBitrateMbps" as any, Number(e.target.value) as any)} - aria-label="Bandwidth Limit (Mbps)" - style={editingBandwidth ? {outline: '2px solid rgba(255,255,255,0.2)'} : undefined} - /> - {`${settings.maxBitrateMbps ?? 75} Mbps`}{editingBandwidth ? ' • Editing' : ''} + {!isLoading && categorizedGames.length === 0 + ? Array.from({ length: 6 }).map((_, idx) => ( +
+
+
+ )) + : categorizedGames.map((game, idx) => { + const isActive = idx === selectedIndex; + return ( +
+ {favoriteGameIdSet.has(game.id) ? : null} +
+ {game.imageUrl ? :
} +
- ) : ( - {item.value} - )} + ); + })} +
+
+
+ )} + + {topLevelShelfActive && ( +
+
+

{selectedTopLevelItemLabel}

+ {topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" ? ( +

+ {(() => { + const se = spotlightEntries[spotlightIndex]; + if (se?.kind === "cloudResume") { + return se.busy + ? "Resuming your cloud session…" + : "Active cloud session · Enter continues from where you left off"; + } + if (spotlightEntryHasGame(se)) { + return "Recently played · Enter opens this title in your library"; + } + return "Recently played · Empty slot — play games to fill your shelf"; + })()} +

+ ) : null} + {topCategory === "current" && displayItems[topLevelShelfIndex]?.id === "resume" && currentStreamingGame ? ( +
+ {(() => { + const record = playtimeData[currentStreamingGame.id]; + const totalSecs = record?.totalSeconds ?? 0; + const lastPlayedAt = record?.lastPlayedAt ?? null; + const sessionCount = record?.sessionCount ?? 0; + return ( + <> + + + {formatPlaytime(totalSecs)} + + + + {formatLastPlayed(lastPlayedAt)} + + {sessionCount > 0 ? ( + + + {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + + ) : null} + + ); + })()} +
+ ) : null} +
+ {gamesDualShelf ? ( +
+
+
+ + {cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"} + +
+
+
+ {spotlightEntries.map((entry, idx) => { + const isActive = gamesRootPlane === "spotlight" && idx === spotlightIndex; + if (entry.kind === "cloudResume") { + return ( +
+
+ {entry.coverUrl ? ( + + ) : ( +
+ )} +
+ {entry.busy ? "Connecting…" : "Resume"} +
+
+
+ ); + } + const game = entry.game; + const key = game ? game.id : `recent-empty-${idx}`; + return ( +
+
+ {game?.imageUrl ? :
} +
+
+ ); + })}
- )} +
+
+
+
+ Library +
+
{topLevelMenuTrack}
- ); - })} -
+ ) : ( + <> +
+ + {topCategory === "current" ? "Current" : topCategory === "settings" ? "Settings" : topCategory === "all" ? "Library" : "Media"} + +
+
{topLevelMenuTrack}
+ + )} +
)} {topCategory === "media" && mediaSubcategory !== "root" && ( -
- {mediaLoading && ( -
-
-
Loading {mediaSubcategory}...
+
+
+

+ {selectedMediaItem?.gameTitle || selectedMediaItem?.fileName || mediaSubcategory} +

+
+ Open Folder + Options
- )} - - {!mediaLoading && mediaError && ( -
-
-
{mediaError}
-
+
+ Captures
- )} +
+
+ {mediaLoading && Array.from({ length: 8 }).map((_, idx) => ( +
+
+
Loading {mediaSubcategory}...
+
+ ))} - {!mediaLoading && !mediaError && mediaAssetItems.length === 0 && ( -
-
-
No {mediaSubcategory.toLowerCase()} found
-
-
- )} + {!mediaLoading && mediaError && ( +
+
+
{mediaError}
+
+ )} - {!mediaLoading && !mediaError && mediaAssetItems.map((item, idx) => { - const isActive = idx === selectedMediaIndex; - const thumb = mediaThumbById[item.id]; - const dateLabel = new Date(item.createdAtMs).toLocaleDateString(); - const durationMs = item.durationMs ?? 0; - const hasDuration = durationMs > 0; - const durationLabel = hasDuration ? `${Math.max(1, Math.round(durationMs / 1000))}s` : "Screenshot"; - - return ( -
-
- {thumb ? {item.gameTitle :
} -
-
-
{item.gameTitle || item.fileName}
-
- {durationLabel} - {dateLabel} + {!mediaLoading && !mediaError && mediaAssetItems.length === 0 && Array.from({ length: 6 }).map((_, idx) => ( +
+
+
+ {idx === 0 ? `No ${mediaSubcategory.toLowerCase()} found` : "Capture more to fill this shelf"} +
-
+ ))} + + {!mediaLoading && !mediaError && mediaAssetItems.map((item, idx) => { + const isActive = idx === selectedMediaIndex; + const thumb = mediaThumbById[item.id]; + const dateLabel = new Date(item.createdAtMs).toLocaleDateString(); + const durationMs = item.durationMs ?? 0; + const hasDuration = durationMs > 0; + const durationLabel = hasDuration ? `${Math.max(1, Math.round(durationMs / 1000))}s` : "Screenshot"; + + return ( +
+
+ {thumb ? :
} +
+
{item.gameTitle || item.fileName}
+
+ {durationLabel} + {dateLabel} +
+
+ ); + })}
- ); - })} -
+
+
)}
@@ -1214,8 +3093,55 @@ export function ControllerLibraryPage({ )}
+ {ps5Row === "detail" && canEnterDetailRow && detailRailItems.length > 0 && ( +
+ {detailRailItems.map((item, idx) => ( +
+
+ {item.imageUrl ? ( + + ) : ( +
+ )} +
+
{item.title}
+
{item.subtitle}
+
+ ))} +
+ )} + + {optionsOpen && optionsEntries.length > 0 ? ( +
+
+
+
Options
+
    + {optionsEntries.map((opt, i) => ( +
  • + {opt.label} +
  • + ))} +
+
+
+ ) : null} +
- {topCategory === "current" ? ( + {topLevelRowBehaviorActive ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+
L1 Prev Section
+
R1 Next Section
+ + ) : topCategory === "current" ? ( <>
{controllerType === "ps" ? ( @@ -1239,6 +3165,53 @@ export function ControllerLibraryPage({ Enter
+ ) : settingsSubcategory === "Theme" ? ( +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Enter +
+ ) : settingsSubcategory === "ThemeStyle" ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Back +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+ + ) : settingsSubcategory === "ThemeColor" ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Back +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + {editingThemeChannel ? "Confirm" : "Adjust"} +
+ ) : ( <>
@@ -1273,6 +3246,9 @@ export function ControllerLibraryPage({
) : ( <> +
+ Browse · Left / Right +
{controllerType === "ps" ? ( @@ -1281,26 +3257,92 @@ export function ControllerLibraryPage({ )} Open Folder
+
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
{controllerType === "ps" ? ( ) : ( )} - Back + Back To Media
)} + ) : topCategory === "all" && gameSubcategory !== "root" ? ( + gamesHubOpen ? ( + <> +
+ Actions · Left / Right +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Confirm +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Back +
+
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
+ + ) : ( + <> +
+ Browse · Left / Right +
+
+ Library filters · Up +
+
{renderFaceButton("primary", "xmb-btn-icon", 24)} Game hub
+
+ Hub · Down +
+ {gameSubcategory === "all" ? ( +
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Sort
+ ) : null} +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
+ + ) + ) : topCategory === "all" && gameSubcategory === "root" ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + + {gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + ? spotlightEntries[spotlightIndex].busy + ? "Please wait" + : "Resume session" + : gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ? "View in library" + : "Enter"} + +
+ {gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? ( +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
+ ) : null} +
+ L1 Prev Section +
+
+ R1 Next Section +
+ ) : ( <>
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
- {selectedGame?.variants.length && selectedGame.variants.length > 1 && ( -
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Variant
- )} - {selectedGame && ( -
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"}
- )} +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
)}
diff --git a/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx b/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx index ed3b541d9..a21399b6e 100644 --- a/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx @@ -1,21 +1,15 @@ -import { Loader2, Zap } from "lucide-react"; import type { JSX, Ref } from "react"; import { getPreferredSessionAdMediaUrl, - getSessionAdDurationMs, - getSessionAdGracePeriodSeconds, getSessionAdMessage, isSessionQueuePaused, } from "@shared/gfn"; import type { SessionAdInfo, SessionAdState } from "@shared/gfn"; -import { formatPlaytime } from "../utils/usePlaytime"; -import type { PlaytimeStore } from "../utils/usePlaytime"; import { QueueAdPreview, type QueueAdPlaybackEvent, type QueueAdPreviewHandle } from "./QueueAdPreview"; +import { Ps5ThreeDots } from "./Ps5LoadingScreen"; export interface ControllerStreamLoadingProps { gameTitle: string; - gamePoster?: string; - gameDescription?: string; status: "queue" | "setup" | "starting" | "connecting"; queuePosition?: number; adState?: SessionAdState; @@ -28,9 +22,6 @@ export interface ControllerStreamLoadingProps { }; onAdPlaybackEvent?: (event: QueueAdPlaybackEvent, adId: string) => void; adPreviewRef?: Ref; - playtimeData?: PlaytimeStore; - gameId?: string; - enableBackgroundAnimations?: boolean; } function getStatusMessage( @@ -49,32 +40,14 @@ function getStatusMessage( case "starting": return "Starting stream..."; case "connecting": - return "Connecting to server..."; + return "Setting up stream..."; default: return "Loading..."; } } -function getStatusPhase( - status: ControllerStreamLoadingProps["status"], -): "queue" | "setup" | "launching" { - switch (status) { - case "queue": - return "queue"; - case "setup": - return "setup"; - case "starting": - case "connecting": - return "launching"; - default: - return "queue"; - } -} - export function ControllerStreamLoading({ gameTitle, - gamePoster, - gameDescription, status, queuePosition, adState, @@ -83,135 +56,52 @@ export function ControllerStreamLoading({ error, onAdPlaybackEvent, adPreviewRef, - playtimeData = {}, - gameId, - enableBackgroundAnimations = false, }: ControllerStreamLoadingProps): JSX.Element { const statusMessage = getStatusMessage(status, queuePosition, adState); - const statusPhase = getStatusPhase(status); - const playtimeRecord = gameId ? playtimeData[gameId] : undefined; - const totalSecs = playtimeRecord?.totalSeconds ?? 0; - const playtimeLabel = formatPlaytime(totalSecs); const cachedAdMediaUrl = activeAdMediaUrl ?? getPreferredSessionAdMediaUrl(activeAd); - const adDurationMs = getSessionAdDurationMs(activeAd); - const adDurationSeconds = adDurationMs ? Math.round(adDurationMs / 1000) : undefined; const adMessage = getSessionAdMessage(adState) ?? (isSessionQueuePaused(adState) ? "Resume ads to stay in queue." : undefined); - const gracePeriodSeconds = getSessionAdGracePeriodSeconds(adState); const hasError = Boolean(error); return (
- {enableBackgroundAnimations && ( -
-
-
-
-
-
- )} - {/* Fade-to-black backdrop */}
- {/* Content fade-in layer */}
- {/* Left side: Game Poster */} -
- {gamePoster ? ( - {gameTitle} - ) : ( -
- -
- )} -
- - {/* Right side: Game Info and Status */}
- {/* Game Title */} -
-

{gameTitle}

+
+
- {/* Game Description */} - {gameDescription && ( -
-

{gameDescription}

-
- )} +
+

{gameTitle}

+

{statusMessage}

+
- {/* Playtime */} - {playtimeLabel !== "0h" && ( -
- Playtime: - {playtimeLabel} + {hasError && error ? ( +
+
{error.title}
+
{error.description}
+ {error.code ?
{error.code}
: null}
- )} - - {/* Network Status Section */} -
-
{statusMessage}
- - {!hasError && activeAd && cachedAdMediaUrl && ( -
-
- Ad Queue - {adMessage &&
{adMessage}
} -
-
- onAdPlaybackEvent?.(event, activeAd.adId)} - /> -
-
- )} - - {hasError && error && ( -
-
{error.title}
-
{error.description}
- {error.code &&
{error.code}
} -
- )} + ) : null} - {/* Status Progress Indicator */} -
-
- - Queue + {!hasError && activeAd && cachedAdMediaUrl ? ( +
+
+ Ad Queue + {adMessage ?
{adMessage}
: null}
- -
- -
- - Setup -
- -
- -
- - Launching +
+ onAdPlaybackEvent?.(event, activeAd.adId)} + />
- - {/* Loading Spinner */} -
- -
-
+ ) : null}
diff --git a/opennow-stable/src/renderer/src/components/Ps5LoadingScreen.tsx b/opennow-stable/src/renderer/src/components/Ps5LoadingScreen.tsx new file mode 100644 index 000000000..9d0f93bb6 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/Ps5LoadingScreen.tsx @@ -0,0 +1,73 @@ +import type { JSX } from "react"; + +export interface Ps5ThreeDotsProps { + /** Default matches PS5 system loader scale; `lg` for full-screen */ + size?: "md" | "lg"; + className?: string; +} + +/** + * PS5 system-style loader: three horizontal white dots with a sequential pulse + * (matches the console “please wait” indicator, not a themed accent ring). + */ +export function Ps5ThreeDots({ size = "md", className = "" }: Ps5ThreeDotsProps): JSX.Element { + const dim = size === "lg" ? "ps5-load-dots--lg" : ""; + return ( +
+ + + +
+ ); +} + +/** @deprecated Alias for {@link Ps5ThreeDots} — PS5 uses three dots, not spinner arcs */ +export const Ps5LoadingSpinner = Ps5ThreeDots; +export type Ps5LoadingSpinnerProps = Ps5ThreeDotsProps; + +export interface Ps5LoadingScreenProps { + /** Used for `aria-label` */ + title?: string; + /** Optional second line under the dots (hybrid PS5 + minimal copy) */ + subtitle?: string; + /** Optional blurred backdrop (e.g. game art), heavily darkened like the console */ + backdropImageUrl?: string | null; + className?: string; +} + +/** + * Full-viewport PS5-style wait: black field, optional dimmed art, three-dot loader, + * optional visible subtitle under dots (matches controller stream loading hybrid). + */ +export function Ps5LoadingScreen({ + title = "Loading", + subtitle, + backdropImageUrl, + className = "", +}: Ps5LoadingScreenProps): JSX.Element { + const announced = subtitle ? `${title}. ${subtitle}` : title; + + return ( +
+ {backdropImageUrl ? ( +
+ ) : null} + +
+ +
+ +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index db17839a2..822e141a3 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -2607,18 +2607,6 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, {settings.controllerMode && (
-
- -
- -
-
-