From adb9e96cc77b84b786fed7c38dce1b6981f8a44d Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 11:43:11 -0500 Subject: [PATCH 01/16] feat(settings): add controller theme customization options - Introduced new settings for controller theme style and color, allowing users to customize the background visuals in controller mode. - Added functions to normalize and clamp theme color values. - Updated the SettingsManager to handle migrations for the new theme settings. - Enhanced the ControllerLibraryPage to utilize the new theme settings for improved visual consistency. - Updated styles to reflect the new theme options, including multiple preset styles. This enhancement improves user experience by providing more personalization options for the controller interface. --- opennow-stable/src/main/settings.ts | 53 ++- opennow-stable/src/renderer/src/App.tsx | 6 + .../src/components/ControllerLibraryPage.tsx | 399 ++++++++++++++++-- opennow-stable/src/renderer/src/styles.css | 162 ++++++- opennow-stable/src/shared/gfn.ts | 14 + 5 files changed, 571 insertions(+), 63 deletions(-) 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..04c10eac3 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -845,6 +845,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: [], @@ -4102,6 +4104,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, @@ -4276,6 +4280,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/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 67a8eb2c6..fe2cb7650 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; -import type { GameInfo, MediaListingEntry, Settings } from "@shared/gfn"; +import type { GameInfo, MediaListingEntry, Settings, ControllerThemeStyle } from "@shared/gfn"; import { Star, Clock, Calendar, Repeat2 } from "lucide-react"; import { ButtonA, ButtonB, ButtonX, ButtonY, ButtonPSCross, ButtonPSCircle, ButtonPSSquare, ButtonPSTriangle } from "./ControllerButtons"; import { getStoreDisplayName } from "./GameCard"; @@ -42,6 +42,8 @@ interface ControllerLibraryPageProps { aspectRatio?: string; posterSizeScale?: number; maxBitrateMbps?: number; + controllerThemeStyle?: ControllerThemeStyle; + controllerThemeColor?: { r: number; g: number; b: number }; }; resolutionOptions?: string[]; fpsOptions?: number[]; @@ -55,14 +57,33 @@ interface ControllerLibraryPageProps { } 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}`; const CATEGORY_STEP_PX = 160; const CATEGORY_ACTIVE_HALF_WIDTH_PX = 60; const GAME_ACTIVE_CENTER_OFFSET_X_PX = 320; +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 .replace(/_/g, " ") @@ -77,25 +98,10 @@ 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" }; } @@ -133,13 +139,12 @@ export function ControllerLibraryPage({ }: 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); @@ -167,6 +172,10 @@ export function ControllerLibraryPage({ }; const [listTranslateY, setListTranslateY] = useState(0); 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 +183,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 +193,9 @@ 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); useEffect(() => { if (typeof window === "undefined") { @@ -297,12 +312,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; @@ -319,6 +332,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,8 +362,23 @@ 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]); @@ -367,12 +398,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 +495,26 @@ 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]); + 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, favoriteGames, gameSubcategory, topCategory, playtimeData]); const selectedIndex = useMemo(() => { const index = categorizedGames.findIndex((game) => game.id === selectedGameId); @@ -516,6 +572,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 @@ -542,6 +617,10 @@ export function ControllerLibraryPage({ setSettingsSubcategory("root"); setSelectedMediaIndex(0); setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); + setEditingBandwidth(false); + setEditingThemeChannel(null); return; } if (direction === "right") { @@ -552,6 +631,10 @@ export function ControllerLibraryPage({ setSettingsSubcategory("root"); setSelectedMediaIndex(0); setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); + setEditingBandwidth(false); + setEditingThemeChannel(null); return; } if (topCategory === "current" || topCategory === "settings") { @@ -594,6 +677,27 @@ export function ControllerLibraryPage({ } 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; + } if (categorizedGames.length === 0) return; if (direction === "up") { const nextIndex = Math.max(0, selectedIndex - 1); @@ -624,6 +728,11 @@ export function ControllerLibraryPage({ playUiSound("confirm"); return; } + if (topCategory === "settings" && settingsSubcategory === "ThemeColor" && editingThemeChannel) { + setEditingThemeChannel(null); + playUiSound("confirm"); + return; + } if (topCategory === "current") { const item = displayItems[selectedSettingIndex]; if (item?.id === "resume" && currentStreamingGame && onResumeGame) { @@ -660,6 +769,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 +844,21 @@ export function ControllerLibraryPage({ } playUiSound("confirm"); + } else if (topCategory === "all") { + if (gameSubcategory === "root") { + const item = displayItems[selectedGameSubcategoryIndex]; + if (item) { + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory(item.id as GameSubcategory); + setSelectedGameSubcategoryIndex(0); + playUiSound("confirm"); + } + return; + } + if (selectedGame) { + onPlayGame(selectedGame); + playUiSound("confirm"); + } } else if (selectedGame) { onPlayGame(selectedGame); playUiSound("confirm"); @@ -710,6 +871,7 @@ export function ControllerLibraryPage({ 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 +879,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; @@ -784,7 +956,7 @@ export function ControllerLibraryPage({ }; const tertiaryActivateHandler = () => { - if (topCategory !== "settings" && topCategory !== "current") { + if (topCategory !== "settings" && topCategory !== "current" && !(topCategory === "all" && gameSubcategory === "root")) { toggleFavoriteForSelected(); } }; @@ -799,6 +971,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,6 +1009,13 @@ export function ControllerLibraryPage({ setSelectedMediaIndex(lastRootMediaIndex); playUiSound("move"); e.preventDefault(); + return; + } + if (topCategory === "all" && gameSubcategory !== "root") { + setGameSubcategory("root"); + setSelectedGameSubcategoryIndex(lastRootGameIndex); + playUiSound("move"); + e.preventDefault(); } }; @@ -859,6 +1065,10 @@ export function ControllerLibraryPage({ cancelHandler(e); return; } + if (topCategory === "all" && gameSubcategory !== "root") { + cancelHandler(e); + return; + } e.preventDefault(); if (topCategory === "current" || topCategory === "settings") { setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); @@ -866,6 +1076,8 @@ export function ControllerLibraryPage({ setSettingsSubcategory("root"); setSelectedMediaIndex(0); setMediaSubcategory("root"); + setSelectedGameSubcategoryIndex(0); + setGameSubcategory("root"); } else { onOpenSettings?.(); } @@ -886,7 +1098,7 @@ export function ControllerLibraryPage({ 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, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -906,12 +1118,20 @@ export function ControllerLibraryPage({ : ; }; - const wrapperClassName = `xmb-wrapper ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"}`; + const themeStyleSafe = sanitizeControllerThemeStyle(settings.controllerThemeStyle); + 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), + } as React.CSSProperties; + + const wrapperClassName = `xmb-wrapper xmb-theme-${themeStyleSafe} ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"}`; - if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; return ( -
+
@@ -962,7 +1182,7 @@ export function ControllerLibraryPage({ })}
- {topCategory !== "settings" && topCategory !== "current" && topCategory !== "media" && ( + {topCategory === "all" && gameSubcategory !== "root" && (
)} - {(topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root")) && ( + {(topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")) && (
{displayItems.map((item, idx) => { - const isActive = idx === (topCategory === "media" ? selectedMediaIndex : selectedSettingIndex); + const isActive = idx === (topCategory === "media" ? selectedMediaIndex : topCategory === "all" ? selectedGameSubcategoryIndex : 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"); + const isGameSubcategoryItem = topCategory === "all" && gameSubcategory === "root"; + const isThemeNavItem = + topCategory === "settings" && + ((settingsSubcategory === "Theme" && (item.id === "themeColor" || item.id === "themeStyle")) || + (settingsSubcategory === "System" && item.id === "theme")); + const themeChannelForRow = + item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; + const themeRgbLive = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; return (
{item.label}
@@ -1078,6 +1306,29 @@ export function ControllerLibraryPage({ /> {`${settings.maxBitrateMbps ?? 75} 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.value} )} @@ -1239,6 +1490,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"} +
+ ) : ( <>
@@ -1292,6 +1590,15 @@ export function ControllerLibraryPage({ )} + ) : topCategory === "all" && gameSubcategory === "root" ? ( +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Enter +
) : ( <>
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 962f8d8ec..b6ce97e57 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6567,20 +6567,28 @@ button.game-card-store-chip.owned.active:hover { .xmb-wrapper { position: fixed; inset: 0; - background: #000; + background: #07090c; color: #fff; font-family: inherit; overflow: hidden; z-index: 2000; display: flex; flex-direction: column; + /* Controller theme tint (overridden inline from settings) */ + --xmb-theme-r: 124; + --xmb-theme-g: 241; + --xmb-theme-b: 177; } .xmb-wrapper::after { content: ""; position: absolute; inset: 0; - background: radial-gradient(90% 90% at 50% 50%, rgba(179, 255, 215, 0.08), rgba(0, 0, 0, 0.48) 72%); + background: radial-gradient( + 90% 90% at 50% 50%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.12), + rgba(0, 0, 0, 0.34) 72% + ); opacity: 0; pointer-events: none; z-index: 0; @@ -6632,8 +6640,19 @@ button.game-card-store-chip.owned.active:hover { inset: 0; z-index: -1; background: - radial-gradient(118% 72% at 50% -10%, rgba(188, 255, 223, 0.22) 0%, rgba(124, 241, 177, 0.08) 28%, rgba(7, 17, 30, 0) 56%), - linear-gradient(180deg, #081711 0%, #06110d 32%, #030906 68%, #010302 100%); + radial-gradient( + 118% 72% at 50% -10%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.32) 0%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.14) 28%, + rgba(20, 28, 34, 0) 56% + ), + linear-gradient( + 180deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.16) 0%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.09) 30%, + #121c18 62%, + #0a1210 100% + ); } @@ -6641,13 +6660,29 @@ button.game-card-store-chip.owned.active:hover { position: absolute; inset: -16%; background: - linear-gradient(118deg, transparent 21%, rgba(215, 255, 232, 0.08) 28%, rgba(215, 255, 232, 0.22) 31%, rgba(112, 231, 167, 0.18) 34%, rgba(215, 255, 232, 0.06) 38%, transparent 44%), - linear-gradient(128deg, transparent 30%, rgba(162, 255, 206, 0.06) 37%, rgba(242, 255, 248, 0.2) 40%, rgba(112, 231, 167, 0.16) 44%, rgba(242, 255, 248, 0.05) 48%, transparent 54%), - radial-gradient(circle at 52% 48%, rgba(199, 255, 225, 0.06), transparent 34%); + linear-gradient( + 118deg, + transparent 21%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.12) 28%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.3) 31%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.22) 34%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.1) 38%, + transparent 44% + ), + linear-gradient( + 128deg, + transparent 30%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.1) 37%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.26) 40%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.18) 44%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.08) 48%, + transparent 54% + ), + radial-gradient(circle at 52% 48%, rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.1), transparent 34%); background-size: 140% 140%, 155% 155%, 100% 100%; background-repeat: no-repeat; mix-blend-mode: screen; - opacity: 0.88; + opacity: 0.96; transform-origin: center center; /* default: animations disabled; enabled via xmb-animate wrapper */ animation: none; @@ -6664,19 +6699,34 @@ button.game-card-store-chip.owned.active:hover { .xmb-bg-gradient::before { background: - radial-gradient(76% 12% at 30% 54%, rgba(241, 255, 248, 0.34) 0%, rgba(241, 255, 248, 0.2) 18%, rgba(241, 255, 248, 0) 58%), - radial-gradient(84% 14% at 67% 60%, rgba(133, 247, 187, 0.22) 0%, rgba(133, 247, 187, 0.12) 16%, rgba(133, 247, 187, 0) 60%); + radial-gradient( + 76% 12% at 30% 54%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.42) 0%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.26) 18%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0) 58% + ), + radial-gradient( + 84% 14% at 67% 60%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.28) 0%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.16) 16%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0) 60% + ); mix-blend-mode: screen; - opacity: 0.82; + opacity: 0.88; transform: rotate(-8deg) scale(1.06); animation: none; } .xmb-bg-gradient::after { background: - radial-gradient(78% 12% at 58% 40%, rgba(169, 255, 212, 0.2) 0%, rgba(169, 255, 212, 0.11) 18%, rgba(169, 255, 212, 0) 58%), - radial-gradient(70% 10% at 41% 70%, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.08) 16%, rgba(255, 255, 255, 0) 52%); - opacity: 0.52; + radial-gradient( + 78% 12% at 58% 40%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.26) 0%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.14) 18%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0) 58% + ), + radial-gradient(70% 10% at 41% 70%, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.1) 16%, rgba(255, 255, 255, 0) 52%); + opacity: 0.62; transform: rotate(6deg) scale(1.12); animation: none; } @@ -6732,8 +6782,88 @@ button.game-card-store-chip.owned.active:hover { position: absolute; inset: 0; background: - linear-gradient(180deg, rgba(2, 6, 4, 0.42) 0%, rgba(2, 6, 4, 0.08) 22%, rgba(2, 6, 4, 0.05) 74%, rgba(0, 0, 0, 0.46) 100%), - linear-gradient(90deg, rgba(0, 0, 0, 0.12) 0%, rgba(0, 0, 0, 0) 24%, rgba(0, 0, 0, 0) 76%, rgba(0, 0, 0, 0.16) 100%); + linear-gradient( + 180deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.1) 0%, + rgba(12, 18, 22, 0.06) 28%, + rgba(8, 12, 14, 0.04) 72%, + rgba(0, 0, 0, 0.28) 100% + ), + linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0) 24%, rgba(0, 0, 0, 0) 76%, rgba(0, 0, 0, 0.1) 100%); +} + +/* --- Controller theme style presets (see Settings → System → Theme → Style) --- */ + +.xmb-theme-nebula .xmb-bg-layer { + background: + radial-gradient( + 92% 85% at 18% 18%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.38) 0%, + transparent 58% + ), + radial-gradient( + 78% 75% at 88% 72%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.24) 0%, + transparent 52% + ), + radial-gradient( + circle at 48% 42%, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.12) 0%, + transparent 42% + ), + linear-gradient(215deg, #161228 0%, #101018 52%, #0a0a12 100%); +} + +.xmb-theme-nebula .xmb-bg-gradient { + opacity: 0.84; +} + +.xmb-theme-grid .xmb-bg-overlay { + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 56px), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 56px), + linear-gradient( + 180deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.1) 0%, + rgba(0, 0, 0, 0.22) 45%, + rgba(0, 0, 0, 0.38) 100% + ), + linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0) 28%, rgba(0, 0, 0, 0) 72%, rgba(0, 0, 0, 0.12) 100%); +} + +.xmb-theme-minimal .xmb-bg-layer { + background: linear-gradient( + 185deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.22) 0%, + #161a1e 52%, + #0c0e12 100% + ); +} + +.xmb-theme-minimal .xmb-bg-gradient, +.xmb-theme-minimal .xmb-bg-gradient::before, +.xmb-theme-minimal .xmb-bg-gradient::after { + opacity: 0.46; +} + +@keyframes xmb-theme-pulse-layer { + 0%, + 100% { + opacity: 1; + filter: saturate(1) brightness(1); + } + 50% { + opacity: 0.93; + filter: saturate(1.12) brightness(1.06); + } +} + +.xmb-theme-pulse .xmb-bg-layer { + animation: xmb-theme-pulse-layer 11s ease-in-out infinite; +} + +.xmb-theme-pulse.xmb-animate .xmb-bg-gradient { + animation: xmb-ribbon-pan 30s linear infinite, xmb-theme-pulse-layer 11s ease-in-out infinite; } .xmb-categories-container { diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index a4843d51a..27ba7215c 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -98,6 +98,16 @@ export function colorQualityIs10Bit(cq: ColorQuality): boolean { return cq.startsWith("10bit"); } +/** Controller-mode XMB background visual preset */ +export type ControllerThemeStyle = "aurora" | "nebula" | "grid" | "minimal" | "pulse"; + +/** RGB tint for controller-mode background (0–255 each) */ +export interface ControllerThemeRgb { + r: number; + g: number; + b: number; +} + export type MicrophoneMode = "disabled" | "push-to-talk" | "voice-activity"; export type AspectRatio = "16:9" | "16:10" | "21:9" | "32:9"; export type RuntimePlatform = @@ -159,6 +169,10 @@ export interface Settings { autoLoadControllerLibrary: boolean; /** When true, controller-mode overlays will show animated background orbs */ controllerBackgroundAnimations: boolean; + /** Controller-mode library background visual preset */ + controllerThemeStyle: ControllerThemeStyle; + /** Controller-mode library background tint (applied per style preset) */ + controllerThemeColor: ControllerThemeRgb; /** When true, the app will automatically enter fullscreen when controller mode triggers it */ autoFullScreen: boolean; favoriteGameIds: string[]; From 78ed48fd74df4e747e364d09a28033c418b136a7 Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 11:47:04 -0500 Subject: [PATCH 02/16] refactor(styles): update theme colors in CSS for improved consistency - Replaced hardcoded color values with theme-based RGB variables for better customization. - Simplified CSS selectors by removing unnecessary classes and consolidating animations. - Enhanced visual elements in the ControllerLibraryPage to align with the new theme settings. These changes improve the maintainability of the styles and ensure a cohesive look across the application. --- .../src/components/ControllerLibraryPage.tsx | 2 - opennow-stable/src/renderer/src/styles.css | 41 +++++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index fe2cb7650..e38613e01 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -1163,8 +1163,6 @@ export function ControllerLibraryPage({
-
-
Date: Sat, 2 May 2026 12:01:33 -0500 Subject: [PATCH 03/16] feat(styles): implement PS5-style layout and animations for game hub - Added new CSS styles for the PS5-themed games hub, including blurred hero art and a horizontal shelf layout. - Enhanced animations for various components within the XMB wrapper to improve visual transitions. - Introduced new class selectors for game items, categories, and menu tiles to align with the PS5 aesthetic. These changes enhance the user interface and provide a more immersive experience for users navigating the game hub. --- .../src/components/ControllerLibraryPage.tsx | 530 ++++++++++++------ opennow-stable/src/renderer/src/styles.css | 274 +++++++++ 2 files changed, 633 insertions(+), 171 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index e38613e01..acf1fb606 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -65,6 +65,7 @@ type GameSubcategory = "root" | "all" | "favorites" | `genre:${string}`; const CATEGORY_STEP_PX = 160; const CATEGORY_ACTIVE_HALF_WIDTH_PX = 60; const GAME_ACTIVE_CENTER_OFFSET_X_PX = 320; +const PREVIEW_TILE_COUNT = 12; const CONTROLLER_THEME_STYLE_ORDER: readonly ControllerThemeStyle[] = ["aurora", "nebula", "grid", "minimal", "pulse"]; @@ -171,6 +172,10 @@ export function ControllerLibraryPage({ } }; const [listTranslateY, setListTranslateY] = useState(0); + const [listTranslateX, setListTranslateX] = 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)), @@ -516,6 +521,52 @@ export function ControllerLibraryPage({ }); }, [games, favoriteGames, gameSubcategory, topCategory, playtimeData]); + 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 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 selectedIndex = useMemo(() => { const index = categorizedGames.findIndex((game) => game.id === selectedGameId); return index >= 0 ? index : 0; @@ -532,6 +583,20 @@ export function ControllerLibraryPage({ const showCurrentDetail = topCategory === "current" && Boolean(currentStreamingGame); const detailVisible = showCurrentDetail; + const gamesShelfBrowseActive = topCategory === "all" && gameSubcategory !== "root"; + const topLevelShelfActive = + !gamesShelfBrowseActive && + (topCategory === "settings" || + topCategory === "current" || + (topCategory === "media" && mediaSubcategory === "root") || + (topCategory === "all" && gameSubcategory === "root")); + const topLevelShelfIndex = + topCategory === "media" + ? selectedMediaIndex + : topCategory === "all" + ? selectedGameSubcategoryIndex + : selectedSettingIndex; + const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory, currentStreamingGame?.title).label, [topCategory, currentStreamingGame?.title]); const selectedGameDescription = useMemo(() => { if (!selectedGame) return ""; @@ -547,11 +612,42 @@ export function ControllerLibraryPage({ + useEffect(() => { + if (!gamesShelfBrowseActive && !topLevelShelfActive) setListTranslateX(0); + }, [gamesShelfBrowseActive, topLevelShelfActive]); + + useEffect(() => { + if (typeof window === "undefined") return; + const onResize = () => setViewportWidth(window.innerWidth); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + useLayoutEffect(() => { 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 : topLevelShelfIndex; + if (children.length === 0 || activeIndex >= children.length) { + if (gamesShelfBrowseActive || topLevelShelfActive) setListTranslateX(0); + return; + } + + if (gamesShelfBrowseActive || topLevelShelfActive) { + let gap = 14; + if (children.length >= 2) { + gap = Math.max(8, children[1].offsetLeft - children[0].offsetLeft - children[0].offsetWidth); + } + let offsetCenter = 0; + for (let i = 0; i < activeIndex; i++) { + offsetCenter += children[i].offsetWidth + gap; + } + offsetCenter += children[activeIndex].offsetWidth / 2; + setListTranslateX(viewportWidth / 2 - offsetCenter); + setListTranslateY(0); + return; + } + let offset = 0; for (let i = 0; i < selectedIndex; i++) { const childStyle = window.getComputedStyle(children[i]); @@ -559,7 +655,8 @@ export function ControllerLibraryPage({ } offset += children[selectedIndex].offsetHeight / 2; setListTranslateY(-offset); - }, [selectedIndex, categorizedGames]); + setListTranslateX(0); + }, [selectedIndex, categorizedGames, gamesShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, viewportWidth]); const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); @@ -609,6 +706,74 @@ export function ControllerLibraryPage({ } } if (isLoading && topCategory !== "settings" && topCategory !== "current") return; + + const shelfHasGames = categorizedGames.length > 0; + + if (gamesShelfBrowseActive) { + if (shelfHasGames) { + 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") { + playUiSound("move"); + setGameSubcategory("root"); + setSelectedGameSubcategoryIndex(lastRootGameIndex); + return; + } + if (direction === "down") { + return; + } + } else if (direction === "up") { + playUiSound("move"); + setGameSubcategory("root"); + setSelectedGameSubcategoryIndex(lastRootGameIndex); + return; + } + } + + if (topLevelShelfActive) { + const itemCount = displayItems.length; + 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") { + playUiSound("move"); + const delta = direction === "up" ? -1 : 1; + 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); + return; + } + } + if (direction === "left") { playUiSound("move"); // Cycle main categories (settings always resets to root) @@ -698,23 +863,6 @@ export function ControllerLibraryPage({ } return; } - if (categorizedGames.length === 0) return; - if (direction === "up") { - const nextIndex = Math.max(0, selectedIndex - 1); - if (nextIndex !== selectedIndex) { - playUiSound("move"); - throttledOnSelectGame(categorizedGames[nextIndex].id); - } - return; - } - if (direction === "down") { - const nextIndex = Math.min(categorizedGames.length - 1, selectedIndex + 1); - if (nextIndex !== selectedIndex) { - playUiSound("move"); - throttledOnSelectGame(categorizedGames[nextIndex].id); - } - return; - } }; const handler = (e: any) => { @@ -1098,7 +1246,7 @@ export function ControllerLibraryPage({ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel]); + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1119,6 +1267,20 @@ export function ControllerLibraryPage({ }; const themeStyleSafe = sanitizeControllerThemeStyle(settings.controllerThemeStyle); + const selectedMediaItem = topCategory === "media" && mediaSubcategory !== "root" + ? mediaAssetItems[selectedMediaIndex] ?? null + : null; + const heroBackdropUrl = useMemo(() => { + 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, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); const themeRgbResolved = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; const wrapperThemeVars = { "--xmb-theme-r": String(themeRgbResolved.r), @@ -1126,13 +1288,16 @@ export function ControllerLibraryPage({ "--xmb-theme-b": String(themeRgbResolved.b), } as React.CSSProperties; - const wrapperClassName = `xmb-wrapper xmb-theme-${themeStyleSafe} ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"}`; + const wrapperClassName = `xmb-wrapper xmb-theme-${themeStyleSafe} ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"} xmb-layout--ps5-home`; if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; return (
+ {heroBackdropUrl ? ( +
+ ) : null}
@@ -1181,162 +1346,169 @@ export function ControllerLibraryPage({
{topCategory === "all" && gameSubcategory !== "root" && ( -
- {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} -
-
-
{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}

+
+ {(() => { + 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} + + ); + })()}
- ); - })} -
+ ) : null} +
+
+ {categorizedGames.map((game, idx) => { + const isActive = idx === selectedIndex; + return ( +
+ {favoriteGameIdSet.has(game.id) ? : null} +
+ +
+
+ ); + })} +
+
+
)} - {(topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")) && ( -
- {displayItems.map((item, idx) => { - const isActive = idx === (topCategory === "media" ? selectedMediaIndex : topCategory === "all" ? selectedGameSubcategoryIndex : 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"); - const isGameSubcategoryItem = topCategory === "all" && gameSubcategory === "root"; - const isThemeNavItem = - topCategory === "settings" && - ((settingsSubcategory === "Theme" && (item.id === "themeColor" || item.id === "themeStyle")) || - (settingsSubcategory === "System" && item.id === "theme")); - const themeChannelForRow = - item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; - const themeRgbLive = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; - return ( -
+
+

{selectedCategoryLabel}

+
+
+
-
-
{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' : ''} + {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 = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const isGameRootTile = topCategory === "all" && gameSubcategory === "root"; + const previewThumbs = isGameRootTile ? (gameCategoryPreviewById[item.id] ?? []) : []; + return ( +
+ {isGameRootTile ? ( +
+ {previewThumbs.map((src, i) => ( +
+ +
+ ))} + {Array.from({ length: Math.max(0, PREVIEW_TILE_COUNT - previewThumbs.length) }).map((_, i) => ( +
+ ))}
- ) : 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" : ""} - + ) : 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} + /> + {`${settings.maxBitrateMbps ?? 75} 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.value} + )}
- ) : ( - {item.value} - )} + ) : null}
- )} -
+ ); + })}
- ); - })} -
+
+
)} {topCategory === "media" && mediaSubcategory !== "root" && ( @@ -1588,6 +1760,22 @@ export function ControllerLibraryPage({ )} + ) : topCategory === "all" && gameSubcategory !== "root" ? ( + <> +
+ Browse · Left / Right +
+
+ Library filters · Up +
+
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
+ {selectedGame && selectedGame.variants.length > 1 ? ( +
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Variant
+ ) : null} + {selectedGame ? ( +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"}
+ ) : null} + ) : topCategory === "all" && gameSubcategory === "root" ? (
{controllerType === "ps" ? ( @@ -1600,12 +1788,12 @@ export function ControllerLibraryPage({ ) : ( <>
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
- {selectedGame?.variants.length && selectedGame.variants.length > 1 && ( + {selectedGame && selectedGame.variants.length > 1 ? (
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Variant
- )} - {selectedGame && ( + ) : null} + {selectedGame ? (
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"}
- )} + ) : null} )}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 6373a7fdb..d372ec07a 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6610,6 +6610,7 @@ button.game-card-store-chip.owned.active:hover { .xmb-wrapper.xmb-entering .xmb-top-right, .xmb-wrapper.xmb-entering .xmb-categories-container, .xmb-wrapper.xmb-entering .xmb-items-container, +.xmb-wrapper.xmb-entering .xmb-ps5-stack, .xmb-wrapper.xmb-entering .xmb-detail-layer, .xmb-wrapper.xmb-entering .xmb-footer { animation: xmb-enter-content 680ms cubic-bezier(0.22, 1, 0.36, 1) both; @@ -6625,6 +6626,7 @@ button.game-card-store-chip.owned.active:hover { } .xmb-wrapper.xmb-entering .xmb-items-container, +.xmb-wrapper.xmb-entering .xmb-ps5-stack, .xmb-wrapper.xmb-entering .xmb-detail-layer { animation-delay: 150ms; } @@ -6790,6 +6792,278 @@ button.game-card-store-chip.owned.active:hover { linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0) 24%, rgba(0, 0, 0, 0) 76%, rgba(0, 0, 0, 0.1) 100%); } +/* PS5-style Games hub: blurred hero art + horizontal shelf */ +.xmb-ps5-hero-art { + position: absolute; + inset: -14%; + background-size: cover; + background-position: center center; + background-repeat: no-repeat; + filter: blur(18px) saturate(1.12) brightness(0.92); + opacity: 0.62; + pointer-events: none; + transform: scale(1.06); +} + +.xmb-layout--ps5-home .xmb-bg-overlay { + background: + linear-gradient(180deg, rgba(8, 10, 14, 0.52) 0%, rgba(10, 14, 18, 0.28) 38%, rgba(6, 8, 12, 0.06) 62%, rgba(0, 0, 0, 0.72) 100%), + radial-gradient(120% 90% at 50% 22%, rgba(0, 0, 0, 0.08), transparent 52%), + linear-gradient(90deg, rgba(0, 0, 0, 0.26) 0%, rgba(0, 0, 0, 0) 22%, rgba(0, 0, 0, 0) 78%, rgba(0, 0, 0, 0.26) 100%); +} + +.xmb-layout--ps5-home .xmb-categories-container { + top: 12%; +} + +.xmb-layout--ps5-home .xmb-category-item { + width: 96px; + height: 88px; + margin-right: 28px; + opacity: 0.48; + transform: scale(0.82); +} + +.xmb-layout--ps5-home .xmb-category-item.active { + transform: scale(1.08); +} + +.xmb-layout--ps5-home .xmb-category-label { + font-size: 0.72rem; + letter-spacing: 0.24em; +} + +.xmb-layout--ps5-home .xmb-items-container { + top: 54%; +} + +.xmb-layout--ps5-home .xmb-game-item { + width: 620px; + padding: 14px 24px; + border-radius: 18px; + opacity: 0.5; + transform: translateX(0) scale(0.9); + background: rgba(12, 16, 22, 0.44); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.34); +} + +.xmb-layout--ps5-home .xmb-game-item.active { + transform: translateX(22px) scale(1.04); + background: linear-gradient( + 90deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.3), + rgba(255, 255, 255, 0.06) + ); + border-color: rgba(255, 255, 255, 0.56); + box-shadow: + 0 20px 42px rgba(0, 0, 0, 0.45), + 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.3); +} + +.xmb-layout--ps5-home .xmb-detail-layer { + bottom: 170px; +} + +.xmb-layout--ps5-home .xmb-footer { + background: linear-gradient(transparent, rgba(3, 8, 12, 0.52) 46%, rgba(0, 0, 0, 0.5) 100%); + border-top: 1px solid rgba(221, 255, 233, 0.12); +} + +.xmb-wrapper .xmb-ps5-stack { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; +} + +.xmb-ps5-focus-meta { + position: absolute; + left: clamp(24px, 4vw, 56px); + right: clamp(24px, 4vw, 56px); + bottom: clamp(300px, 40vh, 440px); + display: flex; + flex-direction: column; + gap: 14px; + align-items: flex-start; + max-width: min(82vw, 1080px); + z-index: 6; +} + +.xmb-ps5-focus-title { + margin: 0; + font-size: clamp(1.65rem, 3.6vw, 2.75rem); + font-weight: 800; + letter-spacing: -0.03em; + color: rgba(255, 255, 255, 0.97); + text-shadow: + 0 2px 24px rgba(0, 0, 0, 0.55), + 0 1px 3px rgba(0, 0, 0, 0.45); + max-width: min(720px, 88vw); +} + +.xmb-ps5-focus-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + max-width: 100%; + max-height: 100px; + overflow: hidden; +} + +.xmb-ps5-shelf-viewport { + position: absolute; + left: 0; + right: 0; + bottom: 92px; + height: clamp(210px, 26vh, 280px); + overflow: hidden; + mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); + -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); +} + +.xmb-ps5-shelf-track { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 16px; + width: max-content; + min-height: 100%; + padding: 12px 24px 16px; + box-sizing: border-box; + transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-ps5-shelf-track--menu { + align-items: stretch; + padding-bottom: 28px; +} + +.xmb-ps5-tile { + position: relative; + flex-shrink: 0; + width: 118px; + opacity: 0.52; + transform: translateY(10px) scale(0.92); + transition: + opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), + transform 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-ps5-tile.active { + opacity: 1; + transform: translateY(0) scale(1.06); + z-index: 2; +} + +.xmb-ps5-tile-frame { + border-radius: 14px; + overflow: hidden; + aspect-ratio: 2 / 3; + border: 2px solid rgba(255, 255, 255, 0.14); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.55), + inset 0 0 0 1px rgba(255, 255, 255, 0.06); + background: rgba(0, 0, 0, 0.35); + transition: + border-color 360ms ease, + box-shadow 360ms ease; +} + +.xmb-ps5-tile.active .xmb-ps5-tile-frame { + border-color: rgba(255, 255, 255, 0.96); + box-shadow: + 0 22px 52px rgba(0, 0, 0, 0.62), + 0 0 0 3px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.42), + 0 0 28px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.35); +} + +.xmb-ps5-tile-cover { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.xmb-ps5-tile-fav { + position: absolute; + top: 10px; + right: 10px; + width: 18px; + height: 18px; + color: #ffd700; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.65)); + z-index: 3; +} + +.xmb-ps5-menu-tile { + width: clamp(260px, 24vw, 380px); + min-height: 140px; + border-radius: 18px; + padding: 16px 18px; + box-sizing: border-box; + background: rgba(10, 14, 20, 0.44); + border: 1px solid rgba(255, 255, 255, 0.14); + opacity: 0.58; + transform: translateY(10px) scale(0.92); + transition: all 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-ps5-menu-tile.active { + opacity: 1; + transform: translateY(0) scale(1.03); + border-color: rgba(255, 255, 255, 0.65); + box-shadow: + 0 18px 44px rgba(0, 0, 0, 0.48), + 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.28); +} + +.xmb-ps5-menu-title { + font-size: 1.08rem; + font-weight: 800; + color: rgba(255, 255, 255, 0.95); + margin-bottom: 8px; +} + +.xmb-ps5-menu-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.xmb-ps5-menu-thumb-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; + min-height: 128px; +} + +.xmb-ps5-menu-thumb { + width: 100%; + aspect-ratio: 1 / 1.18; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.35); +} + +.xmb-ps5-menu-thumb-img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.xmb-ps5-menu-thumb--empty { + width: 48px; + border-style: dashed; + border-color: rgba(255, 255, 255, 0.24); + background: rgba(0, 0, 0, 0.22); +} + /* --- Controller theme style presets (see Settings → System → Theme → Style) --- */ .xmb-theme-nebula .xmb-bg-layer { From a8a6bce986f99037e3c64a15737490ae4eb5dade Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 12:23:25 -0500 Subject: [PATCH 04/16] feat(controller): enhance ControllerLibraryPage with media navigation and parallax effects - Added new icons for category labels to improve visual representation. - Implemented parallax backdrop tiles for a dynamic background experience. - Enhanced navigation logic to support media browsing alongside games. - Updated state management to handle media selection and transitions effectively. These changes enrich the user interface and provide a more engaging experience while navigating the controller library. --- .../src/components/ControllerLibraryPage.tsx | 335 ++++++++++++++---- .../src/renderer/src/controllerNavigation.ts | 5 +- opennow-stable/src/renderer/src/styles.css | 228 +++++++++++- 3 files changed, 472 insertions(+), 96 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index acf1fb606..02f8e1f15 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; import type { GameInfo, MediaListingEntry, Settings, ControllerThemeStyle } from "@shared/gfn"; -import { Star, Clock, Calendar, Repeat2 } from "lucide-react"; +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"; @@ -105,6 +105,13 @@ function getCategoryLabel(categoryId: string, currentGameTitle?: string): { labe return { label: "Games" }; } +function getCategoryIcon(categoryId: string): JSX.Element { + if (categoryId === "current") return ; + if (categoryId === "settings") return ; + if (categoryId === "media") return ; + return ; +} + export function ControllerLibraryPage({ games, @@ -567,6 +574,44 @@ export function ControllerLibraryPage({ 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); return index >= 0 ? index : 0; @@ -584,8 +629,10 @@ export function ControllerLibraryPage({ 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") || @@ -598,6 +645,17 @@ export function ControllerLibraryPage({ : selectedSettingIndex; const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory, currentStreamingGame?.title).label, [topCategory, currentStreamingGame?.title]); + const selectedTopLevelItemLabel = useMemo(() => { + if (!topLevelShelfActive) return selectedCategoryLabel; + const active = displayItems[topLevelShelfIndex]; + if (topCategory === "all" && gameSubcategory === "root" && active?.label) return active.label; + return selectedCategoryLabel; + }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory]); + const focusMotionKey = useMemo(() => { + 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, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); const selectedGameDescription = useMemo(() => { if (!selectedGame) return ""; const description = selectedGame.longDescription?.trim() || selectedGame.description?.trim(); @@ -613,8 +671,8 @@ export function ControllerLibraryPage({ useEffect(() => { - if (!gamesShelfBrowseActive && !topLevelShelfActive) setListTranslateX(0); - }, [gamesShelfBrowseActive, topLevelShelfActive]); + if (!gamesShelfBrowseActive && !mediaShelfBrowseActive && !topLevelShelfActive) setListTranslateX(0); + }, [gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive]); useEffect(() => { if (typeof window === "undefined") return; @@ -627,13 +685,13 @@ export function ControllerLibraryPage({ const container = itemsContainerRef.current; if (!container) return; const children = Array.from(container.children) as HTMLElement[]; - const activeIndex = gamesShelfBrowseActive ? selectedIndex : topLevelShelfIndex; + const activeIndex = gamesShelfBrowseActive ? selectedIndex : mediaShelfBrowseActive ? selectedMediaIndex : topLevelShelfIndex; if (children.length === 0 || activeIndex >= children.length) { - if (gamesShelfBrowseActive || topLevelShelfActive) setListTranslateX(0); + if (gamesShelfBrowseActive || mediaShelfBrowseActive || topLevelShelfActive) setListTranslateX(0); return; } - if (gamesShelfBrowseActive || topLevelShelfActive) { + if (gamesShelfBrowseActive || mediaShelfBrowseActive || topLevelShelfActive) { let gap = 14; if (children.length >= 2) { gap = Math.max(8, children[1].offsetLeft - children[0].offsetLeft - children[0].offsetWidth); @@ -656,7 +714,7 @@ export function ControllerLibraryPage({ offset += children[selectedIndex].offsetHeight / 2; setListTranslateY(-offset); setListTranslateX(0); - }, [selectedIndex, categorizedGames, gamesShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, viewportWidth]); + }, [selectedIndex, categorizedGames, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, selectedMediaIndex, viewportWidth]); const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); @@ -744,6 +802,33 @@ export function ControllerLibraryPage({ } } + if (mediaShelfBrowseActive) { + 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") { + playUiSound("move"); + setMediaSubcategory("root"); + setSelectedMediaIndex(lastRootMediaIndex); + return; + } + if (direction === "down") return; + } + if (topLevelShelfActive) { const itemCount = displayItems.length; if (itemCount > 0 && (direction === "left" || direction === "right")) { @@ -821,7 +906,7 @@ export function ControllerLibraryPage({ } return; } - if (topCategory === "media") { + if (topCategory === "media" && mediaSubcategory === "root") { const itemCount = mediaSubcategory === "root" ? displayItems.length : mediaAssetItems.length; if (itemCount === 0) return; if (direction === "up") { @@ -865,9 +950,29 @@ export function ControllerLibraryPage({ } }; + 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); + 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 (topCategory === "settings" && settingsSubcategory !== "root") return; + if (editingBandwidth || editingThemeChannel) return; + cycleTopCategory(direction === "prev" ? -1 : 1); + }; const activateHandler = () => { // If currently editing bandwidth, A confirms and exits edit mode @@ -1014,6 +1119,10 @@ export function ControllerLibraryPage({ }; const secondaryActivateHandler = () => { + if (topLevelShelfActive) { + cycleTopCategory(-1); + return; + } if (topCategory === "current") { // X button does nothing on current game menu items return; @@ -1104,6 +1213,10 @@ export function ControllerLibraryPage({ }; const tertiaryActivateHandler = () => { + if (topLevelShelfActive) { + cycleTopCategory(1); + return; + } if (topCategory !== "settings" && topCategory !== "current" && !(topCategory === "all" && gameSubcategory === "root")) { toggleFavoriteForSelected(); } @@ -1164,7 +1277,11 @@ export function ControllerLibraryPage({ setSelectedGameSubcategoryIndex(lastRootGameIndex); playUiSound("move"); e.preventDefault(); + return; } + + // At top-level views, Back/Cancel is intentionally a no-op. + e.preventDefault(); }; const kbdHandler = (e: KeyboardEvent) => { @@ -1204,6 +1321,16 @@ export function ControllerLibraryPage({ tertiaryActivateHandler(); return; } + if (e.key.toLowerCase() === "q" && topLevelShelfActive) { + e.preventDefault(); + cycleTopCategory(-1); + return; + } + if (e.key.toLowerCase() === "e" && topLevelShelfActive) { + e.preventDefault(); + cycleTopCategory(1); + return; + } if (e.key === "Backspace" || e.key === "Escape") { if (topCategory === "settings" && settingsSubcategory !== "root") { cancelHandler(e); @@ -1217,22 +1344,15 @@ export function ControllerLibraryPage({ cancelHandler(e); return; } + + // Top-level back is intentionally a no-op. 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"); - setSelectedGameSubcategoryIndex(0); - setGameSubcategory("root"); - } else { - onOpenSettings?.(); - } + 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); @@ -1240,13 +1360,14 @@ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex]); + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1295,6 +1416,30 @@ export function ControllerLibraryPage({ return (
+ {parallaxBackdropTiles.length > 0 ? ( +
+ {parallaxBackdropTiles.map((tile, idx) => { + return ( +
+ ); + })} +
+ ) : null} {heroBackdropUrl ? (
) : null} @@ -1339,6 +1484,7 @@ export function ControllerLibraryPage({ const label = cat.label; return (
+
{getCategoryIcon(cat.id)}
{label}
); @@ -1348,8 +1494,17 @@ export function ControllerLibraryPage({ {topCategory === "all" && gameSubcategory !== "root" && (
{selectedGame ? ( -
+

{selectedGame.title}

+
+ + {currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"} + + + {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"} + + {selectedGame.variants.length > 1 ? Variant : null} +
{(() => { const record = playtimeData[selectedGame.id]; @@ -1426,8 +1581,12 @@ export function ControllerLibraryPage({ {topLevelShelfActive && (
-
-

{selectedCategoryLabel}

+
+

{selectedTopLevelItemLabel}

+
+ Enter + Change Section +
- {mediaLoading && ( -
-
-
Loading {mediaSubcategory}...
-
-
- )} - - {!mediaLoading && mediaError && ( -
-
-
{mediaError}
+
+
+

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

+
+ Open Folder + Back To Media
- )} +
+
+ {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} +
+
+ ); + })}
- ); - })} -
+
+
)}
@@ -1636,7 +1803,20 @@ export function ControllerLibraryPage({
- {topCategory === "current" ? ( + {topLevelShelfActive ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+
L1 Prev Section
+
R1 Next Section
+ + ) : topCategory === "current" ? ( <>
{controllerType === "ps" ? ( @@ -1741,6 +1921,9 @@ export function ControllerLibraryPage({
) : ( <> +
+ Browse · Left / Right +
{controllerType === "ps" ? ( @@ -1755,7 +1938,7 @@ export function ControllerLibraryPage({ ) : ( )} - Back + Back To Media
)} diff --git a/opennow-stable/src/renderer/src/controllerNavigation.ts b/opennow-stable/src/renderer/src/controllerNavigation.ts index b9016e5fa..93928c9eb 100644 --- a/opennow-stable/src/renderer/src/controllerNavigation.ts +++ b/opennow-stable/src/renderer/src/controllerNavigation.ts @@ -329,7 +329,6 @@ export function useControllerNavigation({ const b = Boolean(pad.buttons[1]?.pressed); const lb = Boolean(pad.buttons[4]?.pressed); const rb = Boolean(pad.buttons[5]?.pressed); - const scopedToDocument = getFocusScopeRoot() === document; const handleDirection = (direction: Direction, pressed: boolean): void => { const state = directionStateRef.current[direction]; @@ -379,10 +378,10 @@ export function useControllerNavigation({ if (b && !actionStateRef.current.b) { triggerBackAction(onBackAction); } - if (scopedToDocument && lb && !actionStateRef.current.lb) { + if (lb && !actionStateRef.current.lb) { onNavigatePage?.("prev"); } - if (scopedToDocument && rb && !actionStateRef.current.rb) { + if (rb && !actionStateRef.current.rb) { onNavigatePage?.("next"); } diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index d372ec07a..45edd63b6 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6731,15 +6731,11 @@ button.game-card-store-chip.owned.active:hover { animation: none; } -/* Enable ribbon animations when wrapper has xmb-animate */ -.xmb-wrapper.xmb-animate .xmb-bg-gradient { - animation: xmb-ribbon-pan 30s linear infinite; -} -.xmb-wrapper.xmb-animate .xmb-bg-gradient::before { - animation: xmb-ribbon-float 26s ease-in-out infinite; -} +/* Legacy ribbon animations intentionally disabled; parallax is the active background animation. */ +.xmb-wrapper.xmb-animate .xmb-bg-gradient, +.xmb-wrapper.xmb-animate .xmb-bg-gradient::before, .xmb-wrapper.xmb-animate .xmb-bg-gradient::after { - animation: xmb-ribbon-swell 22s ease-in-out infinite; + animation: none; } @keyframes xmb-enter-bg { @@ -6805,6 +6801,66 @@ button.game-card-store-chip.owned.active:hover { transform: scale(1.06); } +.xmb-ps5-parallax-field { + position: absolute; + inset: -10%; + overflow: hidden; + opacity: 0.34; + pointer-events: none; +} + +.xmb-ps5-parallax-tile { + position: absolute; + width: clamp(110px, 11vw, 180px); + aspect-ratio: 2 / 3; + border-radius: 14px; + background-size: cover; + background-position: center; + filter: saturate(1.05) brightness(0.78); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.42); + top: 120%; + animation-name: xmb-ps5-parallax-scroll; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-delay: var(--parallax-delay); +} + +.xmb-wrapper.xmb-static .xmb-ps5-parallax-tile { + animation-play-state: paused; +} + +.xmb-wrapper.xmb-static .xmb-ps5-parallax-field { + opacity: 0.22; +} + +.xmb-wrapper.xmb-animate .xmb-ps5-parallax-tile { + animation-play-state: running; +} + +.xmb-ps5-parallax-row-0 { + animation-duration: 36s; +} + +.xmb-ps5-parallax-row-1 { + animation-duration: 46s; +} + +.xmb-ps5-parallax-row-2 { + animation-duration: 58s; +} + +@keyframes xmb-ps5-parallax-scroll { + 0% { + transform: translate3d(var(--parallax-x-from), 0, 0) rotate(var(--parallax-rot-from)) scale(var(--parallax-scale, 1)); + top: 120%; + } + 100% { + transform: translate3d(var(--parallax-x-to), 0, 0) rotate(var(--parallax-rot-to)) scale(var(--parallax-scale, 1)); + top: -40%; + } +} + .xmb-layout--ps5-home .xmb-bg-overlay { background: linear-gradient(180deg, rgba(8, 10, 14, 0.52) 0%, rgba(10, 14, 18, 0.28) 38%, rgba(6, 8, 12, 0.06) 62%, rgba(0, 0, 0, 0.72) 100%), @@ -6817,8 +6873,8 @@ button.game-card-store-chip.owned.active:hover { } .xmb-layout--ps5-home .xmb-category-item { - width: 96px; - height: 88px; + width: 102px; + height: 92px; margin-right: 28px; opacity: 0.48; transform: scale(0.82); @@ -6829,8 +6885,26 @@ button.game-card-store-chip.owned.active:hover { } .xmb-layout--ps5-home .xmb-category-label { - font-size: 0.72rem; - letter-spacing: 0.24em; + font-size: 0.62rem; + letter-spacing: 0.2em; + margin-top: 7px; +} + +.xmb-category-icon-wrap { + width: 44px; + height: 44px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.34); +} + +.xmb-layout--ps5-home .xmb-category-item.active .xmb-category-icon-wrap { + background: rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.28); + border-color: rgba(255, 255, 255, 0.8); } .xmb-layout--ps5-home .xmb-items-container { @@ -6888,6 +6962,7 @@ button.game-card-store-chip.owned.active:hover { align-items: flex-start; max-width: min(82vw, 1080px); z-index: 6; + animation: xmb-ps5-focus-in 280ms cubic-bezier(0.22, 1, 0.36, 1) both; } .xmb-ps5-focus-title { @@ -6912,6 +6987,47 @@ button.game-card-store-chip.owned.active:hover { overflow: hidden; } +.xmb-ps5-actions { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 2px; + flex-wrap: wrap; +} + +.xmb-ps5-action { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.22); +} + +.xmb-ps5-action--primary { + color: #0a1116; + background: rgba(255, 255, 255, 0.95); + border-color: rgba(255, 255, 255, 0.98); +} + +@keyframes xmb-ps5-focus-in { + 0% { + opacity: 0; + transform: translateY(14px) scale(0.985); + filter: blur(6px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + .xmb-ps5-shelf-viewport { position: absolute; left: 0; @@ -6940,6 +7056,11 @@ button.game-card-store-chip.owned.active:hover { padding-bottom: 28px; } +.xmb-ps5-shelf-track--media { + align-items: flex-start; + padding-bottom: 18px; +} + .xmb-ps5-tile { position: relative; flex-shrink: 0; @@ -7032,6 +7153,67 @@ button.game-card-store-chip.owned.active:hover { gap: 8px; } +.xmb-ps5-media-tile { + width: clamp(260px, 30vw, 420px); + opacity: 0.58; + transform: translateY(10px) scale(0.94); + transition: all 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-ps5-media-tile.active { + opacity: 1; + transform: translateY(0) scale(1.02); +} + +.xmb-ps5-media-frame { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 16px; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.18); + background: rgba(12, 16, 20, 0.6); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.46); + transition: all 360ms ease; +} + +.xmb-ps5-media-tile.active .xmb-ps5-media-frame { + border-color: rgba(255, 255, 255, 0.94); + box-shadow: + 0 22px 48px rgba(0, 0, 0, 0.6), + 0 0 0 3px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.34); +} + +.xmb-ps5-media-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.xmb-ps5-media-image--placeholder, +.xmb-ps5-media-frame--placeholder { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.05) 0 8px, rgba(255, 255, 255, 0.01) 8px 16px); +} + +.xmb-ps5-media-caption { + margin-top: 10px; + font-size: 0.9rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.94); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.xmb-ps5-media-meta { + margin-top: 8px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + .xmb-ps5-menu-thumb-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -7131,11 +7313,7 @@ button.game-card-store-chip.owned.active:hover { } .xmb-theme-pulse .xmb-bg-layer { - animation: xmb-theme-pulse-layer 11s ease-in-out infinite; -} - -.xmb-theme-pulse.xmb-animate .xmb-bg-gradient { - animation: xmb-ribbon-pan 30s linear infinite, xmb-theme-pulse-layer 11s ease-in-out infinite; + animation: none; } .xmb-categories-container { @@ -7644,6 +7822,22 @@ button.game-card-store-chip.owned.active:hover { transform: scale(1.3); } +.xmb-btn-keycap { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 20px; + padding: 0 7px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.95); + font-size: 0.62rem; + font-weight: 900; + letter-spacing: 0.06em; +} + .xmb-top-right { position: absolute; top: 40px; From 89c200c367742d016d89750a46728e229e231b7b Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 12:33:13 -0500 Subject: [PATCH 05/16] feat(controller): enhance PS5 row navigation and detail rail functionality - Introduced state management for PS5 row navigation, allowing transitions between top, main, and detail views. - Added detail rail items for enhanced media and game interaction, improving user engagement. - Updated navigation logic to support new detail rail interactions, including cycling through items and activating selections. - Enhanced CSS styles for PS5 detail cards, improving visual presentation and responsiveness. These changes provide a more dynamic and immersive experience for users navigating the controller library. --- .../src/components/ControllerLibraryPage.tsx | 189 +++++++++++++++--- opennow-stable/src/renderer/src/styles.css | 87 ++++++++ 2 files changed, 253 insertions(+), 23 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 02f8e1f15..b018c276a 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -208,6 +208,8 @@ export function ControllerLibraryPage({ 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); useEffect(() => { if (typeof window === "undefined") { @@ -651,6 +653,30 @@ export function ControllerLibraryPage({ if (topCategory === "all" && gameSubcategory === "root" && active?.label) return active.label; return selectedCategoryLabel; }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory]); + const detailRailItems = useMemo>(() => { + if (topCategory === "all" && gameSubcategory !== "root" && selectedGame) { + const genre = selectedGame.genres?.[0] ? sanitizeGenreName(selectedGame.genres[0]) : "Cloud Action"; + return [ + { id: "d1", title: "Activities", subtitle: `${genre} challenges`, imageUrl: selectedGame.imageUrl }, + { id: "d2", title: "Community", subtitle: "Friends playing now", imageUrl: selectedGame.imageUrl }, + { id: "d3", title: "Store", subtitle: "DLC and add-ons", imageUrl: selectedGame.imageUrl }, + ]; + } + if (topCategory === "media" && mediaSubcategory !== "root") { + const current = mediaAssetItems[selectedMediaIndex]; + const imageUrl = current?.thumbnailDataUrl || current?.dataUrl || (current ? mediaThumbById[current.id] : undefined); + return [ + { id: "m1", title: "Recent", subtitle: `Latest ${mediaSubcategory.toLowerCase()}`, imageUrl }, + { id: "m2", title: "By Game", subtitle: "Grouped captures", imageUrl }, + { id: "m3", title: "Storage", subtitle: "Manage media files", imageUrl }, + ]; + } + return [ + { id: "p1", title: "Explore", subtitle: "Discover recommendations" }, + { id: "p2", title: "Continue", subtitle: "Jump back in quickly" }, + { id: "p3", title: "Tips", subtitle: "Controller shortcuts" }, + ]; + }, [topCategory, gameSubcategory, selectedGame, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); const focusMotionKey = useMemo(() => { if (topCategory === "all" && gameSubcategory !== "root") return `game-${selectedGame?.id ?? "none"}`; if (topCategory === "media" && mediaSubcategory !== "root") return `media-${selectedMediaIndex}-${mediaAssetItems[selectedMediaIndex]?.id ?? "none"}`; @@ -674,6 +700,11 @@ export function ControllerLibraryPage({ if (!gamesShelfBrowseActive && !mediaShelfBrowseActive && !topLevelShelfActive) setListTranslateX(0); }, [gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive]); + useEffect(() => { + setPs5Row("main"); + setDetailRailIndex(0); + }, [topCategory, gameSubcategory, mediaSubcategory, settingsSubcategory]); + useEffect(() => { if (typeof window === "undefined") return; const onResize = () => setViewportWidth(window.innerWidth); @@ -765,10 +796,57 @@ export function ControllerLibraryPage({ } if (isLoading && topCategory !== "settings" && topCategory !== "current") return; + if (ps5Row === "top") { + if (direction === "left") { + cycleTopCategory(-1); + return; + } + if (direction === "right") { + cycleTopCategory(1); + return; + } + if (direction === "down") { + playUiSound("move"); + setPs5Row("main"); + return; + } + return; + } + + if (ps5Row === "detail") { + 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") { + playUiSound("move"); + setPs5Row("detail"); + return; + } if (direction === "left") { const ni = Math.max(0, selectedIndex - 1); if (ni !== selectedIndex) { @@ -787,22 +865,22 @@ export function ControllerLibraryPage({ } if (direction === "up") { playUiSound("move"); - setGameSubcategory("root"); - setSelectedGameSubcategoryIndex(lastRootGameIndex); - return; - } - if (direction === "down") { + setPs5Row("top"); return; } } else if (direction === "up") { playUiSound("move"); - setGameSubcategory("root"); - setSelectedGameSubcategoryIndex(lastRootGameIndex); + setPs5Row("top"); return; } } if (mediaShelfBrowseActive) { + if (direction === "down") { + playUiSound("move"); + setPs5Row("detail"); + return; + } const itemCount = mediaAssetItems.length; if (itemCount > 0 && direction === "left") { const nextIndex = Math.max(0, selectedMediaIndex - 1); @@ -822,11 +900,9 @@ export function ControllerLibraryPage({ } if (direction === "up") { playUiSound("move"); - setMediaSubcategory("root"); - setSelectedMediaIndex(lastRootMediaIndex); + setPs5Row("top"); return; } - if (direction === "down") return; } if (topLevelShelfActive) { @@ -845,16 +921,7 @@ export function ControllerLibraryPage({ if (direction === "up" || direction === "down") { playUiSound("move"); - const delta = direction === "up" ? -1 : 1; - 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); + if (direction === "up") setPs5Row("top"); return; } } @@ -975,6 +1042,63 @@ export function ControllerLibraryPage({ }; const activateHandler = () => { + if (ps5Row === "top") { + setPs5Row("main"); + playUiSound("confirm"); + return; + } + + if (ps5Row === "detail") { + const selectedDetail = detailRailItems[detailRailIndex]; + if (!selectedDetail) return; + + if (topCategory === "all" && gameSubcategory !== "root" && selectedGame) { + if (selectedDetail.id === "d1") { + onPlayGame(selectedGame); + playUiSound("confirm"); + return; + } + if (selectedDetail.id === "d2") { + toggleFavoriteForSelected(); + return; + } + if (selectedDetail.id === "d3" && 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("confirm"); + return; + } + + if (topCategory === "media" && mediaSubcategory !== "root") { + if (selectedDetail.id === "m3" || 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); @@ -1367,7 +1491,7 @@ export function ControllerLibraryPage({ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex]); + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, ps5Row, detailRailIndex, detailRailItems.length]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1410,11 +1534,12 @@ export function ControllerLibraryPage({ } 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}`; - if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; return ( -
+
{parallaxBackdropTiles.length > 0 ? (
@@ -1802,6 +1927,24 @@ export function ControllerLibraryPage({ )}
+ {ps5Row === "detail" && (gamesShelfBrowseActive || mediaShelfBrowseActive) && ( +
+ {detailRailItems.map((item, idx) => ( +
+
+ {item.imageUrl ? ( + + ) : ( +
+ )} +
+
{item.title}
+
{item.subtitle}
+
+ ))} +
+ )} +
{topLevelShelfActive ? ( <> diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 45edd63b6..f64c32853 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6907,6 +6907,30 @@ button.game-card-store-chip.owned.active:hover { border-color: rgba(255, 255, 255, 0.8); } +.xmb-row-top .xmb-categories-container { + filter: brightness(1.08); +} + +.xmb-row-top .xmb-category-item { + opacity: 0.72; + transform: scale(0.94); +} + +.xmb-row-top .xmb-category-item.active { + opacity: 1; + transform: scale(1.2); +} + +.xmb-row-top .xmb-ps5-shelf-viewport { + opacity: 0.36; + filter: blur(1px); +} + +.xmb-row-detail .xmb-categories-container, +.xmb-row-detail .xmb-ps5-shelf-viewport { + opacity: 0.62; +} + .xmb-layout--ps5-home .xmb-items-container { top: 54%; } @@ -7214,6 +7238,69 @@ button.game-card-store-chip.owned.active:hover { flex-wrap: wrap; } +.xmb-ps5-detail-rail { + position: absolute; + left: clamp(24px, 4vw, 56px); + right: clamp(24px, 4vw, 56px); + bottom: clamp(208px, 28vh, 320px); + display: flex; + gap: 14px; + z-index: 7; +} + +.xmb-ps5-detail-card { + width: clamp(180px, 18vw, 260px); + padding: 10px; + border-radius: 14px; + background: rgba(12, 16, 24, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + opacity: 0.64; + transform: translateY(8px) scale(0.96); + transition: all 340ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-ps5-detail-card.active { + opacity: 1; + transform: translateY(0) scale(1); + border-color: rgba(255, 255, 255, 0.68); + box-shadow: + 0 14px 30px rgba(0, 0, 0, 0.4), + 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.3); +} + +.xmb-ps5-detail-card-image-wrap { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 10px; + overflow: hidden; +} + +.xmb-ps5-detail-card-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.xmb-ps5-detail-card-image--placeholder { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.04) 0 8px, rgba(255, 255, 255, 0.01) 8px 16px); +} + +.xmb-ps5-detail-card-title { + margin-top: 8px; + font-size: 0.84rem; + font-weight: 800; + color: rgba(255, 255, 255, 0.95); +} + +.xmb-ps5-detail-card-subtitle { + margin-top: 3px; + font-size: 0.72rem; + color: rgba(236, 245, 255, 0.72); +} + .xmb-ps5-menu-thumb-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); From 3fc4f037a4dbe72c3627ff25a4c3a58f1aa268fe Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 13:01:17 -0500 Subject: [PATCH 06/16] feat(controller): refine PS5 row navigation and CSS styles - Enhanced PS5 row navigation logic to conditionally allow transitions between detail and top views based on active states. - Updated CSS styles for improved visual presentation of detail rails and shelf components, including adjustments to opacity and layout. - Improved responsiveness of shelf track elements to enhance user experience during navigation. These changes contribute to a more intuitive and visually appealing interface for users interacting with the controller library. --- .../src/components/ControllerLibraryPage.tsx | 73 +++++++++++++------ opennow-stable/src/renderer/src/styles.css | 26 ++++++- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index b018c276a..4d9ab5cdb 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -639,6 +639,8 @@ export function ControllerLibraryPage({ topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); + const canEnterDetailRow = gamesShelfBrowseActive || mediaShelfBrowseActive; + const canEnterTopRow = topLevelShelfActive || gamesShelfBrowseActive || mediaShelfBrowseActive; const topLevelShelfIndex = topCategory === "media" ? selectedMediaIndex @@ -671,11 +673,7 @@ export function ControllerLibraryPage({ { id: "m3", title: "Storage", subtitle: "Manage media files", imageUrl }, ]; } - return [ - { id: "p1", title: "Explore", subtitle: "Discover recommendations" }, - { id: "p2", title: "Continue", subtitle: "Jump back in quickly" }, - { id: "p3", title: "Tips", subtitle: "Controller shortcuts" }, - ]; + return []; }, [topCategory, gameSubcategory, selectedGame, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); const focusMotionKey = useMemo(() => { if (topCategory === "all" && gameSubcategory !== "root") return `game-${selectedGame?.id ?? "none"}`; @@ -705,6 +703,16 @@ export function ControllerLibraryPage({ 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; const onResize = () => setViewportWidth(window.innerWidth); @@ -814,6 +822,10 @@ export function ControllerLibraryPage({ } if (ps5Row === "detail") { + if (!canEnterDetailRow || detailRailItems.length === 0) { + setPs5Row("main"); + return; + } if (direction === "up") { playUiSound("move"); setPs5Row("main"); @@ -843,8 +855,10 @@ export function ControllerLibraryPage({ if (gamesShelfBrowseActive) { if (shelfHasGames) { if (direction === "down") { - playUiSound("move"); - setPs5Row("detail"); + if (canEnterDetailRow && detailRailItems.length > 0) { + playUiSound("move"); + setPs5Row("detail"); + } return; } if (direction === "left") { @@ -864,21 +878,27 @@ export function ControllerLibraryPage({ return; } if (direction === "up") { - playUiSound("move"); - setPs5Row("top"); + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } return; } } else if (direction === "up") { - playUiSound("move"); - setPs5Row("top"); + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } return; } } if (mediaShelfBrowseActive) { if (direction === "down") { - playUiSound("move"); - setPs5Row("detail"); + if (canEnterDetailRow && detailRailItems.length > 0) { + playUiSound("move"); + setPs5Row("detail"); + } return; } const itemCount = mediaAssetItems.length; @@ -899,8 +919,10 @@ export function ControllerLibraryPage({ return; } if (direction === "up") { - playUiSound("move"); - setPs5Row("top"); + if (canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } return; } } @@ -920,8 +942,13 @@ export function ControllerLibraryPage({ } if (direction === "up" || direction === "down") { - playUiSound("move"); - if (direction === "up") setPs5Row("top"); + if (direction === "up" && canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + } else if (direction === "down" && canEnterDetailRow && detailRailItems.length > 0) { + playUiSound("move"); + setPs5Row("detail"); + } return; } } @@ -1049,6 +1076,10 @@ export function ControllerLibraryPage({ } if (ps5Row === "detail") { + if (!canEnterDetailRow || detailRailItems.length === 0) { + setPs5Row("main"); + return; + } const selectedDetail = detailRailItems[detailRailIndex]; if (!selectedDetail) return; @@ -1491,7 +1522,7 @@ export function ControllerLibraryPage({ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, ps5Row, detailRailIndex, detailRailItems.length]); + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, canEnterDetailRow, canEnterTopRow, ps5Row, detailRailIndex, detailRailItems.length]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1713,10 +1744,10 @@ export function ControllerLibraryPage({ Change Section
-
+
- {ps5Row === "detail" && (gamesShelfBrowseActive || mediaShelfBrowseActive) && ( + {ps5Row === "detail" && canEnterDetailRow && detailRailItems.length > 0 && (
{detailRailItems.map((item, idx) => (
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index f64c32853..c549efe30 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6931,6 +6931,14 @@ button.game-card-store-chip.owned.active:hover { opacity: 0.62; } +.xmb-row-detail .xmb-ps5-detail-rail { + opacity: 1; +} + +.xmb-row-detail .xmb-ps5-focus-meta { + opacity: 0.82; +} + .xmb-layout--ps5-home .xmb-items-container { top: 54%; } @@ -6979,7 +6987,8 @@ button.game-card-store-chip.owned.active:hover { position: absolute; left: clamp(24px, 4vw, 56px); right: clamp(24px, 4vw, 56px); - bottom: clamp(300px, 40vh, 440px); + top: clamp(132px, 18vh, 230px); + bottom: auto; display: flex; flex-direction: column; gap: 14px; @@ -7056,13 +7065,18 @@ button.game-card-store-chip.owned.active:hover { position: absolute; left: 0; right: 0; - bottom: 92px; - height: clamp(210px, 26vh, 280px); + bottom: 108px; + height: clamp(240px, 30vh, 330px); overflow: hidden; mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); } +.xmb-ps5-shelf-viewport--games-root { + bottom: 132px; + height: clamp(340px, 44vh, 520px); +} + .xmb-ps5-shelf-track { display: flex; flex-direction: row; @@ -7070,7 +7084,7 @@ button.game-card-store-chip.owned.active:hover { gap: 16px; width: max-content; min-height: 100%; - padding: 12px 24px 16px; + padding: 12px 24px 28px; box-sizing: border-box; transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); } @@ -7080,6 +7094,10 @@ button.game-card-store-chip.owned.active:hover { padding-bottom: 28px; } +.xmb-ps5-shelf-track--games-root { + padding-bottom: 68px; +} + .xmb-ps5-shelf-track--media { align-items: flex-start; padding-bottom: 18px; From 41b7b968eccf4cacdcaf3c5351eb64bb01370292 Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 14:23:05 -0500 Subject: [PATCH 07/16] feat(controller): update PS5 menu styles and navigation logic - Modified CSS styles for the PS5 menu, enhancing the visual presentation with new background and overlay effects. - Improved navigation logic in the ControllerLibraryPage to better handle transitions between top-level and settings submenus. - Introduced new classes for resume tiles, providing a live snapshot feature for current streaming games. These changes enhance the user experience by creating a more visually appealing and intuitive interface for navigating the controller library. --- .../src/components/ControllerLibraryPage.tsx | 45 ++++++++++--- opennow-stable/src/renderer/src/styles.css | 64 +++++++++++++++---- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 4d9ab5cdb..ba10791fa 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -639,8 +639,9 @@ export function ControllerLibraryPage({ topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); + const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); const canEnterDetailRow = gamesShelfBrowseActive || mediaShelfBrowseActive; - const canEnterTopRow = topLevelShelfActive || gamesShelfBrowseActive || mediaShelfBrowseActive; + const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; const topLevelShelfIndex = topCategory === "media" ? selectedMediaIndex @@ -927,7 +928,7 @@ export function ControllerLibraryPage({ } } - if (topLevelShelfActive) { + if (topLevelRowBehaviorActive) { const itemCount = displayItems.length; if (itemCount > 0 && (direction === "left" || direction === "right")) { const delta = direction === "left" ? -1 : 1; @@ -953,6 +954,19 @@ export function ControllerLibraryPage({ } } + 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) @@ -1274,7 +1288,7 @@ export function ControllerLibraryPage({ }; const secondaryActivateHandler = () => { - if (topLevelShelfActive) { + if (topLevelRowBehaviorActive) { cycleTopCategory(-1); return; } @@ -1368,7 +1382,7 @@ export function ControllerLibraryPage({ }; const tertiaryActivateHandler = () => { - if (topLevelShelfActive) { + if (topLevelRowBehaviorActive) { cycleTopCategory(1); return; } @@ -1476,12 +1490,12 @@ export function ControllerLibraryPage({ tertiaryActivateHandler(); return; } - if (e.key.toLowerCase() === "q" && topLevelShelfActive) { + if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive) { e.preventDefault(); cycleTopCategory(-1); return; } - if (e.key.toLowerCase() === "e" && topLevelShelfActive) { + if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive) { e.preventDefault(); cycleTopCategory(1); return; @@ -1522,7 +1536,7 @@ export function ControllerLibraryPage({ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, canEnterDetailRow, canEnterTopRow, ps5Row, detailRailIndex, detailRailItems.length]); + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelRowBehaviorActive, topLevelShelfIndex, canEnterDetailRow, canEnterTopRow, ps5Row, detailRailIndex, detailRailItems.length]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1758,9 +1772,22 @@ export function ControllerLibraryPage({ item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; const themeRgbLive = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; 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) => ( @@ -1977,7 +2004,7 @@ export function ControllerLibraryPage({ )}
- {topLevelShelfActive ? ( + {topLevelRowBehaviorActive ? ( <>
{controllerType === "ps" ? ( diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index c549efe30..56ae1704f 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1428,9 +1428,9 @@ body.controller-mode.controller-hide-cursor * { display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.55) !important; - -webkit-backdrop-filter: blur(16px) brightness(0.45); - backdrop-filter: blur(16px) brightness(0.45); + background: rgba(5, 10, 14, 0.2) !important; + -webkit-backdrop-filter: blur(8px) saturate(1.05); + backdrop-filter: blur(8px) saturate(1.05); pointer-events: auto; } @@ -1444,15 +1444,7 @@ body.controller-mode.controller-hide-cursor * { box-shadow: none !important; } -/* Hide XMB background gradients/layers when used as an in-stream overlay */ -.controller-overlay .xmb-bg-layer, -.controller-overlay .xmb-bg-gradient { - display: none !important; -} -.controller-overlay .xmb-bg-gradient::before, -.controller-overlay .xmb-bg-gradient::after { - display: none !important; -} +/* Keep the same XMB background stack in-stream for visual parity. */ @media (max-width: 760px) { .controller-hint { @@ -7195,6 +7187,54 @@ button.game-card-store-chip.owned.active:hover { gap: 8px; } +.xmb-ps5-menu-tile--resume { + width: clamp(320px, 32vw, 520px); +} + +.xmb-ps5-menu-resume-preview { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.44); + margin-bottom: 10px; +} + +.xmb-ps5-menu-resume-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.xmb-ps5-menu-resume-image--placeholder { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.04) 0 8px, rgba(255, 255, 255, 0.01) 8px 16px); +} + +.xmb-ps5-menu-resume-overlay { + position: absolute; + inset: auto 0 0 0; + padding: 8px 10px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(6, 10, 14, 0.74) 100%); +} + +.xmb-ps5-menu-resume-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #0c1014; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .xmb-ps5-media-tile { width: clamp(260px, 30vw, 420px); opacity: 0.58; From 1d49ef1d5f13db046f9694ced8b2f4d44cd1d944 Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 20:12:46 -0500 Subject: [PATCH 08/16] feat(controller): enhance navigation and sorting in ControllerLibraryPage - Introduced library sorting options, allowing users to sort games by recent playtime, alphabetical order, or favorites. - Updated navigation logic to manage state for spotlight and categories, improving user interaction with the game library. - Enhanced audio feedback for navigation actions with adjusted sound profiles. - Refined CSS styles for better visual presentation and responsiveness of the library interface. These changes improve the overall user experience by providing more intuitive navigation and organization within the controller library. --- .../src/components/ControllerLibraryPage.tsx | 875 ++++++++++++++---- .../src/renderer/src/controllerNavigation.ts | 28 +- opennow-stable/src/renderer/src/styles.css | 750 ++++++++++++++- 3 files changed, 1449 insertions(+), 204 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index ba10791fa..875de13b0 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -62,10 +62,31 @@ type SoundKind = "move" | "confirm"; 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"; + +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 = 12; +const PREVIEW_TILE_COUNT = 6; +const SPOTLIGHT_RECENT_COUNT = 5; const CONTROLLER_THEME_STYLE_ORDER: readonly ControllerThemeStyle[] = ["aurora", "nebula", "grid", "minimal", "pulse"]; @@ -210,6 +231,21 @@ export function ControllerLibraryPage({ 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 spotlightTrackRef = useRef(null); + + useEffect(() => { + try { + sessionStorage.setItem(LIBRARY_SORT_STORAGE_KEY, librarySortId); + } catch { + } + }, [librarySortId]); useEffect(() => { if (typeof window === "undefined") { @@ -292,8 +328,8 @@ export function ControllerLibraryPage({ 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" }, + move: { start: 720, end: 680, duration: 0.032, volume: 0.009, type: "triangle" }, + confirm: { start: 640, end: 860, duration: 0.07, volume: 0.016, type: "sine" }, }; const active = profile[kind]; @@ -516,19 +552,35 @@ export function ControllerLibraryPage({ const genreName = gameSubcategory.slice(6); return games.filter((game) => game.genres?.includes(genreName)); } - 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 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); - }); - }, [games, favoriteGames, gameSubcategory, topCategory, playtimeData]); + }; + 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) => { @@ -545,6 +597,27 @@ export function ControllerLibraryPage({ }); }, [games, playtimeData]); + const spotlightSlots = useMemo((): (GameInfo | null)[] => { + if (games.length === 0) return []; + 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 + .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, SPOTLIGHT_RECENT_COUNT); + const slots: (GameInfo | null)[] = [...played]; + while (slots.length < SPOTLIGHT_RECENT_COUNT) slots.push(null); + return slots; + }, [games, playtimeData]); + const gameCategoryPreviewById = useMemo(() => { const isNonEmptyString = (value: string | undefined): value is string => typeof value === "string" && value.length > 0; const randomize = (arr: string[]): string[] => { @@ -639,6 +712,7 @@ export function ControllerLibraryPage({ topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); + const gamesDualShelf = topCategory === "all" && gameSubcategory === "root" && games.length > 0; const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); const canEnterDetailRow = gamesShelfBrowseActive || mediaShelfBrowseActive; const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; @@ -652,35 +726,51 @@ export function ControllerLibraryPage({ 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 slot = spotlightSlots[spotlightIndex]; + if (slot) return slot.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]); + }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, gamesRootPlane, spotlightSlots, spotlightIndex]); const detailRailItems = useMemo>(() => { if (topCategory === "all" && gameSubcategory !== "root" && selectedGame) { - const genre = selectedGame.genres?.[0] ? sanitizeGenreName(selectedGame.genres[0]) : "Cloud Action"; - return [ - { id: "d1", title: "Activities", subtitle: `${genre} challenges`, imageUrl: selectedGame.imageUrl }, - { id: "d2", title: "Community", subtitle: "Friends playing now", imageUrl: selectedGame.imageUrl }, - { id: "d3", title: "Store", subtitle: "DLC and add-ons", imageUrl: selectedGame.imageUrl }, + const fav = favoriteGameIdSet.has(selectedGame.id); + const rows: Array<{ id: string; title: string; subtitle: string; imageUrl?: string }> = [ + { + id: "d1", + title: "Play", + subtitle: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch to this title" : "Launch now", + imageUrl: selectedGame.imageUrl, + }, + { id: "d2", title: fav ? "Remove favorite" : "Add favorite", subtitle: "Library", imageUrl: selectedGame.imageUrl }, ]; + if (selectedGame.variants.length > 1) { + rows.push({ id: "d3", title: "Version", subtitle: "Cycle stream variant", imageUrl: selectedGame.imageUrl }); + } + return rows; } if (topCategory === "media" && mediaSubcategory !== "root") { const current = mediaAssetItems[selectedMediaIndex]; const imageUrl = current?.thumbnailDataUrl || current?.dataUrl || (current ? mediaThumbById[current.id] : undefined); return [ - { id: "m1", title: "Recent", subtitle: `Latest ${mediaSubcategory.toLowerCase()}`, imageUrl }, - { id: "m2", title: "By Game", subtitle: "Grouped captures", imageUrl }, - { id: "m3", title: "Storage", subtitle: "Manage media files", imageUrl }, + { id: "m1", title: "Open folder", subtitle: "Reveal in Explorer / Finder", imageUrl }, + { id: "m2", title: "Media hub", subtitle: "Back to Videos & Screenshots", imageUrl }, ]; } return []; - }, [topCategory, gameSubcategory, selectedGame, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); + }, [topCategory, gameSubcategory, selectedGame, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById, favoriteGameIdSet, currentStreamingGame]); const focusMotionKey = useMemo(() => { + if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + const slot = spotlightSlots[spotlightIndex]; + return slot ? `spotlight-${slot.id}` : `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, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); + }, [topCategory, gameSubcategory, gamesRootPlane, spotlightSlots, spotlightIndex, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); const selectedGameDescription = useMemo(() => { if (!selectedGame) return ""; const description = selectedGame.longDescription?.trim() || selectedGame.description?.trim(); @@ -693,7 +783,36 @@ 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" || gameSubcategory !== "root") { + setGamesRootPlane("spotlight"); + setSpotlightIndex(0); + } + }, [topCategory, gameSubcategory]); + + useEffect(() => { + setOptionsOpen(false); + setOptionsEntries([]); + setOptionsFocusIndex(0); + }, [topCategory, gameSubcategory, mediaSubcategory, settingsSubcategory]); + 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); @@ -722,6 +841,30 @@ export function ControllerLibraryPage({ }, []); useLayoutEffect(() => { + const gamesRoot = topCategory === "all" && gameSubcategory === "root"; + if (gamesRoot && gamesRootPlane === "spotlight" && gamesDualShelf) { + const container = spotlightTrackRef.current; + if (!container) return; + const children = Array.from(container.children) as HTMLElement[]; + const activeIndex = spotlightIndex; + if (children.length === 0 || activeIndex >= children.length) { + setListTranslateX(0); + return; + } + let gap = 14; + if (children.length >= 2) { + gap = Math.max(8, children[1].offsetLeft - children[0].offsetLeft - children[0].offsetWidth); + } + let offsetCenter = 0; + for (let i = 0; i < activeIndex; i++) { + offsetCenter += children[i].offsetWidth + gap; + } + offsetCenter += children[activeIndex].offsetWidth / 2; + setListTranslateX(viewportWidth / 2 - offsetCenter); + setListTranslateY(0); + return; + } + const container = itemsContainerRef.current; if (!container) return; const children = Array.from(container.children) as HTMLElement[]; @@ -754,7 +897,21 @@ export function ControllerLibraryPage({ offset += children[selectedIndex].offsetHeight / 2; setListTranslateY(-offset); setListTranslateX(0); - }, [selectedIndex, categorizedGames, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelShelfIndex, selectedMediaIndex, viewportWidth]); + }, [ + selectedIndex, + categorizedGames, + gamesShelfBrowseActive, + mediaShelfBrowseActive, + topLevelShelfActive, + topLevelShelfIndex, + selectedMediaIndex, + viewportWidth, + topCategory, + gameSubcategory, + gamesRootPlane, + spotlightIndex, + spotlightSlots, + ]); const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); @@ -805,6 +962,26 @@ export function ControllerLibraryPage({ } if (isLoading && topCategory !== "settings" && topCategory !== "current") return; + if (optionsOpen && optionsEntries.length > 0) { + if (direction === "up") { + const ni = Math.max(0, optionsFocusIndex - 1); + if (ni !== optionsFocusIndex) { + playUiSound("move"); + setOptionsFocusIndex(ni); + } + return; + } + if (direction === "down") { + const ni = Math.min(optionsEntries.length - 1, optionsFocusIndex + 1); + if (ni !== optionsFocusIndex) { + playUiSound("move"); + setOptionsFocusIndex(ni); + } + return; + } + return; + } + if (ps5Row === "top") { if (direction === "left") { cycleTopCategory(-1); @@ -817,6 +994,9 @@ export function ControllerLibraryPage({ if (direction === "down") { playUiSound("move"); setPs5Row("main"); + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf) { + setGamesRootPlane("spotlight"); + } return; } return; @@ -929,7 +1109,19 @@ export function ControllerLibraryPage({ } 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(spotlightSlots.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)); @@ -943,12 +1135,34 @@ export function ControllerLibraryPage({ } 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"); - } else if (direction === "down" && canEnterDetailRow && detailRailItems.length > 0) { + return; + } + if (direction === "down" && canEnterDetailRow && detailRailItems.length > 0) { playUiSound("move"); setPs5Row("detail"); + return; } return; } @@ -1082,7 +1296,85 @@ export function ControllerLibraryPage({ 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" && spotlightSlots[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); + 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 g = spotlightSlots[spotlightIndex]; + if (g) { + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory("all"); + throttledOnSelectGame(g.id); + setOptionsOpen(false); + playUiSound("confirm"); + } + return; + } + return; + } + if (ps5Row === "top") { setPs5Row("main"); playUiSound("confirm"); @@ -1119,7 +1411,7 @@ export function ControllerLibraryPage({ } if (topCategory === "media" && mediaSubcategory !== "root") { - if (selectedDetail.id === "m3" || selectedDetail.id === "m1") { + if (selectedDetail.id === "m1") { const current = mediaAssetItems[selectedMediaIndex]; if (current && typeof window.openNow?.showMediaInFolder === "function") { void window.openNow.showMediaInFolder({ filePath: current.filePath }); @@ -1268,6 +1560,14 @@ export function ControllerLibraryPage({ playUiSound("confirm"); } else if (topCategory === "all") { if (gameSubcategory === "root") { + if (gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex]) { + const g = spotlightSlots[spotlightIndex]; + setLastRootGameIndex(selectedGameSubcategoryIndex); + setGameSubcategory("all"); + throttledOnSelectGame(g.id); + playUiSound("confirm"); + return; + } const item = displayItems[selectedGameSubcategoryIndex]; if (item) { setLastRootGameIndex(selectedGameSubcategoryIndex); @@ -1288,10 +1588,16 @@ export function ControllerLibraryPage({ }; const secondaryActivateHandler = () => { - if (topLevelRowBehaviorActive) { - cycleTopCategory(-1); - return; - } + if (optionsOpen) 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") { // X button does nothing on current game menu items return; @@ -1373,25 +1679,20 @@ 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 (topLevelRowBehaviorActive) { - cycleTopCategory(1); - return; - } - if (topCategory !== "settings" && topCategory !== "current" && !(topCategory === "all" && gameSubcategory === "root")) { - toggleFavoriteForSelected(); - } + if (optionsOpen) return; + openOptionsMenu(); }; const cancelHandler = (e: Event) => { + if (optionsOpen) { + setOptionsOpen(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") { @@ -1490,6 +1791,16 @@ 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) { e.preventDefault(); cycleTopCategory(-1); @@ -1501,6 +1812,12 @@ export function ControllerLibraryPage({ 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; @@ -1536,7 +1853,69 @@ export function ControllerLibraryPage({ 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, selectedGameSubcategoryIndex, displayItems, mediaAssetItems.length, mediaSubcategory, gameSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, lastRootGameIndex, lastSystemMenuIndex, lastThemeRootIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, onExitControllerMode, onExitApp, editingBandwidth, editingThemeChannel, gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive, topLevelRowBehaviorActive, topLevelShelfIndex, canEnterDetailRow, canEnterTopRow, ps5Row, detailRailIndex, detailRailItems.length]); + }, [ + isLoading, + TOP_CATEGORIES.length, + categorizedGames, + selectedIndex, + selectedGame, + 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, + onCloseGame, + onExitControllerMode, + onExitApp, + editingBandwidth, + editingThemeChannel, + gamesShelfBrowseActive, + mediaShelfBrowseActive, + topLevelShelfActive, + topLevelRowBehaviorActive, + topLevelShelfIndex, + canEnterDetailRow, + canEnterTopRow, + ps5Row, + detailRailIndex, + detailRailItems, + librarySortId, + optionsOpen, + optionsFocusIndex, + optionsEntries.length, + gamesRootPlane, + spotlightIndex, + spotlightSlots, + gamesDualShelf, + favoriteGameIdSet, + microphoneDevices, + ]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { if (kind === "primary") { @@ -1561,6 +1940,12 @@ export function ControllerLibraryPage({ ? mediaAssetItems[selectedMediaIndex] ?? null : null; const heroBackdropUrl = useMemo(() => { + if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightSlots.length > 0) { + const cur = spotlightSlots[spotlightIndex]; + if (cur?.imageUrl) return cur.imageUrl; + const fallback = spotlightSlots.find((g) => g?.imageUrl); + return fallback?.imageUrl ?? null; + } if (topCategory === "all") return selectedGame?.imageUrl ?? null; if (topCategory === "current") return currentStreamingGame?.imageUrl ?? null; if (topCategory === "media") { @@ -1570,17 +1955,113 @@ export function ControllerLibraryPage({ } if (currentStreamingGame?.imageUrl) return currentStreamingGame.imageUrl; return selectedGame?.imageUrl ?? null; - }, [topCategory, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); + }, [topCategory, gameSubcategory, gamesRootPlane, spotlightSlots, 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 topLevelMenuTrack = ( +
+ {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 = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + 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} + /> + {`${settings.maxBitrateMbps ?? 75} 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.value} + )} +
+ ) : null} +
+ ); + })} +
+ ); + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; return ( @@ -1611,7 +2092,11 @@ export function ControllerLibraryPage({
) : null} {heroBackdropUrl ? ( -
+
) : null}
@@ -1663,19 +2148,24 @@ export function ControllerLibraryPage({ {topCategory === "all" && gameSubcategory !== "root" && (
- {selectedGame ? ( + {!isLoading && categorizedGames.length === 0 ? ( +
+

No games here

+

Try another category or refresh your library.

+
+ ) : selectedGame ? (

{selectedGame.title}

{currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"} - - {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"} - - {selectedGame.variants.length > 1 ? Variant : null} + Options
+ {gameSubcategory === "all" ? ( + Sort: {LIBRARY_SORT_LABEL[librarySortId]} + ) : null} {(() => { const record = playtimeData[selectedGame.id]; const totalSecs = record?.totalSeconds ?? 0; @@ -1717,6 +2207,9 @@ export function ControllerLibraryPage({
) : null} +
+ Games +
- {categorizedGames.map((game, idx) => { - const isActive = idx === selectedIndex; - return ( -
- {favoriteGameIdSet.has(game.id) ? : null} -
- + {!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 ? :
} +
+
+ ); + })}
@@ -1753,103 +2252,103 @@ export function ControllerLibraryPage({

{selectedTopLevelItemLabel}

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

+ {spotlightSlots[spotlightIndex] + ? "Recently played · Enter opens this title in your library" + : "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}
Enter Change Section
-
-
- {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 = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; - 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} - /> - {`${settings.maxBitrateMbps ?? 75} 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" : ""} - + {gamesDualShelf ? ( +
+
+
+ Recently played +
+
+
+ {spotlightSlots.map((game, idx) => { + const isActive = gamesRootPlane === "spotlight" && idx === spotlightIndex; + const key = game ? game.id : `recent-empty-${idx}`; + return ( +
+
+ {game?.imageUrl ? :
}
- ) : ( - {item.value} - )} -
- ) : null} +
+ ); + })}
- ); - })} +
+
+
+
+ Library +
+
{topLevelMenuTrack}
+
-
+ ) : ( + <> +
+ + {topCategory === "current" ? "Current" : topCategory === "settings" ? "Settings" : topCategory === "all" ? "Library" : "Media"} + +
+
{topLevelMenuTrack}
+ + )}
)} @@ -1861,9 +2360,12 @@ export function ControllerLibraryPage({
Open Folder - Back To Media + Options
+
+ Captures +
)} + {optionsOpen && optionsEntries.length > 0 ? ( +
+
+
+
Options
+
    + {optionsEntries.map((opt, i) => ( +
  • + {opt.label} +
  • + ))} +
+
+
+ ) : null} +
{topLevelRowBehaviorActive ? ( <> @@ -2133,6 +2651,7 @@ export function ControllerLibraryPage({ )} Open Folder
+
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
{controllerType === "ps" ? ( @@ -2153,31 +2672,35 @@ export function ControllerLibraryPage({ Library filters · Up
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
- {selectedGame && selectedGame.variants.length > 1 ? ( -
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Variant
- ) : null} - {selectedGame ? ( -
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"}
+ {gameSubcategory === "all" ? ( +
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Sort
) : null} +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
) : topCategory === "all" && gameSubcategory === "root" ? ( -
- {controllerType === "ps" ? ( - - ) : ( - - )} - Enter -
+ <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + {gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex] ? "View in library" : "Enter"} +
+ {gamesRootPlane === "spotlight" && spotlightSlots[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 && selectedGame.variants.length > 1 ? ( -
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Variant
- ) : null} - {selectedGame ? ( -
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} {favoriteGameIdSet.has(selectedGame.id) ? "Unfavorite" : "Favorite"}
- ) : null} +
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
)}
diff --git a/opennow-stable/src/renderer/src/controllerNavigation.ts b/opennow-stable/src/renderer/src/controllerNavigation.ts index 93928c9eb..e09f8dfd5 100644 --- a/opennow-stable/src/renderer/src/controllerNavigation.ts +++ b/opennow-stable/src/renderer/src/controllerNavigation.ts @@ -25,6 +25,16 @@ const INTERACTIVE_SELECTOR = [ const DIRECTION_INITIAL_REPEAT_MS = 240; const DIRECTION_REPEAT_MS = 110; +const DIRECTION_REPEAT_MIN_MS = 48; +const DIRECTION_REPEAT_ACCEL_STEP_MS = 7; +const DIRECTION_REPEAT_ACCEL_MAX_EXTRA = 62; + +function nextAcceleratedRepeatMs(repeatCount: number): number { + return Math.max( + DIRECTION_REPEAT_MIN_MS, + DIRECTION_REPEAT_MS - Math.min(DIRECTION_REPEAT_ACCEL_MAX_EXTRA, repeatCount * DIRECTION_REPEAT_ACCEL_STEP_MS), + ); +} function isElementInteractive(el: Element): el is HTMLElement { return el instanceof HTMLElement; @@ -268,11 +278,11 @@ export function useControllerNavigation({ const connectedRef = useRef(false); const frameRef = useRef(null); - const directionStateRef = useRef>({ - up: { pressed: false, nextRepeatAt: 0 }, - down: { pressed: false, nextRepeatAt: 0 }, - left: { pressed: false, nextRepeatAt: 0 }, - right: { pressed: false, nextRepeatAt: 0 }, + const directionStateRef = useRef>({ + up: { pressed: false, nextRepeatAt: 0, repeatCount: 0 }, + down: { pressed: false, nextRepeatAt: 0, repeatCount: 0 }, + left: { pressed: false, nextRepeatAt: 0, repeatCount: 0 }, + right: { pressed: false, nextRepeatAt: 0, repeatCount: 0 }, }); const actionStateRef = useRef({ @@ -311,6 +321,7 @@ export function useControllerNavigation({ if (!pad || !enabled) { for (const state of Object.values(directionStateRef.current)) { state.pressed = false; + state.repeatCount = 0; } actionStateRef.current = { a: false, x: false, y: false, b: false, lb: false, rb: false }; frameRef.current = window.requestAnimationFrame(tick); @@ -334,11 +345,13 @@ export function useControllerNavigation({ const state = directionStateRef.current[direction]; if (!pressed) { state.pressed = false; + state.repeatCount = 0; return; } if (!state.pressed) { state.pressed = true; + state.repeatCount = 0; state.nextRepeatAt = now + DIRECTION_INITIAL_REPEAT_MS; if (onDirectionInput?.(direction)) { return; @@ -348,10 +361,13 @@ export function useControllerNavigation({ } if (now >= state.nextRepeatAt) { - state.nextRepeatAt = now + DIRECTION_REPEAT_MS; if (onDirectionInput?.(direction)) { + state.repeatCount += 1; + state.nextRepeatAt = now + nextAcceleratedRepeatMs(state.repeatCount); return; } + state.repeatCount = 0; + state.nextRepeatAt = now + DIRECTION_REPEAT_MS; moveFocus(direction); } }; diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 56ae1704f..01f3e5c49 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1444,6 +1444,20 @@ body.controller-mode.controller-hide-cursor * { box-shadow: none !important; } +.controller-overlay > .xmb-wrapper.xmb-layout--ps5-home { + inset: unset !important; + top: 0 !important; + left: 0 !important; + right: auto !important; + bottom: auto !important; + width: calc(100% / 0.75) !important; + height: calc(100% / 0.75) !important; + max-width: none !important; + max-height: none !important; + transform: scale(0.75) !important; + transform-origin: top left !important; +} + /* Keep the same XMB background stack in-stream for visual parity. */ @media (max-width: 760px) { @@ -6572,6 +6586,24 @@ button.game-card-store-chip.owned.active:hover { --xmb-theme-b: 177; } +/* + * PS5 controller hub: 25% smaller (scale 0.75). Layout size is expanded so the scaled + * subtree still covers the viewport (avoids letterboxing). + */ +.xmb-wrapper.xmb-layout--ps5-home { + inset: unset; + top: 0; + left: 0; + right: auto; + bottom: auto; + width: calc(100vw / 0.75); + height: calc(100vh / 0.75); + max-width: none; + max-height: none; + transform: scale(0.75); + transform-origin: top left; +} + .xmb-wrapper::after { content: ""; position: absolute; @@ -6791,6 +6823,29 @@ button.game-card-store-chip.owned.active:hover { opacity: 0.62; pointer-events: none; transform: scale(1.06); + transition: + opacity var(--xmb-hero-crossfade-ms, 420ms) cubic-bezier(0.22, 1, 0.36, 1), + filter 420ms ease, + transform 420ms ease; +} + +@keyframes xmb-ps5-hero-burns { + 0% { + transform: scale(1.06) translate(0, 0); + } + 100% { + transform: scale(1.11) translate(-1.1%, -0.7%); + } +} + +.xmb-ps5-hero-art--motion { + animation: xmb-ps5-hero-burns 28s ease-in-out infinite alternate; +} + +@media (prefers-reduced-motion: reduce) { + .xmb-ps5-hero-art--motion { + animation: none !important; + } } .xmb-ps5-parallax-field { @@ -6854,10 +6909,11 @@ button.game-card-store-chip.owned.active:hover { } .xmb-layout--ps5-home .xmb-bg-overlay { + /* Keep readability at top; avoid a heavy bottom vignette that reads as “masking” through translucent shelf cards */ background: - linear-gradient(180deg, rgba(8, 10, 14, 0.52) 0%, rgba(10, 14, 18, 0.28) 38%, rgba(6, 8, 12, 0.06) 62%, rgba(0, 0, 0, 0.72) 100%), - radial-gradient(120% 90% at 50% 22%, rgba(0, 0, 0, 0.08), transparent 52%), - linear-gradient(90deg, rgba(0, 0, 0, 0.26) 0%, rgba(0, 0, 0, 0) 22%, rgba(0, 0, 0, 0) 78%, rgba(0, 0, 0, 0.26) 100%); + linear-gradient(180deg, rgba(8, 10, 14, 0.36) 0%, rgba(10, 14, 18, 0.16) 42%, rgba(6, 8, 12, 0.05) 68%, rgba(0, 0, 0, 0.28) 100%), + radial-gradient(120% 90% at 50% 22%, rgba(0, 0, 0, 0.06), transparent 52%), + linear-gradient(90deg, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0) 22%, rgba(0, 0, 0, 0) 78%, rgba(0, 0, 0, 0.14) 100%); } .xmb-layout--ps5-home .xmb-categories-container { @@ -6968,6 +7024,12 @@ button.game-card-store-chip.owned.active:hover { border-top: 1px solid rgba(221, 255, 233, 0.12); } +/* Dual-shelf column: vertical band + footer clearance (overridden in compact media) */ +.xmb-layout--ps5-home { + --xmb-ps5-shelf-bottom: calc(84px + 20px); + --xmb-ps5-shelf-top: clamp(156px, 20dvh, 280px); +} + .xmb-wrapper .xmb-ps5-stack { position: absolute; inset: 0; @@ -6987,19 +7049,35 @@ button.game-card-store-chip.owned.active:hover { align-items: flex-start; max-width: min(82vw, 1080px); z-index: 6; - animation: xmb-ps5-focus-in 280ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation: xmb-ps5-focus-in var(--xmb-hero-crossfade-ms, 280ms) cubic-bezier(0.22, 1, 0.36, 1) both; } .xmb-ps5-focus-title { margin: 0; - font-size: clamp(1.65rem, 3.6vw, 2.75rem); + font-size: clamp(1.85rem, 4vw, 3.05rem); font-weight: 800; letter-spacing: -0.03em; + line-height: 1.05; color: rgba(255, 255, 255, 0.97); text-shadow: 0 2px 24px rgba(0, 0, 0, 0.55), 0 1px 3px rgba(0, 0, 0, 0.45); max-width: min(720px, 88vw); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.xmb-ps5-focus-subtitle { + margin: 0; + max-width: min(560px, 88vw); + font-size: 0.95rem; + line-height: 1.35; + font-weight: 600; + color: rgba(255, 255, 255, 0.72); + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45); } .xmb-ps5-focus-chips { @@ -7060,8 +7138,6 @@ button.game-card-store-chip.owned.active:hover { bottom: 108px; height: clamp(240px, 30vh, 330px); overflow: hidden; - mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); - -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%); } .xmb-ps5-shelf-viewport--games-root { @@ -7069,6 +7145,568 @@ button.game-card-store-chip.owned.active:hover { height: clamp(340px, 44vh, 520px); } +/* Non–dual-shelf games root: never exceed viewport; scroll if the window is very short */ +.xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { + height: min(clamp(300px, 40vh, 520px), calc(100dvh - 200px)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root::-webkit-scrollbar { + display: none; +} + +.xmb-ps5-shelf-label-row { + position: absolute; + left: clamp(24px, 4vw, 48px); + z-index: 6; + pointer-events: none; + opacity: 0.45; + transition: opacity 280ms ease; +} + +.xmb-ps5-shelf-label-row--active { + opacity: 1; +} + +.xmb-ps5-shelf-label { + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45); +} + +.xmb-ps5-shelf-label-row--active .xmb-ps5-shelf-label { + color: rgba(255, 255, 255, 0.88); +} + +.xmb-ps5-shelf-label-row--spotlight { + bottom: clamp(308px, 43vh, 452px); +} + +.xmb-ps5-shelf-label-row--library, +.xmb-ps5-shelf-label-row--games-list, +.xmb-ps5-shelf-label-row--media-list { + bottom: clamp(188px, 28vh, 268px); +} + +/* Spotlight strip (fallback when not using dual-shelf stack metrics) */ +.xmb-ps5-shelf-viewport--spotlight { + bottom: clamp(300px, 42vh, 440px); + height: clamp(150px, 20vh, 210px); + overflow: visible; +} + +/* + * Games root + spotlight: bottom-anchored flex column so spotlight and library stack in flow + * (no overlapping absolute bands). Spotlight tiles use flex-end + scale; transform-origin + * bottom limits growth into the library row. + */ +.xmb-ps5-shelf-anchored { + position: absolute; + left: 0; + right: 0; + top: var(--xmb-ps5-shelf-top); + bottom: var(--xmb-ps5-shelf-bottom); + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + gap: clamp(12px, 2.2vh, 28px); + min-height: 0; + overflow-x: visible; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; + pointer-events: auto; +} + +.xmb-ps5-shelf-anchored::-webkit-scrollbar { + display: none; +} + +.xmb-ps5-shelf-band { + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; + pointer-events: none; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-label-row { + position: relative; + left: clamp(24px, 4vw, 48px); + bottom: auto; + z-index: 2; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-label-row--library .xmb-ps5-shelf-label { + color: rgba(255, 255, 255, 0.92); + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.95), + 0 2px 28px rgba(0, 0, 0, 0.82), + 0 0 1px rgba(0, 0, 0, 0.98); +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-viewport { + position: relative; + left: 0; + right: 0; + bottom: auto; + flex-shrink: 0; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-viewport--spotlight { + height: auto; + z-index: 7; + overflow: visible; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-viewport--games-root { + height: auto; + min-height: 0; + max-height: none; + z-index: 6; + overflow: visible; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-viewport--spotlight .xmb-ps5-tile { + transform-origin: 50% 100%; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-tile--spotlight.active { + transform: translateY(-6px) scale(1.03) translateZ(0); +} + +.xmb-ps5-shelf-track--spotlight { + align-items: flex-end; + padding: 8px 24px 12px; + gap: 12px; +} + +.xmb-ps5-shelf-anchored .xmb-ps5-shelf-track--spotlight { + padding-bottom: 6px; +} + +/* PS5 home: fit hero, shelves, and category tiles on shorter or narrower windows */ +@media (max-height: 860px), (max-width: 1240px) { + .xmb-layout--ps5-home { + --xmb-ps5-shelf-bottom: calc(72px + 20px); + --xmb-ps5-shelf-top: clamp(132px, 17dvh, 232px); + } + + .xmb-layout--ps5-home .xmb-detail-layer { + bottom: 148px; + } + + .xmb-layout--ps5-home .xmb-categories-container { + top: 10%; + } + + .xmb-layout--ps5-home .xmb-category-item { + width: 92px; + height: 82px; + margin-right: 22px; + } + + .xmb-layout--ps5-home .xmb-ps5-focus-meta { + top: clamp(92px, 14.5vh, 188px); + gap: 10px; + max-width: min(90vw, 960px); + } + + .xmb-layout--ps5-home .xmb-ps5-focus-title { + font-size: clamp(1.42rem, 3.35vw, 2.5rem); + -webkit-line-clamp: 2; + } + + .xmb-layout--ps5-home .xmb-ps5-focus-subtitle { + font-size: 0.86rem; + line-height: 1.32; + } + + .xmb-layout--ps5-home .xmb-ps5-action { + padding: 5px 12px; + font-size: 0.66rem; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-viewport { + bottom: 92px; + height: clamp(196px, 25.5vh, 300px); + } + + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { + bottom: 114px; + height: clamp(268px, 36vh, 420px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-viewport--spotlight { + bottom: clamp(268px, 38vh, 400px); + height: clamp(128px, 16.5vh, 188px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--spotlight { + bottom: clamp(276px, 39vh, 412px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--library, + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--games-list, + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--media-list { + bottom: clamp(168px, 25vh, 248px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-anchored { + gap: clamp(10px, 1.8vh, 22px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-band { + gap: 5px; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track { + padding: 10px 18px 20px; + gap: 14px; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track--menu { + padding-bottom: 20px; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track--games-root { + padding-bottom: 48px; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track--spotlight { + padding: 6px 16px 8px; + gap: 10px; + } + + .xmb-layout--ps5-home .xmb-ps5-tile { + width: 106px; + } + + .xmb-layout--ps5-home .xmb-ps5-tile.active { + transform: translateY(0) scale(1.06) translateZ(12px); + } + + .xmb-layout--ps5-home .xmb-ps5-tile--spotlight { + width: 82px; + } + + .xmb-layout--ps5-home .xmb-ps5-tile--spotlight.active { + transform: translateY(-4px) scale(1.04) translateZ(8px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-anchored .xmb-ps5-tile--spotlight.active { + transform: translateY(-4px) scale(1.02) translateZ(0); + } + + .xmb-layout--ps5-home .xmb-ps5-menu-tile { + width: clamp(212px, 21vw, 308px); + min-height: 112px; + padding: 12px 14px; + border-radius: 14px; + } + + .xmb-layout--ps5-home .xmb-ps5-menu-tile.active { + transform: translateY(0) scale(1.02) translateZ(8px); + } + + .xmb-layout--ps5-home .xmb-ps5-menu-tile--resume { + width: clamp(268px, 27vw, 420px); + } + + .xmb-layout--ps5-home .xmb-ps5-menu-title { + font-size: 0.96rem; + margin-bottom: 6px; + } + + .xmb-layout--ps5-home .xmb-ps5-menu-thumb-row { + min-height: 72px; + gap: 5px; + margin-bottom: 8px; + } + + .xmb-layout--ps5-home .xmb-ps5-detail-rail { + bottom: clamp(162px, 23.5vh, 268px); + gap: 10px; + } + + .xmb-layout--ps5-home .xmb-ps5-detail-card { + width: clamp(152px, 15vw, 220px); + padding: 8px; + border-radius: 12px; + } + + .xmb-layout--ps5-home .xmb-ps5-media-tile { + width: clamp(228px, 27vw, 360px); + } + + .xmb-layout--ps5-home .xmb-footer { + height: 72px; + gap: 42px; + } + + .xmb-layout--ps5-home .xmb-btn-hint { + font-size: 0.68rem; + gap: 10px; + letter-spacing: 0.12em; + } + + .xmb-layout--ps5-home .xmb-top-left { + top: 26px; + left: 42px; + } + + .xmb-layout--ps5-home .xmb-top-right { + top: 26px; + right: 42px; + gap: 18px; + } + + .xmb-layout--ps5-home .xmb-logo img { + height: 76px; + } + + .xmb-layout--ps5-home .xmb-clock { + font-size: 1.12rem; + } + + .xmb-layout--ps5-home .xmb-ps5-parallax-tile { + width: clamp(96px, 10vw, 158px); + border-radius: 12px; + } +} + +@media (max-height: 700px), (max-width: 980px) { + .xmb-layout--ps5-home { + --xmb-ps5-shelf-bottom: calc(64px + 16px); + --xmb-ps5-shelf-top: clamp(118px, 14dvh, 200px); + } + + .xmb-layout--ps5-home .xmb-detail-layer { + bottom: 124px; + } + + .xmb-layout--ps5-home .xmb-categories-container { + top: 8%; + } + + .xmb-layout--ps5-home .xmb-category-item { + width: 84px; + height: 76px; + margin-right: 16px; + transform: scale(0.78); + } + + .xmb-layout--ps5-home .xmb-category-item.active { + transform: scale(1.02); + } + + .xmb-layout--ps5-home .xmb-row-top .xmb-category-item.active { + transform: scale(1.12); + } + + .xmb-layout--ps5-home .xmb-ps5-focus-meta { + top: clamp(76px, 12vh, 148px); + gap: 8px; + } + + .xmb-layout--ps5-home .xmb-ps5-focus-title { + font-size: clamp(1.18rem, 3vw, 2.05rem); + } + + .xmb-layout--ps5-home .xmb-ps5-focus-subtitle { + font-size: 0.8rem; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-viewport { + bottom: 80px; + height: clamp(172px, 22vh, 260px); + } + + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { + bottom: 100px; + height: clamp(220px, 30vh, 340px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-viewport--spotlight { + bottom: clamp(232px, 34vh, 340px); + height: clamp(108px, 13.5vh, 158px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--spotlight { + bottom: clamp(240px, 35vh, 352px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--library, + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--games-list, + .xmb-layout--ps5-home .xmb-ps5-shelf-label-row--media-list { + bottom: clamp(144px, 21vh, 212px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-anchored { + gap: clamp(8px, 1.5vh, 16px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track { + padding: 8px 14px 16px; + gap: 12px; + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-track--games-root { + padding-bottom: 40px; + } + + .xmb-layout--ps5-home .xmb-ps5-tile { + width: 96px; + } + + .xmb-layout--ps5-home .xmb-ps5-tile--spotlight { + width: 74px; + } + + .xmb-layout--ps5-home .xmb-ps5-menu-tile { + width: clamp(188px, 19vw, 268px); + min-height: 100px; + padding: 10px 12px; + border-radius: 12px; + } + + .xmb-layout--ps5-home .xmb-ps5-menu-tile--resume { + width: clamp(228px, 24vw, 360px); + } + + .xmb-layout--ps5-home .xmb-ps5-menu-title { + font-size: 0.88rem; + } + + .xmb-layout--ps5-home .xmb-ps5-menu-thumb-row { + min-height: 62px; + gap: 4px; + margin-bottom: 6px; + } + + .xmb-layout--ps5-home .xmb-ps5-detail-rail { + bottom: clamp(138px, 20vh, 228px); + gap: 8px; + } + + .xmb-layout--ps5-home .xmb-ps5-detail-card { + width: clamp(138px, 14vw, 196px); + padding: 6px; + } + + .xmb-layout--ps5-home .xmb-ps5-media-tile { + width: clamp(200px, 24vw, 300px); + } + + .xmb-layout--ps5-home .xmb-footer { + height: 64px; + gap: 28px; + } + + .xmb-layout--ps5-home .xmb-btn-hint { + font-size: 0.62rem; + gap: 8px; + } + + .xmb-layout--ps5-home .xmb-top-left { + top: 18px; + left: 28px; + } + + .xmb-layout--ps5-home .xmb-top-right { + top: 18px; + right: 28px; + gap: 12px; + } + + .xmb-layout--ps5-home .xmb-logo img { + height: 62px; + } + + .xmb-layout--ps5-home .xmb-clock { + font-size: 1rem; + } + + .xmb-layout--ps5-home .xmb-ps5-options-sheet { + padding-bottom: clamp(56px, 9vh, 96px); + } + + .xmb-layout--ps5-home .xmb-ps5-shelf-anchored .xmb-ps5-tile--spotlight.active { + transform: translateY(-3px) scale(1.02) translateZ(0); + } + + .xmb-layout--ps5-home .xmb-ps5-parallax-tile { + width: clamp(88px, 9vw, 140px); + border-radius: 12px; + } +} + +.xmb-ps5-options-sheet { + position: absolute; + inset: 0; + z-index: 40; + display: flex; + align-items: flex-end; + justify-content: center; + pointer-events: auto; + padding-bottom: clamp(72px, 12vh, 120px); +} + +.xmb-ps5-options-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 6, 10, 0.55); + backdrop-filter: blur(6px); +} + +.xmb-ps5-options-panel { + position: relative; + width: min(420px, 92vw); + max-height: min(52vh, 420px); + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(10, 14, 20, 0.92); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); +} + +.xmb-ps5-options-title { + padding: 14px 18px 8px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.xmb-ps5-options-list { + list-style: none; + margin: 0; + padding: 8px 0 12px; +} + +.xmb-ps5-options-item { + padding: 12px 20px; + font-size: 1rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.82); + transition: background 160ms ease, color 160ms ease; +} + +.xmb-ps5-options-item.active { + background: rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.22); + color: rgba(255, 255, 255, 0.98); +} + .xmb-ps5-shelf-track { display: flex; flex-direction: row; @@ -7099,19 +7737,55 @@ button.game-card-store-chip.owned.active:hover { position: relative; flex-shrink: 0; width: 118px; - opacity: 0.52; - transform: translateY(10px) scale(0.92); + opacity: 0.38; + transform: translateY(12px) scale(0.9) translateZ(0); + filter: saturate(0.88); transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), - transform 420ms cubic-bezier(0.22, 1, 0.36, 1); + transform 420ms cubic-bezier(0.22, 1, 0.36, 1), + filter 420ms ease; } .xmb-ps5-tile.active { opacity: 1; - transform: translateY(0) scale(1.06); + transform: translateY(0) scale(1.1) translateZ(12px); + filter: saturate(1.05); z-index: 2; } +.xmb-ps5-tile--spotlight { + width: 96px; + opacity: 0.4; +} + +.xmb-ps5-tile--spotlight.active { + transform: translateY(0) scale(1.06) translateZ(8px); +} + +/* Empty “recently played” slot (no title yet): reads as reserved space, not a missing cover */ +.xmb-ps5-tile--spotlight-empty .xmb-ps5-tile-frame { + border-style: dashed; + border-color: rgba(255, 255, 255, 0.22); + background: rgba(0, 0, 0, 0.22); + box-shadow: none; +} + +.xmb-ps5-tile--spotlight-empty .xmb-ps5-tile-cover--placeholder { + background: repeating-linear-gradient( + -18deg, + rgba(255, 255, 255, 0.04), + rgba(255, 255, 255, 0.04) 6px, + rgba(0, 0, 0, 0.06) 6px, + rgba(0, 0, 0, 0.06) 12px + ); +} + +/* Spotlight covers use same 2:3 poster box; placeholders must not force a taller min than the frame */ +.xmb-ps5-tile--spotlight .xmb-ps5-tile-frame--placeholder, +.xmb-ps5-tile--spotlight .xmb-ps5-tile-cover--placeholder { + min-height: 0; +} + .xmb-ps5-tile-frame { border-radius: 14px; overflow: hidden; @@ -7126,12 +7800,26 @@ button.game-card-store-chip.owned.active:hover { box-shadow 360ms ease; } +.xmb-ps5-tile-frame--placeholder { + width: 100%; + height: 100%; + min-height: 160px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.35)); +} + +.xmb-ps5-tile-cover--placeholder { + width: 100%; + height: 100%; + min-height: 160px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.4)); +} + .xmb-ps5-tile.active .xmb-ps5-tile-frame { border-color: rgba(255, 255, 255, 0.96); box-shadow: - 0 22px 52px rgba(0, 0, 0, 0.62), - 0 0 0 3px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.42), - 0 0 28px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.35); + 0 26px 58px rgba(0, 0, 0, 0.68), + 0 0 0 3px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.45), + 0 0 36px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.38); } .xmb-ps5-tile-cover { @@ -7160,18 +7848,20 @@ button.game-card-store-chip.owned.active:hover { box-sizing: border-box; background: rgba(10, 14, 20, 0.44); border: 1px solid rgba(255, 255, 255, 0.14); - opacity: 0.58; - transform: translateY(10px) scale(0.92); + opacity: 0.48; + transform: translateY(12px) scale(0.9) translateZ(0); + filter: saturate(0.9); transition: all 420ms cubic-bezier(0.22, 1, 0.36, 1); } .xmb-ps5-menu-tile.active { opacity: 1; - transform: translateY(0) scale(1.03); - border-color: rgba(255, 255, 255, 0.65); + transform: translateY(0) scale(1.04) translateZ(8px); + filter: saturate(1.02); + border-color: rgba(255, 255, 255, 0.72); box-shadow: - 0 18px 44px rgba(0, 0, 0, 0.48), - 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.28); + 0 22px 50px rgba(0, 0, 0, 0.52), + 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.32); } .xmb-ps5-menu-title { @@ -7281,8 +7971,10 @@ button.game-card-store-chip.owned.active:hover { .xmb-ps5-media-caption { margin-top: 10px; + min-height: 2.75em; font-size: 0.9rem; font-weight: 700; + line-height: 1.35; color: rgba(255, 255, 255, 0.94); white-space: nowrap; overflow: hidden; @@ -7361,10 +8053,10 @@ button.game-card-store-chip.owned.active:hover { .xmb-ps5-menu-thumb-row { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-bottom: 10px; - min-height: 128px; + min-height: 88px; } .xmb-ps5-menu-thumb { @@ -7430,6 +8122,20 @@ button.game-card-store-chip.owned.active:hover { linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0) 28%, rgba(0, 0, 0, 0) 72%, rgba(0, 0, 0, 0.12) 100%); } +/* Grid theme + PS5 hub: previous rule is darker at bottom; lighten so library row doesn’t look masked */ +.xmb-wrapper.xmb-layout--ps5-home.xmb-theme-grid .xmb-bg-overlay { + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 56px), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 56px), + linear-gradient( + 180deg, + rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.08) 0%, + rgba(0, 0, 0, 0.1) 50%, + rgba(0, 0, 0, 0.2) 100% + ), + linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0) 28%, rgba(0, 0, 0, 0) 72%, rgba(0, 0, 0, 0.1) 100%); +} + .xmb-theme-minimal .xmb-bg-layer { background: linear-gradient( 185deg, From 0cfc5800430faf513ac410ace544a2cddd6cf559 Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 20:26:34 -0500 Subject: [PATCH 09/16] feat(controller): improve shelf navigation and CSS transitions - Added new properties for lazy-loading shelf art to enhance performance during image rendering. - Implemented a function to compute shelf translation for centering active tiles, improving user experience in dual-shelf navigation. - Updated CSS transitions to utilize a variable for consistent timing across components, ensuring a cohesive feel during interactions. These changes enhance the navigation experience and visual consistency within the ControllerLibraryPage. --- .../src/components/ControllerLibraryPage.tsx | 123 +++++++++++------- opennow-stable/src/renderer/src/styles.css | 4 +- 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 875de13b0..7346b3de1 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -88,6 +88,22 @@ 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 = { @@ -201,6 +217,9 @@ 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, ); @@ -815,7 +834,11 @@ export function ControllerLibraryPage({ }, [gamesShelfBrowseActive, selectedIndex, categorizedGames]); useEffect(() => { - if (!gamesShelfBrowseActive && !mediaShelfBrowseActive && !topLevelShelfActive) setListTranslateX(0); + if (!gamesShelfBrowseActive && !mediaShelfBrowseActive && !topLevelShelfActive) { + setListTranslateX(0); + setSpotlightShelfTranslateX(0); + setGamesRootMenuTranslateX(0); + } }, [gamesShelfBrowseActive, mediaShelfBrowseActive, topLevelShelfActive]); useEffect(() => { @@ -835,32 +858,29 @@ export function ControllerLibraryPage({ useEffect(() => { if (typeof window === "undefined") return; - const onResize = () => setViewportWidth(window.innerWidth); + let raf = 0; + const onResize = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => setViewportWidth(window.innerWidth)); + }; window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("resize", onResize); + }; }, []); useLayoutEffect(() => { const gamesRoot = topCategory === "all" && gameSubcategory === "root"; - if (gamesRoot && gamesRootPlane === "spotlight" && gamesDualShelf) { - const container = spotlightTrackRef.current; - if (!container) return; - const children = Array.from(container.children) as HTMLElement[]; - const activeIndex = spotlightIndex; - if (children.length === 0 || activeIndex >= children.length) { - setListTranslateX(0); - return; - } - let gap = 14; - if (children.length >= 2) { - gap = Math.max(8, children[1].offsetLeft - children[0].offsetLeft - children[0].offsetWidth); - } - let offsetCenter = 0; - for (let i = 0; i < activeIndex; i++) { - offsetCenter += children[i].offsetWidth + gap; - } - offsetCenter += children[activeIndex].offsetWidth / 2; - setListTranslateX(viewportWidth / 2 - offsetCenter); + + if (!gamesRoot || !gamesDualShelf) { + setSpotlightShelfTranslateX(0); + setGamesRootMenuTranslateX(0); + } + + if (gamesRoot && gamesDualShelf) { + setSpotlightShelfTranslateX(computeShelfTranslateXToCenter(spotlightTrackRef.current, spotlightIndex)); + setGamesRootMenuTranslateX(computeShelfTranslateXToCenter(itemsContainerRef.current, topLevelShelfIndex)); setListTranslateY(0); return; } @@ -875,16 +895,7 @@ export function ControllerLibraryPage({ } if (gamesShelfBrowseActive || mediaShelfBrowseActive || topLevelShelfActive) { - let gap = 14; - if (children.length >= 2) { - gap = Math.max(8, children[1].offsetLeft - children[0].offsetLeft - children[0].offsetWidth); - } - let offsetCenter = 0; - for (let i = 0; i < activeIndex; i++) { - offsetCenter += children[i].offsetWidth + gap; - } - offsetCenter += children[activeIndex].offsetWidth / 2; - setListTranslateX(viewportWidth / 2 - offsetCenter); + setListTranslateX(computeShelfTranslateXToCenter(container, activeIndex)); setListTranslateY(0); return; } @@ -908,7 +919,7 @@ export function ControllerLibraryPage({ viewportWidth, topCategory, gameSubcategory, - gamesRootPlane, + gamesDualShelf, spotlightIndex, spotlightSlots, ]); @@ -1967,7 +1978,11 @@ export function ControllerLibraryPage({ 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 topLevelMenuTrack = ( + 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 = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; + const themeRgbLive = themeRgbForTrack; const isGameRootTile = topCategory === "all" && gameSubcategory === "root"; const isCurrentResumeTile = topCategory === "current" && item.id === "resume"; const previewThumbs = isGameRootTile ? (gameCategoryPreviewById[item.id] ?? []) : []; @@ -1990,7 +2005,7 @@ export function ControllerLibraryPage({ {isCurrentResumeTile ? (
{currentStreamingGame?.imageUrl ? ( - + ) : (
)} @@ -2003,7 +2018,7 @@ export function ControllerLibraryPage({
{previewThumbs.map((src, i) => (
- +
))} {Array.from({ length: Math.max(0, PREVIEW_TILE_COUNT - previewThumbs.length) }).map((_, i) => ( @@ -2021,12 +2036,12 @@ export function ControllerLibraryPage({ min={1} max={150} step={1} - value={settings.maxBitrateMbps ?? 75} + value={maxBitrateMbpsForTrack} onChange={(e) => 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" : ""} + {`${maxBitrateMbpsForTrack} Mbps`}{editingBandwidth ? " • Editing" : ""}
) : themeChannelForRow && settingsSubcategory === "ThemeColor" ? (
@@ -2060,7 +2075,23 @@ export function ControllerLibraryPage({ ); })}
- ); + ), [ + topCategory, + gameSubcategory, + menuShelfTranslateX, + displayItems, + topLevelShelfIndex, + gameCategoryPreviewById, + currentStreamingGame?.imageUrl, + editingBandwidth, + editingThemeChannel, + settingsSubcategory, + onSettingChange, + themeRgbForTrack.r, + themeRgbForTrack.g, + themeRgbForTrack.b, + maxBitrateMbpsForTrack, + ]); if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; @@ -2238,7 +2269,7 @@ export function ControllerLibraryPage({ > {favoriteGameIdSet.has(game.id) ? : null}
- {game.imageUrl ? :
} + {game.imageUrl ? :
}
); @@ -2306,7 +2337,7 @@ export function ControllerLibraryPage({ className="xmb-ps5-shelf-track xmb-ps5-shelf-track--spotlight" role="listbox" aria-label="Recently played games" - style={{ transform: `translateX(${listTranslateX}px)` }} + style={{ transform: `translateX(${spotlightShelfTranslateX}px)` }} > {spotlightSlots.map((game, idx) => { const isActive = gamesRootPlane === "spotlight" && idx === spotlightIndex; @@ -2320,7 +2351,7 @@ export function ControllerLibraryPage({ aria-label={game ? game.title : "Empty recent slot"} >
- {game?.imageUrl ? :
} + {game?.imageUrl ? :
}
); @@ -2408,7 +2439,7 @@ export function ControllerLibraryPage({ return (
- {thumb ? :
} + {thumb ? :
}
{item.gameTitle || item.fileName}
@@ -2493,7 +2524,7 @@ export function ControllerLibraryPage({
{item.imageUrl ? ( - + ) : (
)} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 01f3e5c49..72034bc82 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6602,6 +6602,8 @@ button.game-card-store-chip.owned.active:hover { max-height: none; transform: scale(0.75); transform-origin: top left; + /* Tuning: horizontal shelf pan (transform) — keep in sync with tile focus motion for a cohesive feel */ + --xmb-shelf-pan-ms: 520ms; } .xmb-wrapper::after { @@ -7716,7 +7718,7 @@ button.game-card-store-chip.owned.active:hover { min-height: 100%; padding: 12px 24px 28px; box-sizing: border-box; - transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); + transition: transform var(--xmb-shelf-pan-ms, 600ms) cubic-bezier(0.22, 1, 0.36, 1); } .xmb-ps5-shelf-track--menu { From e68975f4ea04637ba00e63ab6e13650dc06628cf Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 20:40:52 -0500 Subject: [PATCH 10/16] feat(controller): implement full-screen game hub styles and navigation logic - Added new CSS styles for the full-screen game hub, enhancing the visual layout and user interaction with game tiles and descriptions. - Introduced state management for the game hub, allowing for focused navigation and improved user experience when browsing games. - Updated the ControllerLibraryPage to integrate the game hub functionality, including handling transitions and focus states. These changes provide a more immersive and visually appealing interface for users navigating the game library. --- .../src/components/ControllerGameHub.tsx | 129 ++++++++ .../src/components/ControllerLibraryPage.tsx | 312 ++++++++++++++---- opennow-stable/src/renderer/src/styles.css | 166 ++++++++++ 3 files changed, 543 insertions(+), 64 deletions(-) create mode 100644 opennow-stable/src/renderer/src/components/ControllerGameHub.tsx 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..15ccdae5d --- /dev/null +++ b/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx @@ -0,0 +1,129 @@ +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; + heroBackdropUrl: string | null; + playtimeData: PlaytimeStore; + selectedVariantId: string; + currentStreamingGame: GameInfo | null | undefined; + librarySortLabel?: string | null; + tiles: GameHubTile[]; + focusIndex: number; +} + +export function ControllerGameHub({ + game, + heroBackdropUrl, + playtimeData, + selectedVariantId, + currentStreamingGame, + librarySortLabel, + tiles, + focusIndex, +}: 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 safeFocus = Math.max(0, Math.min(tiles.length - 1, focusIndex)); + + return ( +
+
+ {heroBackdropUrl ? ( +
+ ) : 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 ? ( + 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 7346b3de1..8656b3e9f 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -5,6 +5,7 @@ import { Star, Clock, Calendar, Repeat2, House, Settings as SettingsIcon, Librar 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 { type PlaytimeStore, formatPlaytime, formatLastPlayed } from "../utils/usePlaytime"; interface ControllerLibraryPageProps { @@ -64,6 +65,15 @@ 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; + restoreSelectedGameId: string; +}; + const LIBRARY_SORT_STORAGE_KEY = "opennow:controllerLibrarySort.v1"; const LIBRARY_SORT_LABEL: Record = { @@ -257,6 +267,9 @@ export function ControllerLibraryPage({ const [optionsEntries, setOptionsEntries] = useState>([]); const [optionsFocusIndex, setOptionsFocusIndex] = useState(0); const [heroTransitionMs, setHeroTransitionMs] = useState(420); + const [gamesHubOpen, setGamesHubOpen] = useState(false); + const [gamesHubFocusIndex, setGamesHubFocusIndex] = useState(0); + const gamesHubReturnSnapshotRef = useRef(null); const spotlightTrackRef = useRef(null); useEffect(() => { @@ -733,7 +746,7 @@ export function ControllerLibraryPage({ (topCategory === "all" && gameSubcategory === "root")); const gamesDualShelf = topCategory === "all" && gameSubcategory === "root" && games.length > 0; const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); - const canEnterDetailRow = gamesShelfBrowseActive || mediaShelfBrowseActive; + const canEnterDetailRow = mediaShelfBrowseActive; const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; const topLevelShelfIndex = topCategory === "media" @@ -755,22 +768,6 @@ export function ControllerLibraryPage({ return selectedCategoryLabel; }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, gamesRootPlane, spotlightSlots, spotlightIndex]); const detailRailItems = useMemo>(() => { - if (topCategory === "all" && gameSubcategory !== "root" && selectedGame) { - const fav = favoriteGameIdSet.has(selectedGame.id); - const rows: Array<{ id: string; title: string; subtitle: string; imageUrl?: string }> = [ - { - id: "d1", - title: "Play", - subtitle: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch to this title" : "Launch now", - imageUrl: selectedGame.imageUrl, - }, - { id: "d2", title: fav ? "Remove favorite" : "Add favorite", subtitle: "Library", imageUrl: selectedGame.imageUrl }, - ]; - if (selectedGame.variants.length > 1) { - rows.push({ id: "d3", title: "Version", subtitle: "Cycle stream variant", imageUrl: selectedGame.imageUrl }); - } - return rows; - } if (topCategory === "media" && mediaSubcategory !== "root") { const current = mediaAssetItems[selectedMediaIndex]; const imageUrl = current?.thumbnailDataUrl || current?.dataUrl || (current ? mediaThumbById[current.id] : undefined); @@ -780,7 +777,37 @@ export function ControllerLibraryPage({ ]; } return []; - }, [topCategory, gameSubcategory, selectedGame, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById, favoriteGameIdSet, currentStreamingGame]); + }, [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: + 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]); + + 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 slot = spotlightSlots[spotlightIndex]; @@ -809,11 +836,11 @@ export function ControllerLibraryPage({ }, [focusMotionKey]); useEffect(() => { - if (topCategory !== "all" || gameSubcategory !== "root") { + if (topCategory !== "all") { setGamesRootPlane("spotlight"); setSpotlightIndex(0); } - }, [topCategory, gameSubcategory]); + }, [topCategory]); useEffect(() => { setOptionsOpen(false); @@ -821,6 +848,28 @@ export function ControllerLibraryPage({ 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]; @@ -993,6 +1042,26 @@ export function ControllerLibraryPage({ return; } + 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"); + return; + } + if (direction === "right") { + setGamesHubFocusIndex((i) => Math.min(n - 1, i + 1)); + playUiSound("move"); + return; + } + return; + } + if (ps5Row === "top") { if (direction === "left") { cycleTopCategory(-1); @@ -1047,9 +1116,18 @@ export function ControllerLibraryPage({ if (gamesShelfBrowseActive) { if (shelfHasGames) { if (direction === "down") { - if (canEnterDetailRow && detailRailItems.length > 0) { + if (selectedGame) { playUiSound("move"); - setPs5Row("detail"); + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: selectedGameId, + }; + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); } return; } @@ -1302,6 +1380,7 @@ export function ControllerLibraryPage({ 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) return; cycleTopCategory(direction === "prev" ? -1 : 1); @@ -1345,6 +1424,8 @@ export function ControllerLibraryPage({ } if (opt.id === "play" && selectedGame) { onPlayGame(selectedGame); + gamesHubReturnSnapshotRef.current = null; + setGamesHubOpen(false); setOptionsOpen(false); playUiSound("confirm"); return; @@ -1375,9 +1456,19 @@ export function ControllerLibraryPage({ if (opt.id === "openLibrary") { const g = spotlightSlots[spotlightIndex]; 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"); } @@ -1386,6 +1477,41 @@ export function ControllerLibraryPage({ 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"); @@ -1400,28 +1526,7 @@ export function ControllerLibraryPage({ const selectedDetail = detailRailItems[detailRailIndex]; if (!selectedDetail) return; - if (topCategory === "all" && gameSubcategory !== "root" && selectedGame) { - if (selectedDetail.id === "d1") { - onPlayGame(selectedGame); - playUiSound("confirm"); - return; - } - if (selectedDetail.id === "d2") { - toggleFavoriteForSelected(); - return; - } - if (selectedDetail.id === "d3" && 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("confirm"); - return; - } - - if (topCategory === "media" && mediaSubcategory !== "root") { + if (topCategory === "media") { if (selectedDetail.id === "m1") { const current = mediaAssetItems[selectedMediaIndex]; if (current && typeof window.openNow?.showMediaInFolder === "function") { @@ -1573,9 +1678,19 @@ export function ControllerLibraryPage({ if (gameSubcategory === "root") { if (gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex]) { const g = spotlightSlots[spotlightIndex]; + 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; } @@ -1589,7 +1704,16 @@ export function ControllerLibraryPage({ return; } if (selectedGame) { - onPlayGame(selectedGame); + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: selectedGameId, + }; + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); playUiSound("confirm"); } } else if (selectedGame) { @@ -1600,6 +1724,7 @@ export function ControllerLibraryPage({ const secondaryActivateHandler = () => { if (optionsOpen) return; + if (gamesHubOpen) return; if (gamesShelfBrowseActive && gameSubcategory === "all") { setLibrarySortId((prev) => { const order: LibrarySortId[] = ["recent", "favoritesFirst", "az", "za"]; @@ -1754,6 +1879,22 @@ export function ControllerLibraryPage({ 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); + throttledOnSelectGame(snap.restoreSelectedGameId); + } + return; + } setGameSubcategory("root"); setSelectedGameSubcategoryIndex(lastRootGameIndex); playUiSound("move"); @@ -1812,12 +1953,12 @@ export function ControllerLibraryPage({ tertiaryActivateHandler(); return; } - if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive) { + if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen) { e.preventDefault(); cycleTopCategory(-1); return; } - if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive) { + if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen) { e.preventDefault(); cycleTopCategory(1); return; @@ -1870,6 +2011,7 @@ export function ControllerLibraryPage({ categorizedGames, selectedIndex, selectedGame, + selectedGameId, selectedVariantId, onPlayGame, onSelectGameVariant, @@ -1926,6 +2068,9 @@ export function ControllerLibraryPage({ gamesDualShelf, favoriteGameIdSet, microphoneDevices, + gamesHubOpen, + gamesHubFocusIndex, + gamesHubTiles, ]); const renderFaceButton = (kind: "primary" | "secondary" | "tertiary", className: string, size: number): JSX.Element => { @@ -2177,7 +2322,20 @@ export function ControllerLibraryPage({ })}
- {topCategory === "all" && gameSubcategory !== "root" && ( + {topCategory === "all" && gameSubcategory !== "root" && gamesHubOpen && selectedGame ? ( + + ) : null} + + {topCategory === "all" && gameSubcategory !== "root" && !gamesHubOpen && (
{!isLoading && categorizedGames.length === 0 ? (
@@ -2188,9 +2346,7 @@ export function ControllerLibraryPage({

{selectedGame.title}

- - {currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"} - + Game hub Options
@@ -2695,19 +2851,47 @@ export function ControllerLibraryPage({ )} ) : topCategory === "all" && gameSubcategory !== "root" ? ( - <> -
- Browse · Left / Right -
-
- Library filters · Up -
-
{renderFaceButton("primary", "xmb-btn-icon", 24)} {currentStreamingGame && selectedGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play"}
- {gameSubcategory === "all" ? ( -
{renderFaceButton("secondary", "xmb-btn-icon", 24)} Sort
- ) : null} -
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
- + 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" ? ( <>
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 72034bc82..b4264e10e 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -7990,6 +7990,172 @@ button.game-card-store-chip.owned.active:hover { flex-wrap: wrap; } +/* Full-screen game hub (Games library browse — replaces shelf until Back) */ +.xmb-ps5-game-hub { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + flex-direction: column; + justify-content: flex-end; + pointer-events: auto; + padding: clamp(16px, 3vh, 40px) clamp(20px, 4vw, 56px) clamp(96px, 14vh, 140px); + box-sizing: border-box; +} + +.xmb-ps5-game-hub-bg { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; +} + +.xmb-ps5-game-hub-hero { + position: absolute; + inset: -12%; + background-size: cover; + background-position: center center; + filter: blur(22px) saturate(1.08) brightness(0.85); + opacity: 0.55; + transform: scale(1.05); +} + +.xmb-ps5-game-hub-scrim { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(6, 8, 12, 0.45) 0%, rgba(4, 6, 10, 0.72) 55%, rgba(2, 4, 8, 0.92) 100%), + linear-gradient(90deg, rgba(0, 0, 0, 0.35) 0%, transparent 35%, transparent 65%, rgba(0, 0, 0, 0.35) 100%); +} + +.xmb-ps5-game-hub-content { + position: relative; + z-index: 1; + max-width: min(920px, 92vw); + margin: 0 auto; + width: 100%; +} + +.xmb-ps5-game-hub-header { + margin-bottom: clamp(12px, 2vh, 20px); +} + +.xmb-ps5-game-hub-title { + margin: 0 0 12px; + font-size: clamp(1.65rem, 4.2vw, 2.35rem); + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.15; + color: rgba(255, 255, 255, 0.96); + text-shadow: + 0 2px 24px rgba(0, 0, 0, 0.75), + 0 1px 2px rgba(0, 0, 0, 0.9); +} + +.xmb-ps5-game-hub-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.xmb-ps5-game-hub-description { + margin: 0 0 clamp(16px, 3vh, 28px); + font-size: 0.92rem; + line-height: 1.55; + color: rgba(235, 242, 250, 0.82); + max-height: clamp(72px, 14vh, 140px); + overflow-y: auto; + padding-right: 8px; + scrollbar-width: none; +} + +.xmb-ps5-game-hub-description::-webkit-scrollbar { + display: none; +} + +.xmb-ps5-game-hub-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: stretch; +} + +.xmb-ps5-game-hub-tile { + flex: 1 1 clamp(140px, 28vw, 220px); + min-height: 108px; + border-radius: 16px; + border: 2px solid rgba(255, 255, 255, 0.14); + background: rgba(10, 14, 22, 0.55); + opacity: 0.55; + transform: translateY(6px) scale(0.98); + transition: + opacity 320ms cubic-bezier(0.22, 1, 0.36, 1), + transform 320ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 320ms ease, + box-shadow 320ms ease; +} + +.xmb-ps5-game-hub-tile.active { + opacity: 1; + transform: translateY(0) scale(1); + border-color: rgba(255, 255, 255, 0.72); + box-shadow: + 0 18px 44px rgba(0, 0, 0, 0.45), + 0 0 0 2px rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.35); +} + +.xmb-ps5-game-hub-tile--primary.active { + border-color: rgb(var(--xmb-theme-r) var(--xmb-theme-g) var(--xmb-theme-b) / 0.65); +} + +.xmb-ps5-game-hub-tile--disabled { + opacity: 0.38; +} + +.xmb-ps5-game-hub-tile--disabled.active { + border-color: rgba(255, 255, 255, 0.22); + box-shadow: none; +} + +.xmb-ps5-game-hub-tile-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; + min-height: 100%; + box-sizing: border-box; +} + +.xmb-ps5-game-hub-tile-title { + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.94); +} + +.xmb-ps5-game-hub-tile-sub { + font-size: 0.74rem; + color: rgba(220, 230, 242, 0.72); + line-height: 1.35; +} + +.xmb-ps5-game-hub-stream-hint { + margin-top: 14px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.42); +} + +@media (prefers-reduced-motion: reduce) { + .xmb-ps5-game-hub-tile { + transition-duration: 0.01ms; + } +} + .xmb-ps5-detail-rail { position: absolute; left: clamp(24px, 4vw, 56px); From b58e2e47c7f57aeec4564a8d5e2fa9eddca363bf Mon Sep 17 00:00:00 2001 From: Jared Date: Sat, 2 May 2026 20:47:06 -0500 Subject: [PATCH 11/16] feat(controller): enhance game hub visuals and screenshot handling - Added new CSS styles for the game hub, improving layout and visual presentation of game posters and screenshots. - Updated the ControllerGameHub component to utilize local screenshot URLs, enhancing the display of game visuals. - Modified the ControllerLibraryPage to manage and load screenshots dynamically when the game hub is opened. These changes provide a more engaging and visually appealing experience for users interacting with the game hub. --- .../src/components/ControllerGameHub.tsx | 36 +++++++++++++- .../src/components/ControllerLibraryPage.tsx | 48 ++++++++++++++++++- opennow-stable/src/renderer/src/styles.css | 43 +++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx b/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx index 15ccdae5d..13e9a5184 100644 --- a/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerGameHub.tsx @@ -19,7 +19,8 @@ function sanitizeGenreName(raw: string): string { export interface ControllerGameHubProps { game: GameInfo; - heroBackdropUrl: string | null; + /** Local captures for this title (newest first); hub prefers these over poster art */ + screenshotUrls: string[]; playtimeData: PlaytimeStore; selectedVariantId: string; currentStreamingGame: GameInfo | null | undefined; @@ -30,7 +31,7 @@ export interface ControllerGameHubProps { export function ControllerGameHub({ game, - heroBackdropUrl, + screenshotUrls, playtimeData, selectedVariantId, currentStreamingGame, @@ -51,7 +52,12 @@ export function ControllerGameHub({ 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 (
@@ -63,6 +69,32 @@ export function ControllerGameHub({
+ {primaryVisualUrl ? ( +
+
+ +
+ {extraShots.length > 0 ? ( +
+ {extraShots.map((src, i) => ( + + ))} +
+ ) : null} +
+ ) : null} +

{game.title}

diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 8656b3e9f..6a831ea96 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -269,6 +269,8 @@ export function ControllerLibraryPage({ 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); @@ -726,6 +728,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]; @@ -2325,7 +2371,7 @@ export function ControllerLibraryPage({ {topCategory === "all" && gameSubcategory !== "root" && gamesHubOpen && selectedGame ? ( Date: Sat, 2 May 2026 22:02:24 -0500 Subject: [PATCH 12/16] feat(controller): enhance cloud session resume functionality and PS5-style loading visuals - Implemented a PS5-style resume tile in the ControllerLibraryPage, allowing users to continue cloud sessions seamlessly. - Added new CSS styles for cloud session visuals, including a resume badge and loading animations. - Updated the ControllerStreamLoading component to utilize PS5-themed loading indicators, improving the user experience during stream setup. - Enhanced logging for session recovery and connection processes to aid in debugging and user feedback. These changes provide a more engaging and visually appealing experience for users resuming cloud sessions and enhance the overall loading experience. --- opennow-stable/src/renderer/src/App.tsx | 76 ++- .../src/components/ControllerLibraryPage.tsx | 269 +++++++--- .../components/ControllerStreamLoading.tsx | 164 +----- .../src/components/Ps5LoadingScreen.tsx | 73 +++ .../renderer/src/components/SettingsPage.tsx | 12 - .../renderer/src/components/StreamView.tsx | 7 +- opennow-stable/src/renderer/src/styles.css | 472 ++++++++---------- 7 files changed, 591 insertions(+), 482 deletions(-) create mode 100644 opennow-stable/src/renderer/src/components/Ps5LoadingScreen.tsx diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 04c10eac3..0e218d810 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -2605,6 +2605,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); @@ -2626,6 +2639,13 @@ export function App(): JSX.Element { throw new Error("Active session is missing server address. Start the game again to create a new session."); } + console.log("[Resume] claimAndConnectSession: invoking claimSession", { + sessionId: existingSession.sessionId, + serverIp: existingSession.serverIp, + status: existingSession.status, + appId: existingSession.appId, + }); + const matchedContext = findGameContextForSession(existingSession); if (matchedContext) { setStreamingGame(matchedContext.game); @@ -2881,6 +2901,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); @@ -3859,6 +3886,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), @@ -3927,7 +3972,7 @@ 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 @@ -4006,14 +4051,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 && ( @@ -4080,6 +4121,13 @@ export function App(): JSX.Element { onOpenSettings={() => setCurrentPage("settings")} currentStreamingGame={streamingGame} onResumeGame={() => setControllerOverlayOpen(false)} + cloudSessionResumable={controllerCloudSessionResumable} + cloudResumeTitle={activeSessionGameTitle} + cloudResumeCoverUrl={controllerCloudResumeCoverUrl} + onResumeCloudSession={() => { + void handleResumeFromNavbar(); + }} + cloudResumeBusy={isResumingNavbarSession} onCloseGame={async () => { setControllerOverlayOpen(false); // allow overlay close animation to play @@ -4123,8 +4171,6 @@ export function App(): JSX.Element { {showControllerLaunchLoading && ( )} {showDesktopLaunchLoading && ( @@ -4262,6 +4305,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} diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx index 6a831ea96..33c100b6c 100644 --- a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -6,6 +6,7 @@ import { ButtonA, ButtonB, ButtonX, ButtonY, ButtonPSCross, ButtonPSCircle, Butt 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"; interface ControllerLibraryPageProps { @@ -55,6 +56,12 @@ interface ControllerLibraryPageProps { sessionStartedAtMs?: number | null; isStreaming?: boolean; sessionCounterEnabled?: 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"; @@ -71,9 +78,18 @@ type GamesHubReturnSnapshot = { selectedGameSubcategoryIndex: number; gamesRootPlane: "spotlight" | "categories"; spotlightIndex: number; - restoreSelectedGameId: string; + /** 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 = { @@ -191,6 +207,11 @@ export function ControllerLibraryPage({ sessionStartedAtMs = null, isStreaming = false, sessionCounterEnabled = false, + cloudSessionResumable = false, + cloudResumeTitle = null, + cloudResumeCoverUrl = null, + onResumeCloudSession, + cloudResumeBusy = false, }: ControllerLibraryPageProps): JSX.Element { const [isEntering, setIsEntering] = useState(true); const initialCategoryIndex = (() => { @@ -631,26 +652,75 @@ export function ControllerLibraryPage({ }); }, [games, playtimeData]); - const spotlightSlots = useMemo((): (GameInfo | null)[] => { - if (games.length === 0) return []; + 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 - .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, SPOTLIGHT_RECENT_COUNT); - const slots: (GameInfo | null)[] = [...played]; - while (slots.length < SPOTLIGHT_RECENT_COUNT) slots.push(null); - return slots; - }, [games, playtimeData]); + + 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; @@ -790,7 +860,10 @@ export function ControllerLibraryPage({ topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); - const gamesDualShelf = topCategory === "all" && gameSubcategory === "root" && games.length > 0; + 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; @@ -805,14 +878,15 @@ export function ControllerLibraryPage({ const selectedTopLevelItemLabel = useMemo(() => { if (!topLevelShelfActive) return selectedCategoryLabel; if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { - const slot = spotlightSlots[spotlightIndex]; - if (slot) return slot.title; + 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, spotlightSlots, spotlightIndex]); + }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex]); const detailRailItems = useMemo>(() => { if (topCategory === "media" && mediaSubcategory !== "root") { const current = mediaAssetItems[selectedMediaIndex]; @@ -856,13 +930,15 @@ export function ControllerLibraryPage({ }, [gamesHubTiles.length, selectedGame?.id]); const focusMotionKey = useMemo(() => { if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { - const slot = spotlightSlots[spotlightIndex]; - return slot ? `spotlight-${slot.id}` : `spotlight-empty-${spotlightIndex}`; + 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, spotlightSlots, spotlightIndex, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); + }, [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(); @@ -1016,7 +1092,7 @@ export function ControllerLibraryPage({ gameSubcategory, gamesDualShelf, spotlightIndex, - spotlightSlots, + spotlightEntries, ]); const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); @@ -1249,7 +1325,7 @@ export function ControllerLibraryPage({ if (isGamesRoot && gamesDualShelf && gamesRootPlane === "spotlight" && (direction === "left" || direction === "right")) { const delta = direction === "left" ? -1 : 1; - const next = Math.max(0, Math.min(spotlightSlots.length - 1, spotlightIndex + delta)); + const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); if (next !== spotlightIndex) { playUiSound("move"); setSpotlightIndex(next); @@ -1448,7 +1524,7 @@ export function ControllerLibraryPage({ } } else if (mediaShelfBrowseActive && mediaAssetItems[selectedMediaIndex]) { entries.push({ id: "openFolder", label: "Open folder" }); - } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex]) { + } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex])) { entries.push({ id: "openLibrary", label: "View in library" }); } if (entries.length === 0) return; @@ -1500,7 +1576,8 @@ export function ControllerLibraryPage({ return; } if (opt.id === "openLibrary") { - const g = spotlightSlots[spotlightIndex]; + const entry = spotlightEntries[spotlightIndex]; + const g = spotlightEntryHasGame(entry) ? entry.game : null; if (g) { gamesHubReturnSnapshotRef.current = { gameSubcategory: "root", @@ -1722,23 +1799,35 @@ export function ControllerLibraryPage({ playUiSound("confirm"); } else if (topCategory === "all") { if (gameSubcategory === "root") { - if (gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex]) { - const g = spotlightSlots[spotlightIndex]; - 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; + 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) { @@ -1937,7 +2026,9 @@ export function ControllerLibraryPage({ setSelectedGameSubcategoryIndex(snap.selectedGameSubcategoryIndex); setGamesRootPlane(snap.gamesRootPlane); setSpotlightIndex(snap.spotlightIndex); - throttledOnSelectGame(snap.restoreSelectedGameId); + if (snap.restoreSelectedGameId) { + throttledOnSelectGame(snap.restoreSelectedGameId); + } } return; } @@ -2089,6 +2180,7 @@ export function ControllerLibraryPage({ aspectRatioOptions, currentStreamingGame, onResumeGame, + onResumeCloudSession, onCloseGame, onExitControllerMode, onExitApp, @@ -2110,7 +2202,7 @@ export function ControllerLibraryPage({ optionsEntries.length, gamesRootPlane, spotlightIndex, - spotlightSlots, + spotlightEntries, gamesDualShelf, favoriteGameIdSet, microphoneDevices, @@ -2142,11 +2234,15 @@ export function ControllerLibraryPage({ ? mediaAssetItems[selectedMediaIndex] ?? null : null; const heroBackdropUrl = useMemo(() => { - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightSlots.length > 0) { - const cur = spotlightSlots[spotlightIndex]; - if (cur?.imageUrl) return cur.imageUrl; - const fallback = spotlightSlots.find((g) => g?.imageUrl); - return fallback?.imageUrl ?? null; + 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; @@ -2157,7 +2253,7 @@ export function ControllerLibraryPage({ } if (currentStreamingGame?.imageUrl) return currentStreamingGame.imageUrl; return selectedGame?.imageUrl ?? null; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightSlots, spotlightIndex, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); + }, [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), @@ -2284,7 +2380,20 @@ export function ControllerLibraryPage({ maxBitrateMbpsForTrack, ]); - if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") { + return ( +
+
+
+
+ +
+ ); + } return (
@@ -2487,9 +2596,18 @@ export function ControllerLibraryPage({

{selectedTopLevelItemLabel}

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

- {spotlightSlots[spotlightIndex] - ? "Recently played · Enter opens this title in your library" - : "Recently played · Empty slot — play games to fill your shelf"} + {(() => { + 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 ? ( @@ -2531,7 +2649,9 @@ export function ControllerLibraryPage({
- Recently played + + {cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"} +
- {spotlightSlots.map((game, idx) => { + {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 (
)} - {gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex] ? "View in library" : "Enter"} + + {gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + ? spotlightEntries[spotlightIndex].busy + ? "Please wait" + : "Resume session" + : gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ? "View in library" + : "Enter"} +
- {gamesRootPlane === "spotlight" && spotlightSlots[spotlightIndex] ? ( + {gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? (
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
) : null}
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 && (
-
- -
- -
-
-