From 458e129bd0dbae84247aebe29589d74eb25c157c Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 13:19:33 -0500 Subject: [PATCH 1/9] refactor(controller-library): polish Home tab flow and in-stream controls Align Home/current behavior around last played titles, remove redundant right-side detail chrome, and refine in-stream actions/snapshot rendering for a cleaner, more predictable controller library UX. --- opennow-stable/src/renderer/src/App.tsx | 12 +- .../controllerMode/ControllerLibraryPage.tsx | 134 +++++++++--------- .../ControllerLibraryLayout.tsx | 26 +--- .../controllerLibrary/CurrentDetailPanel.tsx | 97 ------------- .../controllerLibrary/TopLevelMenuTrack.tsx | 2 +- .../TopLevelShelfSection.tsx | 17 +-- .../events/actionHandlers.ts | 5 +- .../controllerLibrary/helpers.tsx | 4 +- .../loadGameScreenshotUrls.ts | 31 ++++ .../controllerMode/controllerLibrary/types.ts | 2 - .../useControllerLibraryEvents.ts | 3 + .../useControllerLibraryGameDerivations.ts | 12 +- .../useControllerLibraryLayoutMotion.ts | 34 +---- .../src/renderer/src/gfn/webrtcClient.ts | 36 ++++- opennow-stable/src/renderer/src/styles.css | 2 + 15 files changed, 169 insertions(+), 248 deletions(-) delete mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/CurrentDetailPanel.tsx create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 07d62f537..259201c78 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -1213,6 +1213,12 @@ export function App(): JSX.Element { setStreamVolume(audioRef.current.volume); } }, [streamStatus]); + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = streamVolume; + } + clientRef.current?.setOutputVolume(streamVolume); + }, [streamVolume]); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); @@ -2474,7 +2480,6 @@ export function App(): JSX.Element { const handleStreamVolumeChange = useCallback((v: number) => { const n = Math.max(0, Math.min(1, v)); setStreamVolume(n); - if (audioRef.current) audioRef.current.volume = n; }, []); const handleToggleStreamMicrophone = useCallback(() => { @@ -3348,6 +3353,7 @@ export function App(): JSX.Element { }, }); clientRef.current.inputPaused = controllerOverlayOpenRef.current; + clientRef.current.setOutputVolume(streamVolume); clientRef.current.setMicrophoneLevel(streamMicLevel); if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); @@ -4661,14 +4667,12 @@ export function App(): JSX.Element { await releasePointerLockIfNeeded(); await handleStopStream(); }} - pendingSwitchGameCover={pendingSwitchGameCover} userName={authSession?.user.displayName} userAvatarUrl={authSession?.user.avatarUrl} subscriptionInfo={subscriptionInfo} playtimeData={playtime} sessionStartedAtMs={sessionStartedAtMs} isStreaming={isStreaming} - sessionCounterEnabled={settings.sessionCounterEnabled} settings={{ resolution: settings.resolution, fps: settings.fps, @@ -4839,14 +4843,12 @@ export function App(): JSX.Element { }} cloudResumeBusy={isResumingNavbarSession} onCloseGame={handlePromptedStopStream} - pendingSwitchGameCover={pendingSwitchGameCover} userName={authSession?.user.displayName} userAvatarUrl={authSession?.user.avatarUrl} subscriptionInfo={subscriptionInfo} playtimeData={playtime} sessionStartedAtMs={sessionStartedAtMs} isStreaming={isStreaming} - sessionCounterEnabled={settings.sessionCounterEnabled} settings={{ resolution: settings.resolution, fps: settings.fps, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 2d2976c99..d849b4825 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -29,6 +29,7 @@ import { spotlightEntryHasGame, } from "./controllerLibrary/helpers"; import { ControllerLibraryLayout } from "./controllerLibrary/ControllerLibraryLayout"; +import { loadScreenshotUrlsForGameTitle } from "./controllerLibrary/loadGameScreenshotUrls"; import { TopLevelMenuTrack } from "./controllerLibrary/TopLevelMenuTrack"; import { useControllerLibraryGameDerivations } from "./controllerLibrary/useControllerLibraryGameDerivations"; import { useControllerLibraryEvents } from "./controllerLibrary/useControllerLibraryEvents"; @@ -72,7 +73,6 @@ export function ControllerLibraryPage({ onResumeGame, onCloseGame, onExitApp, - pendingSwitchGameCover, userName = "Player One", userAvatarUrl, subscriptionInfo, @@ -86,7 +86,6 @@ export function ControllerLibraryPage({ onExitControllerMode, sessionStartedAtMs = null, isStreaming = false, - sessionCounterEnabled = false, inStreamMenu = false, streamMenuVolume = 1, onStreamMenuVolumeChange, @@ -102,14 +101,8 @@ export function ControllerLibraryPage({ onResumeCloudSession, cloudResumeBusy = false, }: ControllerLibraryPageProps): JSX.Element { - const initialCategoryIndex = (() => { - if (currentStreamingGame) { - // TOP_CATEGORIES: current (game title), settings, all, media - return 0; - } - // TOP_CATEGORIES without `current`: settings, all, media - return 1; - })(); + /** Top strip: Home (current / last played) is always the default landing tab. */ + const initialCategoryIndex = 0; const [categoryIndex, setCategoryIndex] = useState(initialCategoryIndex); const [endSessionConfirm, setEndSessionConfirm] = useState(false); const [editingStreamVolume, setEditingStreamVolume] = useState(false); @@ -144,6 +137,8 @@ export function ControllerLibraryPage({ 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([]); + /** Random capture for Home "Live Snapshot" resume tile; falls back to poster in TopLevelMenuTrack */ + const [homeResumeSnapshotUrl, setHomeResumeSnapshotUrl] = useState(null); const gamesHubReturnSnapshotRef = useRef(null); const spotlightTrackRef = useRef(null); @@ -154,8 +149,6 @@ export function ControllerLibraryPage({ } }, [librarySortId]); - // poster measurement handled by `attachPosterRef` callback ref - useEffect(() => { const detectTypeFromGamepad = (g: Gamepad | null): "ps" | "xbox" | "nintendo" | "generic" => { if (!g || !g.id) return "generic"; @@ -207,16 +200,33 @@ export function ControllerLibraryPage({ playControllerUiSound(kind, uiSoundsEnabled); }, [uiSoundsEnabled]); - const TOP_CATEGORIES = useMemo(() => { - const categories: Array<{ id: TopCategory; label: string }> = []; - if (currentStreamingGame) { - categories.push({ id: "current", label: currentStreamingGame.title || "Current Game" }); - } - categories.push({ id: "settings", label: "Settings" }); - categories.push({ id: "all", label: "Games" }); - categories.push({ id: "media", label: "Media" }); - return categories; - }, [currentStreamingGame]); + const lastPlayedGame = useMemo((): GameInfo | null => { + 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); + if (played.length === 0) return null; + played.sort((a, b) => { + const d = lastPlayedMs(b.id) - lastPlayedMs(a.id); + if (d !== 0) return d; + return a.title.localeCompare(b.title); + }); + return played[0] ?? null; + }, [games, playtimeData]); + + const currentTabGame = currentStreamingGame ?? lastPlayedGame; + + const TOP_CATEGORIES = useMemo((): Array<{ id: TopCategory; label: string }> => { + return [ + { id: "current", label: "Home" }, + { id: "all", label: "Games" }, + { id: "media", label: "Media" }, + { id: "settings", label: "Settings" }, + ]; + }, []); const topCategory = (TOP_CATEGORIES[categoryIndex]?.id ?? "all") as TopCategory; const { @@ -251,6 +261,7 @@ export function ControllerLibraryPage({ playtimeData, topCategory, currentStreamingGame, + homeShelfGameTitle: currentTabGame?.title ?? null, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -462,48 +473,40 @@ export function ControllerLibraryPage({ setGameHubScreenshotUrls([]); return; } - if (typeof window.openNow?.listMediaByGame !== "function") { - setGameHubScreenshotUrls([]); - return; - } let cancelled = false; - const titleArg = selectedGame.title.trim(); + void loadScreenshotUrlsForGameTitle(selectedGame.title).then((urls) => { + if (!cancelled) setGameHubScreenshotUrls(urls); + }); - 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); - } + return () => { + cancelled = true; + }; + }, [gamesHubOpen, selectedGame?.id, selectedGame?.title]); - if (!cancelled) setGameHubScreenshotUrls(urls); - } catch { - if (!cancelled) setGameHubScreenshotUrls([]); + useEffect(() => { + const game = currentTabGame; + if (!game?.title?.trim()) { + setHomeResumeSnapshotUrl(null); + return; + } + + let cancelled = false; + void loadScreenshotUrlsForGameTitle(game.title).then((urls) => { + if (cancelled) return; + if (urls.length === 0) { + setHomeResumeSnapshotUrl(null); + return; } - })(); + const pick = urls[Math.floor(Math.random() * urls.length)] ?? null; + setHomeResumeSnapshotUrl(pick); + }); return () => { cancelled = true; }; - }, [gamesHubOpen, selectedGame?.id, selectedGame?.title]); - + }, [currentTabGame?.id, currentTabGame?.title]); - const showCurrentDetail = topCategory === "current" && Boolean(currentStreamingGame); - const detailVisible = showCurrentDetail; const gamesShelfBrowseActive = topCategory === "all" && gameSubcategory !== "root"; const mediaShelfBrowseActive = topCategory === "media" && mediaSubcategory !== "root"; @@ -538,7 +541,7 @@ export function ControllerLibraryPage({ if (item?.id !== "closeGame") setEndSessionConfirm(false); }, [inStreamMenu, endSessionConfirm, topCategory, displayItems, topLevelShelfIndex]); - const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory, currentStreamingGame?.title).label, [topCategory, currentStreamingGame?.title]); + const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory).label, [topCategory]); const selectedTopLevelItemLabel = useMemo(() => { if (!topLevelShelfActive) return selectedCategoryLabel; if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { @@ -551,6 +554,7 @@ export function ControllerLibraryPage({ if (topCategory === "all" && gameSubcategory === "root" && active?.label) return active.label; if (topCategory === "media" && mediaSubcategory === "root" && active?.label) return active.label; if (topCategory === "settings" && active?.label) return active.label; + if (topCategory === "current" && active?.label) return active.label; return selectedCategoryLabel; }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, mediaSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex]); const detailRailItems = useMemo>(() => { @@ -614,8 +618,6 @@ export function ControllerLibraryPage({ spotlightShelfTranslateX, gamesRootMenuTranslateX, heroTransitionMs, - metaMaxWidth, - attachPosterRef, wrapperThemeVars, wrapperClassNameWithRow, menuShelfTranslateX, @@ -745,6 +747,7 @@ export function ControllerLibraryPage({ codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -849,15 +852,15 @@ export function ControllerLibraryPage({ return null; } if (topCategory === "all") return selectedGame?.imageUrl ?? null; - if (topCategory === "current") return currentStreamingGame?.imageUrl ?? null; + if (topCategory === "current") return currentTabGame?.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; + if (currentTabGame?.imageUrl) return currentTabGame.imageUrl; return selectedGame?.imageUrl ?? null; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); + }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame, currentTabGame, selectedMediaItem, mediaThumbById]); const themeRgbForTrack = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; const maxBitrateMbpsForTrack = settings.maxBitrateMbps ?? 75; @@ -871,7 +874,7 @@ export function ControllerLibraryPage({ displayItems={displayItems} topLevelShelfIndex={topLevelShelfIndex} gameCategoryPreviewById={gameCategoryPreviewById} - currentStreamingImageUrl={currentStreamingGame?.imageUrl} + currentStreamingImageUrl={homeResumeSnapshotUrl ?? currentTabGame?.imageUrl} settingsSubcategory={settingsSubcategory} editingBandwidth={editingBandwidth} maxBitrateMbpsForTrack={maxBitrateMbpsForTrack} @@ -894,7 +897,8 @@ export function ControllerLibraryPage({ displayItems, topLevelShelfIndex, gameCategoryPreviewById, - currentStreamingGame?.imageUrl, + homeResumeSnapshotUrl, + currentTabGame?.imageUrl, editingBandwidth, editingThemeChannel, settingsSubcategory, @@ -920,6 +924,7 @@ export function ControllerLibraryPage({ wrapperClassNameWithRow={wrapperClassNameWithRow} wrapperThemeVars={wrapperThemeVars} currentStreamingGame={currentStreamingGame} + currentTabGame={currentTabGame} inStreamMenu={inStreamMenu} endSessionConfirm={endSessionConfirm} parallaxBackdropTiles={parallaxBackdropTiles} @@ -971,11 +976,6 @@ export function ControllerLibraryPage({ mediaHubSlots={mediaHubSlots} selectedMediaIndex={selectedMediaIndex} mediaThumbById={mediaThumbById} - detailVisible={detailVisible} - pendingSwitchGameCover={pendingSwitchGameCover} - attachPosterRef={attachPosterRef} - metaMaxWidth={metaMaxWidth} - sessionCounterEnabled={sessionCounterEnabled} ps5Row={ps5Row} canEnterDetailRow={canEnterDetailRow} detailRailItems={detailRailItems} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx index 5c53d65de..28f5f9172 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx @@ -6,7 +6,6 @@ import { LIBRARY_SORT_LABEL } from "./constants"; import { AllGamesBrowseSection } from "./AllGamesBrowseSection"; import { TopLevelShelfSection } from "./TopLevelShelfSection"; import { MediaHubSection } from "./MediaHubSection"; -import { CurrentDetailPanel } from "./CurrentDetailPanel"; import { DetailRail } from "./DetailRail"; import { OptionsSheet } from "./OptionsSheet"; import { FooterHints } from "./FooterHints"; @@ -19,6 +18,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element wrapperClassNameWithRow, wrapperThemeVars, currentStreamingGame, + currentTabGame, inStreamMenu, endSessionConfirm, parallaxBackdropTiles, @@ -70,11 +70,6 @@ export function ControllerLibraryLayout(props: Record): JSX.Element mediaHubSlots, selectedMediaIndex, mediaThumbById, - detailVisible, - pendingSwitchGameCover, - attachPosterRef, - metaMaxWidth, - sessionCounterEnabled, ps5Row, canEnterDetailRow, detailRailItems, @@ -98,7 +93,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element ); @@ -209,7 +204,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element spotlightIndex={spotlightIndex} displayItems={displayItems} topLevelShelfIndex={topLevelShelfIndex} - currentStreamingGame={currentStreamingGame} + currentTabGame={currentTabGame} playtimeData={playtimeData} gamesDualShelf={gamesDualShelf} cloudSessionResumable={cloudSessionResumable} @@ -236,21 +231,6 @@ export function ControllerLibraryLayout(props: Record): JSX.Element /> )} -
- -
- void; - metaMaxWidth: number | null; - selectedVariantByGameId: Record; - playtimeData: PlaytimeStore; - sessionCounterEnabled: boolean; - sessionStartedAtMs: number | null; - isStreaming: boolean; -} - -export function CurrentDetailPanel({ - topCategory, - pendingSwitchGameCover, - currentStreamingGame, - attachPosterRef, - metaMaxWidth, - selectedVariantByGameId, - playtimeData, - sessionCounterEnabled, - sessionStartedAtMs, - isStreaming, -}: CurrentDetailPanelProps): JSX.Element | null { - if (topCategory !== "current") return null; - - return ( -
-
- {currentStreamingGame?.title -
-
-
{currentStreamingGame?.title ?? "Current Game"}
-
- {(() => { - const cs = currentStreamingGame; - if (!cs) return null; - const vId = selectedVariantByGameId[cs.id] || cs.variants[0]?.id; - const variant = cs.variants.find((v) => v.id === vId) || cs.variants[0]; - const storeName = getStoreDisplayName(variant?.store || ""); - const record = (playtimeData ?? {})[cs.id]; - const totalSecs = record?.totalSeconds ?? 0; - const lastPlayed = record?.lastPlayedAt ?? null; - const sessionCount = record?.sessionCount ?? 0; - const playtimeLabel = formatPlaytime(totalSecs); - const lastPlayedLabel = formatLastPlayed(lastPlayed); - const genres = cs.genres?.slice(0, 2) ?? []; - const tier = cs.membershipTierLabel; - return ( - <> - {storeName && {storeName}} - {sessionCounterEnabled && ( - - - - )} - - - {playtimeLabel} - - - - {lastPlayedLabel} - - {sessionCount > 0 && ( - - - {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} - - )} - {genres.map((g) => ( - {sanitizeGenreName(g)} - ))} - {tier && {tier}} - - ); - })()} -
-
-
- ); -} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx index 00fb06412..c80a79e56 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx @@ -57,7 +57,7 @@ export function TopLevelMenuTrack({ className={`xmb-ps5-shelf-track xmb-ps5-shelf-track--menu ${topCategory === "all" && gameSubcategory === "root" ? "xmb-ps5-shelf-track--games-root" : ""} ${topCategory === "settings" ? "xmb-ps5-shelf-track--settings" : ""}`} role="listbox" aria-label={ - topCategory === "current" ? "Current game actions" : topCategory === "settings" ? "Controller settings" : topCategory === "all" ? "Game categories" : "Media categories" + topCategory === "current" ? "Home actions" : topCategory === "settings" ? "Controller settings" : topCategory === "all" ? "Game categories" : "Media categories" } style={{ transform: `translateX(${menuShelfTranslateX}px)` }} > diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx index 6e1f78de5..503ac3ec5 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx @@ -17,7 +17,7 @@ interface TopLevelShelfSectionProps { spotlightIndex: number; displayItems: Array<{ id?: string }>; topLevelShelfIndex: number; - currentStreamingGame?: GameInfo | null; + currentTabGame?: GameInfo | null; playtimeData: PlaytimeStore; gamesDualShelf: boolean; cloudSessionResumable?: boolean; @@ -38,7 +38,7 @@ export function TopLevelShelfSection({ spotlightIndex, displayItems, topLevelShelfIndex, - currentStreamingGame, + currentTabGame, playtimeData, gamesDualShelf, cloudSessionResumable, @@ -69,10 +69,10 @@ export function TopLevelShelfSection({ })()}

) : null} - {topCategory === "current" && displayItems[topLevelShelfIndex]?.id === "resume" && currentStreamingGame ? ( + {topCategory === "current" && displayItems[topLevelShelfIndex]?.id === "resume" && currentTabGame ? (
{(() => { - const record = playtimeData[currentStreamingGame.id]; + const record = playtimeData[currentTabGame.id]; const totalSecs = record?.totalSeconds ?? 0; const lastPlayedAt = record?.lastPlayedAt ?? null; const sessionCount = record?.sessionCount ?? 0; @@ -162,14 +162,7 @@ export function TopLevelShelfSection({
) : ( - <> - {topCategory === "current" ? ( -
- Current -
- ) : null} -
{topLevelMenuTrack}
- +
{topLevelMenuTrack}
)} ); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts index e18f76425..d8a6fffc2 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts @@ -60,6 +60,7 @@ export function createActionHandlers( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -309,8 +310,8 @@ export function createActionHandlers( if (topCategory === "current") { const item = displayItems[selectedSettingIndex]; - if (item?.id === "resume" && currentStreamingGame && onResumeGame) { - onResumeGame(currentStreamingGame); + if (item?.id === "resume" && currentTabGame && onResumeGame) { + onResumeGame(currentTabGame); playUiSound("confirm"); return; } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx index 96438eb71..f801eb904 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx @@ -73,8 +73,8 @@ export function isEditableTarget(target: EventTarget | null): boolean { return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT"; } -export function getCategoryLabel(categoryId: string, currentGameTitle?: string): { label: string } { - if (categoryId === "current") return { label: currentGameTitle || "Current" }; +export function getCategoryLabel(categoryId: string): { label: string } { + if (categoryId === "current") return { label: "Home" }; if (categoryId === "all") return { label: "Games" }; if (categoryId === "settings") return { label: "Settings" }; if (categoryId === "media") return { label: "Media" }; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts new file mode 100644 index 000000000..770f6be8e --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts @@ -0,0 +1,31 @@ +/** + * Loads screenshot image URLs for a library game title (newest first). + * Uses embedded data URLs when present, otherwise resolves via getMediaThumbnail when available. + */ +export async function loadScreenshotUrlsForGameTitle(gameTitle: string): Promise { + const trimmed = gameTitle.trim(); + if (!trimmed) return []; + if (typeof window.openNow?.listMediaByGame !== "function") return []; + + try { + const listing = await window.openNow.listMediaByGame({ gameTitle: trimmed }); + 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); + } + + return urls; + } catch { + return []; + } +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts index 8049ffea4..536eef020 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts @@ -21,7 +21,6 @@ export interface ControllerLibraryPageProps { onResumeGame?: (game: GameInfo) => void; onCloseGame?: () => void; onExitApp?: () => void; - pendingSwitchGameCover?: string | null; settings?: { resolution?: string; fps?: number; @@ -47,7 +46,6 @@ export interface ControllerLibraryPageProps { onExitControllerMode?: () => void; sessionStartedAtMs?: number | null; isStreaming?: boolean; - sessionCounterEnabled?: boolean; inStreamMenu?: boolean; streamMenuVolume?: number; onStreamMenuVolumeChange?: (volume01: number) => void; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts index b324849a6..32a3e238f 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts @@ -44,6 +44,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -147,6 +148,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -266,6 +268,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts index 807cd55b1..60e405513 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts @@ -16,6 +16,8 @@ type UseControllerLibraryGameDerivationsArgs = { playtimeData: PlaytimeStore; topCategory: TopCategory; currentStreamingGame?: GameInfo | null; + /** Shown as the first row label on the Home shelf (stream or last-played title). */ + homeShelfGameTitle?: string | null; gameSubcategory: GameSubcategory; selectedGameId: string; selectedVariantByGameId: Record; @@ -70,6 +72,7 @@ export function useControllerLibraryGameDerivations({ playtimeData, topCategory, currentStreamingGame, + homeShelfGameTitle, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -109,12 +112,15 @@ export function useControllerLibraryGameDerivations({ { id: "toggleFullscreen", label: "Fullscreen", value: streamMenuIsFullscreen ? "On" : "Off" }, ] : []; + const resumeLabel = homeShelfGameTitle?.trim() || "Last played"; return [ - { id: "resume", label: "Resume Game", value: "" }, + { id: "resume", label: resumeLabel, value: "" }, ...streamExtras, - { id: "closeGame", label: inStreamMenu && endSessionConfirm ? "End session (confirm)" : "Close Game", value: "" }, + ...(inStreamMenu + ? [{ id: "closeGame", label: endSessionConfirm ? "End session (confirm)" : "Close Game", value: "" }] + : []), ]; - }, [inStreamMenu, endSessionConfirm, streamMenuMicOn, streamMenuMicLevel, streamMenuVolume, streamMenuIsFullscreen]); + }, [inStreamMenu, endSessionConfirm, streamMenuMicOn, streamMenuMicLevel, streamMenuVolume, streamMenuIsFullscreen, homeShelfGameTitle]); const mediaRootItems = useMemo( () => [ diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts index cfc944868..fc9475f91 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import type { CSSProperties, RefObject } from "react"; import { computeShelfTranslateXClamped, sanitizeControllerThemeStyle } from "./helpers"; import type { GameSubcategory, MediaSubcategory, SettingsSubcategory, TopCategory } from "./types"; @@ -35,8 +35,6 @@ type UseControllerLibraryLayoutMotionResult = { spotlightShelfTranslateX: number; gamesRootMenuTranslateX: number; heroTransitionMs: number; - metaMaxWidth: number | null; - attachPosterRef: (el: HTMLImageElement | null) => void; wrapperThemeVars: CSSProperties; wrapperClassNameWithRow: string; menuShelfTranslateX: number; @@ -69,34 +67,6 @@ export function useControllerLibraryLayoutMotion({ const [gamesRootMenuTranslateX, setGamesRootMenuTranslateX] = useState(0); const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1200 : window.innerWidth)); const [heroTransitionMs, setHeroTransitionMs] = useState(420); - const [metaMaxWidth, setMetaMaxWidth] = useState(null); - const currentPosterImgRef = useRef(null); - const posterObserverRef = useRef(null); - - const attachPosterRef = (el: HTMLImageElement | null) => { - if (posterObserverRef.current) { - try { - posterObserverRef.current.disconnect(); - } catch { - } - posterObserverRef.current = null; - } - currentPosterImgRef.current = el; - const update = () => setMetaMaxWidth(currentPosterImgRef.current?.clientWidth ?? null); - if (el) { - if (typeof ResizeObserver !== "undefined") { - const ro = new ResizeObserver(update); - posterObserverRef.current = ro; - try { - ro.observe(el); - } catch { - } - } - update(); - } else { - setMetaMaxWidth(null); - } - }; useEffect(() => { if (typeof window === "undefined") { @@ -205,8 +175,6 @@ export function useControllerLibraryLayoutMotion({ spotlightShelfTranslateX, gamesRootMenuTranslateX, heroTransitionMs, - metaMaxWidth, - attachPosterRef, wrapperThemeVars, wrapperClassNameWithRow, menuShelfTranslateX, diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 076dba36e..9bd8f38ef 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -542,6 +542,8 @@ export class GfnWebRtcClient { private controlChannel: RTCDataChannel | null = null; private audioContext: AudioContext | null = null; private audioSourceNode: MediaStreamAudioSourceNode | null = null; + private audioGainNode: GainNode | null = null; + private outputVolume = 1; private inputReady = false; /** When true, the host (e.g. in-stream controller menu) blocks forwarding; not cleared by focus/visibility. */ @@ -730,6 +732,7 @@ export class GfnWebRtcClient { options.videoElement.srcObject = this.videoStream; options.audioElement.srcObject = this.audioStream; options.audioElement.muted = true; + options.audioElement.volume = this.outputVolume; this.mouseSensitivity = options.mouseSensitivity ?? 1; this.mouseAccelerationPercent = Math.max(1, Math.min(150, Math.round(options.mouseAcceleration ?? 1))); this.autoFullScreenEnabled = options.autoFullScreen !== false; @@ -1663,6 +1666,15 @@ export class GfnWebRtcClient { this.audioSourceNode = null; } + if (this.audioGainNode) { + try { + this.audioGainNode.disconnect(); + } catch { + // Ignore cleanup errors from an already-disconnected node. + } + this.audioGainNode = null; + } + if (this.audioContext) { void this.audioContext.close().catch(() => {}); this.audioContext = null; @@ -1675,6 +1687,7 @@ export class GfnWebRtcClient { private startDirectAudioPlayback(reason: string): void { this.log(reason); this.options.audioElement.muted = false; + this.options.audioElement.volume = this.outputVolume; this.options.audioElement .play() .then(() => { @@ -1792,6 +1805,7 @@ export class GfnWebRtcClient { // matching what the official GFN browser client does for low-latency playback. let audioContext: AudioContext | null = null; let audioSourceNode: MediaStreamAudioSourceNode | null = null; + let audioGainNode: GainNode | null = null; try { audioContext = new AudioContext({ @@ -1799,7 +1813,10 @@ export class GfnWebRtcClient { sampleRate: 48000, }); audioSourceNode = audioContext.createMediaStreamSource(this.audioStream); - audioSourceNode.connect(audioContext.destination); + audioGainNode = audioContext.createGain(); + audioGainNode.gain.value = this.outputVolume; + audioSourceNode.connect(audioGainNode); + audioGainNode.connect(audioContext.destination); // Resume the context (browsers require user gesture, but Electron is more lenient) if (audioContext.state === "suspended") { @@ -1808,6 +1825,7 @@ export class GfnWebRtcClient { this.audioContext = audioContext; this.audioSourceNode = audioSourceNode; + this.audioGainNode = audioGainNode; this.log( `Audio routed through AudioContext (latency: ${(audioContext.baseLatency * 1000).toFixed(1)}ms, sampleRate: ${audioContext.sampleRate}Hz)`, ); @@ -1819,6 +1837,13 @@ export class GfnWebRtcClient { // Ignore cleanup errors from a partially-created node. } } + if (audioGainNode) { + try { + audioGainNode.disconnect(); + } catch { + // Ignore cleanup errors from a partially-created node. + } + } if (audioContext) { void audioContext.close().catch(() => {}); } @@ -4295,6 +4320,15 @@ export class GfnWebRtcClient { this.micManager.setMicLevel(level01); } + setOutputVolume(volume: number): void { + const next = Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 1)); + this.outputVolume = next; + this.options.audioElement.volume = next; + if (this.audioGainNode) { + this.audioGainNode.gain.value = next; + } + } + getMicrophoneLevel(): number { return this.micManager?.getMicLevel() ?? 1; } diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 6427ccc0c..6f1f00a69 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -8919,6 +8919,8 @@ button.game-card-store-chip.owned.active:hover { padding-left: 0; transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); pointer-events: none; + /* Above .xmb-ps5-stack (5) / focus meta (6) so Home · Settings · Games · Media stay visible */ + z-index: 20; } .xmb-category-item { From 052d0ee171948aa2b910f1ee61e24cb4ab3fe72b Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 13:41:14 -0500 Subject: [PATCH 2/9] fix(styles): adjust layout and overflow for PS5 home and media tiles - Updated CSS styles to ensure proper height and overflow management for non-games-root shelves, preventing clipping of Live Snapshot tiles. - Modified aspect ratio and layout properties for the resume preview to enhance visual presentation and responsiveness. - Refined aria-labels and text for improved accessibility and clarity in the controller library interface. --- .../controllerMode/ControllerLibraryPage.tsx | 2 +- .../controllerLibrary/TopLevelMenuTrack.tsx | 5 +- opennow-stable/src/renderer/src/styles.css | 49 +++++++++++++++++-- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index d849b4825..25f74e780 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -137,7 +137,7 @@ export function ControllerLibraryPage({ 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([]); - /** Random capture for Home "Live Snapshot" resume tile; falls back to poster in TopLevelMenuTrack */ + /** Random capture for Home Snapshot resume tile; falls back to poster in TopLevelMenuTrack */ const [homeResumeSnapshotUrl, setHomeResumeSnapshotUrl] = useState(null); const gamesHubReturnSnapshotRef = useRef(null); const spotlightTrackRef = useRef(null); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx index c80a79e56..9382161f8 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx @@ -74,6 +74,7 @@ export function TopLevelMenuTrack({ className={`xmb-ps5-menu-tile ${isActive ? "active" : ""} ${isCurrentResumeTile ? "xmb-ps5-menu-tile--resume" : ""} ${isSettingsTile ? "xmb-ps5-menu-tile--settings" : ""} ${isSettingsTile && settingsSubcategory === "root" ? "xmb-ps5-menu-tile--settings-root" : ""}`} role="option" aria-selected={isActive} + {...(isCurrentResumeTile ? { "aria-label": item.label } : {})} > {isCurrentResumeTile ? (
@@ -83,7 +84,7 @@ export function TopLevelMenuTrack({
)}
- Live Snapshot + Snapshot
) : null} @@ -99,7 +100,7 @@ export function TopLevelMenuTrack({ ))}
) : null} -
{item.label}
+ {isCurrentResumeTile ? null :
{item.label}
} {item.value ? (
{item.id === "bandwidth" && settingsSubcategory !== "root" ? ( diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 6f1f00a69..f56dbe7df 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -7482,6 +7482,19 @@ button.game-card-store-chip.owned.active:hover { height: clamp(340px, 44vh, 520px); } +/* + * Home / Settings / Media root: menu strip is not games-root, so it used the default shelf + * viewport (hard max-height ~330px + overflow:hidden). That clipped the Live Snapshot tile + * below the preview image — not an object-fit issue. Shrink-wrap height so the full card fits. + */ +.xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(240px, 30vh, 330px); + max-height: min(520px, calc(100dvh - 160px)); + overflow-x: hidden; + overflow-y: auto; +} + /* 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)); @@ -7677,6 +7690,14 @@ button.game-card-store-chip.owned.active:hover { height: clamp(196px, 25.5vh, 300px); } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(196px, 25.5vh, 300px); + max-height: min(480px, calc(100dvh - 140px)); + overflow-x: hidden; + overflow-y: auto; + } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { bottom: 114px; height: clamp(268px, 36vh, 420px); @@ -7867,6 +7888,14 @@ button.game-card-store-chip.owned.active:hover { height: clamp(172px, 22vh, 260px); } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(172px, 22vh, 260px); + max-height: min(420px, calc(100dvh - 110px)); + overflow-x: hidden; + overflow-y: auto; + } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { bottom: 100px; height: clamp(220px, 30vh, 340px); @@ -8359,18 +8388,28 @@ button.game-card-store-chip.owned.active:hover { .xmb-ps5-menu-resume-preview { position: relative; width: 100%; - aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + /* Taller than 16:9 so “Live Snapshot” reads more like a hero still */ + aspect-ratio: 16 / 10; + min-height: clamp(150px, 20vh, 240px); border-radius: 12px; overflow: hidden; + background: rgba(6, 10, 14, 0.92); border: 1px solid rgba(255, 255, 255, 0.24); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.44); - margin-bottom: 10px; + margin-bottom: 20px; } .xmb-ps5-menu-resume-image { - width: 100%; - height: 100%; - object-fit: cover; + flex: 0 1 auto; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center center; display: block; } From df34f279140a951cc23c6fcb73ae06f2fe3d6660 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 14:11:41 -0500 Subject: [PATCH 3/9] feat(media): enhance local video playback and media management features - Introduced a custom media protocol for in-app video playback, allowing seamless integration of video files within the application. - Added IPC handlers for retrieving playback URLs, deleting media files, and regenerating thumbnails, improving media management capabilities. - Updated the ControllerLibraryPage to support local video playback, including a new overlay for video controls and enhanced user interactions. - Enhanced styles for the video playback interface, ensuring a visually appealing and user-friendly experience. These changes significantly improve the media handling experience, providing users with better access to video content and management options. --- opennow-stable/src/main/index.ts | 118 ++++++++++++-- opennow-stable/src/main/mediaPaths.ts | 151 ++++++++++++++++++ opennow-stable/src/preload/index.ts | 6 + .../controllerMode/ControllerLibraryPage.tsx | 42 ++++- .../ControllerLibraryLayout.tsx | 1 + .../controllerLibrary/FooterHints.tsx | 19 ++- .../LocalVideoPlayerOverlay.tsx | 19 +++ .../controllerLibrary/MediaHubSection.tsx | 3 - .../controllerLibrary/actions/contracts.ts | 7 + .../controllerLibrary/actions/mediaActions.ts | 6 + .../actions/optionsActions.ts | 58 ++++++- .../events/actionHandlers.ts | 26 ++- .../events/navigationHandlers.ts | 7 + .../useControllerLibraryEvents.ts | 15 ++ .../useControllerLibraryMedia.ts | 5 +- .../useLocalVideoPlayback.ts | 48 ++++++ opennow-stable/src/renderer/src/styles.css | 52 ++++++ opennow-stable/src/shared/gfn.ts | 9 ++ opennow-stable/src/shared/ipc.ts | 3 + opennow-stable/src/shared/mediaPlayback.ts | 7 + 20 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 opennow-stable/src/main/mediaPaths.ts create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts create mode 100644 opennow-stable/src/shared/mediaPlayback.ts diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 15553ee5a..81292bee0 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session, protocol } from "electron"; import { fileURLToPath } from "node:url"; -import { dirname, join, resolve, relative } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { existsSync, readFileSync, createWriteStream } from "node:fs"; import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile, realpath } from "node:fs/promises"; import * as net from "node:net"; @@ -14,6 +14,11 @@ import { spawn } from "node:child_process"; // F8 - Toggle mouse/pointer lock (handled in main process via IPC) import { IPC_CHANNELS } from "@shared/ipc"; +import { + getTrustedVideoPlaybackFileUrl, + registerOpenNowMediaProtocol, + resolveTrustedOpenNowMediaPath, +} from "./mediaPaths"; import { initLogCapture, exportLogs } from "@shared/logger"; import { cacheManager } from "./services/cacheManager"; import { refreshScheduler } from "./services/refreshScheduler"; @@ -223,6 +228,20 @@ app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); // Remove getUserMedia FPS cap (not strictly needed for receive-only but avoids potential limits) app.commandLine.appendSwitch("max-gum-fps", "999"); +// file:// in <video> is blocked by Chromium for renderer pages; use a privileged custom scheme. +protocol.registerSchemesAsPrivileged([ + { + scheme: "opennow-media", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + corsEnabled: true, + }, + }, +]); + let mainWindow: BrowserWindow | null = null; let rendererControlledFullscreen = false; let signalingClient: GfnSignalingClient | null = null; @@ -648,7 +667,7 @@ async function listRecordings(): Promise { const filePath = join(dir, fileName); try { const fileStats = await stat(filePath); - const stem = fileName.replace(/\.webm$/i, ""); + const stem = fileName.replace(/\.(mp4|webm)$/i, ""); const thumbName = `${stem}-thumb.jpg`; const thumbPath = join(dir, thumbName); @@ -1803,14 +1822,9 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.MEDIA_THUMBNAIL, async (_event, payload: { filePath: string }): Promise => { const rawFp = payload?.filePath; if (typeof rawFp !== "string") return null; - if (rawFp.length > 4096) return null; try { - const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW")); - const fpResolved = resolve(rawFp); - const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot); - const fpReal = await realpath(fpResolved).catch(() => fpResolved); - const rel = relative(allowedRootReal, fpReal); - if (rel.startsWith("..")) return null; + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return null; const lower = fpReal.toLowerCase(); if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".webp")) { @@ -1855,18 +1869,89 @@ function registerIpcHandlers(): void { const rawFp = payload?.filePath; if (typeof rawFp !== "string") return; try { - const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW")); - const fpResolved = resolve(rawFp); - const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot); - const fpReal = await realpath(fpResolved).catch(() => fpResolved); - const rel = relative(allowedRootReal, fpReal); - if (rel.startsWith("..")) return; + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return; shell.showItemInFolder(fpReal); } catch { return; } }); + ipcMain.handle(IPC_CHANNELS.MEDIA_PLAYBACK_URL, async (_event, payload: { filePath: string }): Promise => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return null; + try { + return await getTrustedVideoPlaybackFileUrl(rawFp); + } catch (err) { + console.warn("MEDIA_PLAYBACK_URL error:", err); + return null; + } + }); + + ipcMain.handle(IPC_CHANNELS.MEDIA_DELETE_FILE, async (_event, payload: { filePath: string }): Promise<{ ok: boolean }> => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return { ok: false }; + try { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return { ok: false }; + let st: Awaited>; + try { + st = await stat(fpReal); + } catch { + return { ok: false }; + } + const key = md5(`${fpReal}|${st.mtimeMs}`); + const cacheDir = await ensureThumbnailCacheDirectory(); + await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined); + const stem = fpReal.replace(/\.(mp4|webm|mkv|mov|png|jpg|jpeg|webp)$/i, ""); + await unlink(`${stem}-thumb.jpg`).catch(() => undefined); + await unlink(fpReal); + return { ok: true }; + } catch (err) { + console.warn("MEDIA_DELETE_FILE error:", err); + return { ok: false }; + } + }); + + ipcMain.handle( + IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, + async (_event, payload: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }> => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return { ok: false, thumbnailDataUrl: null }; + try { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return { ok: false, thumbnailDataUrl: null }; + const st = await stat(fpReal); + const key = md5(`${fpReal}|${st.mtimeMs}`); + const cacheDir = await ensureThumbnailCacheDirectory(); + await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined); + if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) { + const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, ""); + await unlink(`${videoStem}-thumb.jpg`).catch(() => undefined); + } + const genPath = await ensureThumbnailForMedia(fpReal); + if (!genPath) return { ok: false, thumbnailDataUrl: null }; + + if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) { + const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, ""); + const sidecar = `${videoStem}-thumb.jpg`; + await copyFile(genPath, sidecar).catch((err) => { + console.warn("MEDIA_REGEN_THUMBNAIL sidecar copy:", err); + }); + } + + const b = await readFile(genPath); + return { + ok: true, + thumbnailDataUrl: `data:image/jpeg;base64,${b.toString("base64")}`, + }; + } catch (err) { + console.warn("MEDIA_REGEN_THUMBNAIL error:", err); + return { ok: false, thumbnailDataUrl: null }; + } + }, + ); + ipcMain.handle(IPC_CHANNELS.CACHE_REFRESH_MANUAL, async (): Promise => { await refreshScheduler.manualRefresh(); }); @@ -2173,6 +2258,7 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); + registerOpenNowMediaProtocol(); registerIpcHandlers(); refreshScheduler.initialize( diff --git a/opennow-stable/src/main/mediaPaths.ts b/opennow-stable/src/main/mediaPaths.ts new file mode 100644 index 000000000..52ea7164a --- /dev/null +++ b/opennow-stable/src/main/mediaPaths.ts @@ -0,0 +1,151 @@ +import { app, protocol } from "electron"; +import { createReadStream } from "node:fs"; +import { join, resolve, relative } from "node:path"; +import { Readable } from "node:stream"; +import { realpath, stat } from "node:fs/promises"; +import { isPlayableVideoFilePath } from "@shared/mediaPlayback"; + +const MAX_MEDIA_PATH_LENGTH = 4096; + +const OPENNOW_MEDIA_HOST = "opennow"; + +let openNowMediaProtocolHandleInstalled = false; + +function videoMimeTypeForPath(filePath: string): string { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".webm")) return "video/webm"; + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".mkv")) return "video/x-matroska"; + return "application/octet-stream"; +} + +/** + * Parse a single Range: bytes=… header. Returns inclusive start/end, or null if unsatisfiable. + */ +function parseByteRangeHeader(rangeHeader: string, fileSize: number): { start: number; end: number } | null { + const m = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim()); + if (!m) return null; + const g1 = m[1]; + const g2 = m[2]; + if (g1 !== "" && g2 !== "") { + const start = Number(g1); + const end = Number(g2); + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= fileSize) return null; + return { start, end: Math.min(end, fileSize - 1) }; + } + if (g1 !== "" && g2 === "") { + const start = Number(g1); + if (!Number.isFinite(start) || start >= fileSize) return null; + return { start, end: fileSize - 1 }; + } + if (g1 === "" && g2 !== "") { + const len = Number(g2); + if (!Number.isFinite(len) || len <= 0) return null; + if (len >= fileSize) return { start: 0, end: fileSize - 1 }; + return { start: fileSize - len, end: fileSize - 1 }; + } + return { start: 0, end: fileSize - 1 }; +} + +/** + * Resolve a user-supplied path to a real path under Pictures/OpenNOW, or null if unsafe / missing. + */ +export async function resolveTrustedOpenNowMediaPath(rawFp: string): Promise { + if (typeof rawFp !== "string" || rawFp.length > MAX_MEDIA_PATH_LENGTH) return null; + try { + const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW")); + const fpResolved = resolve(rawFp); + const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot); + const fpReal = await realpath(fpResolved).catch(() => fpResolved); + const rel = relative(allowedRootReal, fpReal); + if (rel.startsWith("..")) return null; + return fpReal; + } catch { + return null; + } +} + +/** + * URL for in-renderer <video src>. Uses custom scheme (registered in main) because file:// is often blocked. + */ +export async function getTrustedVideoPlaybackFileUrl(rawFp: string): Promise { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal || !isPlayableVideoFilePath(fpReal)) return null; + return `opennow-media://${OPENNOW_MEDIA_HOST}/playback?p=${encodeURIComponent(fpReal)}`; +} + +/** + * Must run during app startup (after ready), before windows load media URLs. + */ +export function registerOpenNowMediaProtocol(): void { + if (openNowMediaProtocolHandleInstalled) return; + openNowMediaProtocolHandleInstalled = true; + + protocol.handle("opennow-media", async (request) => { + try { + const url = new URL(request.url); + if (url.hostname.toLowerCase() !== OPENNOW_MEDIA_HOST) { + return new Response(null, { status: 404 }); + } + const pathNorm = url.pathname.replace(/\/$/, "") || "/"; + if (!pathNorm.endsWith("/playback")) { + return new Response(null, { status: 404 }); + } + const p = url.searchParams.get("p"); + if (!p) return new Response(null, { status: 400 }); + const fpReal = await resolveTrustedOpenNowMediaPath(p); + if (!fpReal || !isPlayableVideoFilePath(fpReal)) return new Response(null, { status: 404 }); + + const mime = videoMimeTypeForPath(fpReal); + const { size } = await stat(fpReal); + const baseHeaders: Record = { + "Content-Type": mime, + "Accept-Ranges": "bytes", + }; + + if (request.method === "HEAD") { + return new Response(null, { + status: 200, + headers: { + ...baseHeaders, + "Content-Length": String(size), + }, + }); + } + + const rangeRaw = request.headers.get("range"); + if (rangeRaw) { + const firstRange = rangeRaw.split(",")[0]?.trim() ?? ""; + const parsed = firstRange ? parseByteRangeHeader(firstRange, size) : null; + if (parsed) { + const { start, end } = parsed; + const chunkLength = end - start + 1; + const nodeStream = createReadStream(fpReal, { start, end }); + const body = Readable.toWeb(nodeStream) as ReadableStream; + return new Response(body, { + status: 206, + headers: { + ...baseHeaders, + "Content-Length": String(chunkLength), + "Content-Range": `bytes ${start}-${end}/${size}`, + }, + }); + } + } + + const nodeStream = createReadStream(fpReal); + const body = Readable.toWeb(nodeStream) as ReadableStream; + return new Response(body, { + status: 200, + headers: { + ...baseHeaders, + "Content-Length": String(size), + }, + }); + } catch (err) { + console.warn("[opennow-media] protocol handler:", err); + return new Response(null, { status: 500 }); + } + }); +} diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index d3a18d19f..357a6b7dc 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -173,6 +173,12 @@ const api: OpenNowApi = { getMediaThumbnail: (input: { filePath: string }) => ipcRenderer.invoke(IPC_CHANNELS.MEDIA_THUMBNAIL, input), showMediaInFolder: (input: { filePath: string }): Promise => ipcRenderer.invoke(IPC_CHANNELS.MEDIA_SHOW_IN_FOLDER, input), + getMediaPlaybackUrl: (input: { filePath: string }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_PLAYBACK_URL, input), + deleteMediaFile: (input: { filePath: string }): Promise<{ ok: boolean }> => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_DELETE_FILE, input), + regenMediaThumbnail: (input: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }> => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, input), deleteCache: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CACHE_DELETE_ALL), fetchPrintedWasteQueue: (): Promise => diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 25f74e780..6072e43b6 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -34,7 +34,9 @@ import { TopLevelMenuTrack } from "./controllerLibrary/TopLevelMenuTrack"; import { useControllerLibraryGameDerivations } from "./controllerLibrary/useControllerLibraryGameDerivations"; import { useControllerLibraryEvents } from "./controllerLibrary/useControllerLibraryEvents"; import { useControllerLibraryLayoutMotion } from "./controllerLibrary/useControllerLibraryLayoutMotion"; +import { LocalVideoPlayerOverlay } from "./controllerLibrary/LocalVideoPlayerOverlay"; import { useControllerLibraryMedia } from "./controllerLibrary/useControllerLibraryMedia"; +import { useLocalVideoPlayback } from "./controllerLibrary/useLocalVideoPlayback"; import { useControllerWindowBindings } from "./controllerLibrary/useControllerWindowBindings"; import { routeCancel, @@ -135,6 +137,7 @@ export function ControllerLibraryPage({ const [optionsFocusIndex, setOptionsFocusIndex] = useState(0); const [gamesHubOpen, setGamesHubOpen] = useState(false); const [gamesHubFocusIndex, setGamesHubFocusIndex] = useState(0); + const [mediaListRefreshNonce, setMediaListRefreshNonce] = useState(0); /** Local captures for the focused game; loaded when hub opens so Media tab need not be visited first */ const [gameHubScreenshotUrls, setGameHubScreenshotUrls] = useState([]); /** Random capture for Home Snapshot resume tile; falls back to poster in TopLevelMenuTrack */ @@ -200,6 +203,17 @@ export function ControllerLibraryPage({ playControllerUiSound(kind, uiSoundsEnabled); }, [uiSoundsEnabled]); + const { + playback: localVideoPlayback, + localVideoPlayerOpen, + openFromEntry: openLocalVideoPlayer, + close: closeLocalVideoPlayer, + } = useLocalVideoPlayback(playUiSound); + + const bumpMediaListRefresh = useCallback((): void => { + setMediaListRefreshNonce((n) => n + 1); + }, []); + const lastPlayedGame = useMemo((): GameInfo | null => { const lastPlayedMs = (gameId: string) => { const raw = playtimeData[gameId]?.lastPlayedAt; @@ -241,7 +255,19 @@ export function ControllerLibraryPage({ topCategory, mediaSubcategory, selectedMediaIndex, + mediaListRefreshNonce, }); + + useEffect(() => { + if (topCategory !== "media" || mediaSubcategory === "root") return; + const len = mediaAssetItems.length; + if (len === 0) { + if (selectedMediaIndex !== 0) setSelectedMediaIndex(0); + return; + } + if (selectedMediaIndex >= len) setSelectedMediaIndex(len - 1); + }, [topCategory, mediaSubcategory, mediaAssetItems.length, selectedMediaIndex]); + const { favoriteGameIdSet, allGenres, @@ -522,7 +548,8 @@ export function ControllerLibraryPage({ gameSubcategory === "root" && (games.length > 0 || Boolean(cloudSessionResumable && onResumeCloudSession)); const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); - const canEnterDetailRow = mediaShelfBrowseActive; + /** Media browse: no secondary “detail” row (down used to open Open folder / Media hub cards). */ + const canEnterDetailRow = false; const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; const topLevelShelfIndex = topCategory === "media" @@ -797,6 +824,8 @@ export function ControllerLibraryPage({ setEditingThemeChannel, setEditingStreamVolume, setEditingStreamMicLevel, + setOptionsEntries, + setOptionsOpen, setOptionsFocusIndex, setGamesHubFocusIndex, setPs5Row, @@ -812,6 +841,11 @@ export function ControllerLibraryPage({ setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions: localVideoPlayback?.filePath ?? null, + bumpMediaListRefresh, }); @@ -918,8 +952,10 @@ export function ControllerLibraryPage({ ]); return ( + <> + {localVideoPlayback ? ( + + ) : null} + ); } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx index 28f5f9172..cc0d3140c 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx @@ -245,6 +245,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element /> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Close +
+
+ ); + } + return (
{topLevelRowBehaviorActive ? ( @@ -151,7 +168,7 @@ export function FooterHints({ ) : ( )} - Open Folder + {mediaSubcategory === "Videos" ? "Play" : "Open Folder"}
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx new file mode 100644 index 000000000..1ed8d25b6 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx @@ -0,0 +1,19 @@ +import type { JSX } from "react"; + +export interface LocalVideoPlayerOverlayProps { + src: string; + onClose: () => void; +} + +export function LocalVideoPlayerOverlay({ src, onClose }: LocalVideoPlayerOverlayProps): JSX.Element { + return ( +
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx index f6329cd03..179c66370 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx @@ -47,9 +47,6 @@ export function MediaHubSection({ Options
-
- Captures -
void; playUiSound: (kind: SoundKind) => void; spotlightEntryHasGame: (entry: SpotlightEntry | undefined) => entry is { kind: "recent"; game: GameInfo }; + /** When the in-app video player is open, options apply to this file path. */ + localVideoFilePathForOptions: string | null; + bumpMediaListRefresh: () => void; + closeLocalVideoPlayer: () => void; + setSelectedMediaIndex: (updater: (prev: number) => number) => void; } export interface SettingsActivateContext { @@ -93,6 +98,8 @@ export interface MediaActivateContext { setSelectedMediaIndex: (index: number) => void; mediaAssetItems: MediaListingEntry[]; playUiSound: (kind: SoundKind) => void; + /** In-app playback for Media > Videos (orchestrated outside this module). */ + openLocalVideoPlayer: (entry: MediaListingEntry) => void; } export interface MediaCancelContext { diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts index 270a06973..9acc210fd 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts @@ -1,3 +1,4 @@ +import { isPlayableVideoFilePath } from "@shared/mediaPlayback"; import type { MediaActivateContext, MediaCancelContext } from "./contracts"; export function handleMediaActivateAction(context: MediaActivateContext): boolean { @@ -10,6 +11,7 @@ export function handleMediaActivateAction(context: MediaActivateContext): boolea setSelectedMediaIndex, mediaAssetItems, playUiSound, + openLocalVideoPlayer, } = context; const item = displayItems[selectedMediaIndex]; @@ -23,6 +25,10 @@ export function handleMediaActivateAction(context: MediaActivateContext): boolea if (mediaSubcategory !== "root") { const selectedMedia = mediaAssetItems[selectedMediaIndex]; + if (mediaSubcategory === "Videos" && selectedMedia && isPlayableVideoFilePath(selectedMedia.filePath)) { + openLocalVideoPlayer(selectedMedia); + return true; + } if (selectedMedia && typeof window.openNow?.showMediaInFolder === "function") { void window.openNow.showMediaInFolder({ filePath: selectedMedia.filePath }); playUiSound("confirm"); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts index 67a2e1fff..31bdac0cc 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts @@ -20,6 +20,7 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { setOptionsFocusIndex, setOptionsOpen, playUiSound, + localVideoFilePathForOptions, } = context; const entries: Array<{ id: string; label: string }> = []; @@ -35,8 +36,18 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { 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 ( + (mediaShelfBrowseActive && mediaAssetItems[selectedMediaIndex]) || + (typeof localVideoFilePathForOptions === "string" && localVideoFilePathForOptions.length > 0) + ) { + const mediaPath = localVideoFilePathForOptions ?? mediaAssetItems[selectedMediaIndex]?.filePath; + if (!mediaPath) { + /* noop */ + } else { + entries.push({ id: "openFolder", label: "Open folder" }); + entries.push({ id: "mediaDelete", label: "Delete File" }); + entries.push({ id: "mediaRegenThumb", label: "Regen Thumbnail" }); + } } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex])) { entries.push({ id: "openLibrary", label: "View in library" }); } @@ -97,8 +108,15 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo setGamesHubFocusIndex, setPs5Row, playUiSound, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, } = context; + const mediaOptionsFilePath = (): string | null => + localVideoFilePathForOptions ?? mediaAssetItems[selectedMediaIndex]?.filePath ?? null; + if (optionsEntries.length === 0) return false; const opt = optionsEntries[optionsFocusIndex]; if (!opt) return true; @@ -130,14 +148,44 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo return true; } if (opt.id === "openFolder") { - const cur = mediaAssetItems[selectedMediaIndex]; - if (cur && typeof window.openNow?.showMediaInFolder === "function") { - void window.openNow.showMediaInFolder({ filePath: cur.filePath }); + const fp = mediaOptionsFilePath(); + if (fp && typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: fp }); } setOptionsOpen(false); playUiSound("confirm"); return true; } + if (opt.id === "mediaDelete") { + const fp = mediaOptionsFilePath(); + if (!fp || typeof window.openNow?.deleteMediaFile !== "function") return true; + void window.openNow.deleteMediaFile({ filePath: fp }).then((r) => { + if (r.ok) { + setOptionsOpen(false); + closeLocalVideoPlayer(); + bumpMediaListRefresh(); + setSelectedMediaIndex((i) => Math.max(0, i - 1)); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + }); + return true; + } + if (opt.id === "mediaRegenThumb") { + const fp = mediaOptionsFilePath(); + if (!fp || typeof window.openNow?.regenMediaThumbnail !== "function") return true; + void window.openNow.regenMediaThumbnail({ filePath: fp }).then((r) => { + if (r.ok) { + setOptionsOpen(false); + bumpMediaListRefresh(); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + }); + return true; + } if (opt.id === "openLibrary") { const entry = spotlightEntries[spotlightIndex]; const game = spotlightEntryHasGame(entry) ? entry.game : null; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts index d8a6fffc2..4a9d56171 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts @@ -121,6 +121,11 @@ export function createActionHandlers( setCategoryIndex, setGamesRootPlane, setSpotlightIndex, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, } = ctx; const openOptionsMenu = (): void => { @@ -142,10 +147,15 @@ export function createActionHandlers( setOptionsOpen, playUiSound, spotlightEntryHasGame, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, }); }; const onSecondaryActivate = (): void => { + if (localVideoPlayerOpen) return; if (optionsOpen) return; if (gamesHubOpen) return; if (routeSecondaryActivate({ @@ -218,7 +228,12 @@ export function createActionHandlers( setGamesHubFocusIndex, setPs5Row, gamesHubReturnSnapshotRef, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, })) return; + if (localVideoPlayerOpen) return; if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root" && selectedGame) { const tile = gamesHubTiles[gamesHubFocusIndex]; @@ -380,6 +395,7 @@ export function createActionHandlers( setSelectedMediaIndex, mediaAssetItems, playUiSound, + openLocalVideoPlayer, } : undefined, all: topCategory === "all" ? { gameSubcategory, @@ -428,6 +444,12 @@ export function createActionHandlers( e.preventDefault(); return; } + if (localVideoPlayerOpen) { + closeLocalVideoPlayer(); + playUiSound("move"); + e.preventDefault(); + return; + } if (inStreamMenu && endSessionConfirm) { setEndSessionConfirm(false); playUiSound("move"); @@ -550,12 +572,12 @@ export function createActionHandlers( onTertiaryActivate(); return; } - if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen) { + if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen && !localVideoPlayerOpen) { e.preventDefault(); cycleTopCategory(-1); return; } - if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen) { + if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen && !localVideoPlayerOpen) { e.preventDefault(); cycleTopCategory(1); return; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts index e87e563d9..3f5247c86 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts @@ -83,6 +83,7 @@ export function createNavigationHandlers( setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, + localVideoPlayerOpen, } = ctx; const cycleTopCategory = (delta: number): void => { @@ -191,6 +192,10 @@ export function createNavigationHandlers( return; } + if (localVideoPlayerOpen) { + return; + } + if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root") { const n = gamesHubTiles.length; if (n === 0) return; @@ -511,6 +516,8 @@ export function createNavigationHandlers( const onShoulder = (e: any): void => { const direction = e?.detail?.direction as "prev" | "next" | undefined; if (!direction) return; + if (optionsOpen) return; + if (localVideoPlayerOpen) return; if (gamesHubOpen) return; if (topCategory === "settings" && settingsSubcategory !== "root") return; if (editingBandwidth || editingThemeChannel || editingStreamVolume || editingStreamMicLevel) return; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts index 32a3e238f..040bbd70c 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts @@ -111,6 +111,11 @@ export function useControllerLibraryEvents( setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, } = ctx; return useMemo(() => { @@ -215,6 +220,11 @@ export function useControllerLibraryEvents( setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, }; const { applyDirection, cycleTopCategory, onDirection, onShoulder } = createNavigationHandlers(eventContext); @@ -307,6 +317,11 @@ export function useControllerLibraryEvents( onStreamMenuVolumeChange, onStreamMenuToggleMicrophone, onStreamMenuToggleFullscreen, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, ]); } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts index 3abec020a..4d66e86c6 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts @@ -11,6 +11,8 @@ type UseControllerLibraryMediaArgs = { topCategory: TopCategory; mediaSubcategory: MediaSubcategory; selectedMediaIndex: number; + /** Increment to refetch listing and thumbnails after delete/regen. */ + mediaListRefreshNonce: number; }; type UseControllerLibraryMediaResult = { @@ -29,6 +31,7 @@ export function useControllerLibraryMedia({ topCategory, mediaSubcategory, selectedMediaIndex, + mediaListRefreshNonce, }: UseControllerLibraryMediaArgs): UseControllerLibraryMediaResult { const [mediaLoading, setMediaLoading] = useState(false); const [mediaError, setMediaError] = useState(null); @@ -92,7 +95,7 @@ export function useControllerLibraryMedia({ return () => { cancelled = true; }; - }, [topCategory, mediaSubcategory]); + }, [topCategory, mediaSubcategory, mediaListRefreshNonce]); const mediaAssetItems = useMemo(() => { if (mediaSubcategory === "Videos") return mediaVideos; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts new file mode 100644 index 000000000..9867e4156 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from "react"; +import type { MediaListingEntry } from "@shared/gfn"; +import type { SoundKind } from "./types"; + +export type LocalVideoPlaybackState = { src: string; filePath: string } | null; + +export function useLocalVideoPlayback(playUiSound: (kind: SoundKind) => void): { + playback: LocalVideoPlaybackState; + localVideoPlayerOpen: boolean; + openFromEntry: (entry: MediaListingEntry) => Promise; + close: () => void; +} { + const [playback, setPlayback] = useState(null); + + const close = useCallback(() => { + setPlayback(null); + }, []); + + const openFromEntry = useCallback( + async (entry: MediaListingEntry) => { + if (typeof window.openNow?.getMediaPlaybackUrl !== "function") { + if (typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: entry.filePath }); + } + playUiSound("confirm"); + return; + } + const url = await window.openNow.getMediaPlaybackUrl({ filePath: entry.filePath }); + if (!url) { + if (typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: entry.filePath }); + } + playUiSound("confirm"); + return; + } + setPlayback({ src: url, filePath: entry.filePath }); + playUiSound("confirm"); + }, + [playUiSound], + ); + + return { + playback, + localVideoPlayerOpen: playback !== null, + openFromEntry, + close, + }; +} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index f56dbe7df..2d8f93ec5 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -10000,3 +10000,55 @@ button.game-card-store-chip.owned.active:hover { font-size: 0.88rem; } } + +/* Local video playback (Media hub) */ +.xmb-local-video-overlay { + position: fixed; + inset: 0; + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; +} + +.xmb-local-video-backdrop { + position: absolute; + inset: 0; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + background: rgba(2, 6, 10, 0.92); +} + +.xmb-local-video-panel { + position: relative; + z-index: 1; + width: min(96vw, 1200px); + max-height: min(92vh, 900px); + display: flex; + flex-direction: column; + padding: 10px; + border-radius: 16px; + background: rgba(12, 18, 24, 0.96); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.65); +} + +.xmb-local-video-frame { + flex: 1; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + background: #000; + border-radius: 12px; + overflow: hidden; +} + +.xmb-local-video-element { + width: 100%; + max-height: min(72vh, 760px); + vertical-align: middle; +} diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 737b26845..058e94ea0 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -853,6 +853,15 @@ export interface OpenNowApi { /** Reveal a media file path in the system file manager */ showMediaInFolder(input: { filePath: string }): Promise; + /** Trusted file:// URL for in-app playback of a video under OpenNOW media root, or null */ + getMediaPlaybackUrl(input: { filePath: string }): Promise; + + /** Delete a media file under the OpenNOW pictures root (recordings, screenshots, etc.) */ + deleteMediaFile(input: { filePath: string }): Promise<{ ok: boolean }>; + + /** Invalidate cached / sidecar thumbnails and regenerate (returns data URL when possible) */ + regenMediaThumbnail(input: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }>; + deleteCache(): Promise; /** Fetch current GFN queue wait times from the PrintedWaste API */ diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 6c49a4449..8ee3f6cff 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -64,6 +64,9 @@ export const IPC_CHANNELS = { MEDIA_LIST_BY_GAME: "media:list-by-game", MEDIA_THUMBNAIL: "media:thumbnail", MEDIA_SHOW_IN_FOLDER: "media:show-in-folder", + MEDIA_PLAYBACK_URL: "media:playback-url", + MEDIA_DELETE_FILE: "media:delete-file", + MEDIA_REGEN_THUMBNAIL: "media:regen-thumbnail", // PrintedWaste queue integration PRINTEDWASTE_QUEUE_FETCH: "printedwaste:queue-fetch", PRINTEDWASTE_SERVER_MAPPING_FETCH: "printedwaste:server-mapping-fetch", diff --git a/opennow-stable/src/shared/mediaPlayback.ts b/opennow-stable/src/shared/mediaPlayback.ts new file mode 100644 index 000000000..ebacc8c52 --- /dev/null +++ b/opennow-stable/src/shared/mediaPlayback.ts @@ -0,0 +1,7 @@ +/** Extensions the app treats as local video files for in-app playback and thumbnails. Keep in sync with main media handling. */ +export const PLAYABLE_VIDEO_EXTENSIONS = [".mp4", ".webm", ".mkv", ".mov"] as const; + +export function isPlayableVideoFilePath(filePath: string): boolean { + const lower = filePath.toLowerCase(); + return PLAYABLE_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} From 1537b68050f6f41c7c142f04746a822693d01e55 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 14:16:39 -0500 Subject: [PATCH 4/9] feat(video): add video duration probing and random thumbnail extraction - Introduced a new function to probe video duration using ffprobe, enhancing the ability to generate thumbnails at random points within the video. - Updated the thumbnail generation logic to utilize the newly added duration probing and random seek functionality, improving the quality and variability of extracted thumbnails. - Refactored the existing thumbnail extraction function to support dynamic seeking based on video length, ensuring better representation of video content. These changes enhance the media management capabilities by providing more effective thumbnail generation for videos. --- opennow-stable/src/main/index.ts | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 81292bee0..204af8f32 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -586,10 +586,49 @@ function md5(input: string): string { return createHash("md5").update(input).digest("hex"); } -async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise { +/** Seconds; null if ffprobe missing or unreadable. */ +async function probeVideoDurationSeconds(sourcePath: string): Promise { + return new Promise((resolve) => { + const args = [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + sourcePath, + ]; + const child = spawn("ffprobe", args, { stdio: ["ignore", "pipe", "ignore"] }); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => { + if (code !== 0) { + resolve(null); + return; + } + const n = Number.parseFloat(out.trim()); + resolve(Number.isFinite(n) && n > 0 ? n : null); + }); + }); +} + +function randomThumbnailSeekSeconds(durationSec: number | null): number { + if (durationSec !== null && durationSec > 0.2) { + const margin = Math.min(0.35, durationSec * 0.08); + const hi = Math.max(durationSec - margin, margin + 0.05); + const lo = Math.min(margin, hi * 0.5); + return lo + Math.random() * (hi - lo); + } + return 0.2 + Math.random() * 4.8; +} + +function ffmpegExtractOneFrame(sourcePath: string, outPath: string, seekSec: number): Promise { + const ss = seekSec.toFixed(3); return new Promise((resolve) => { - // Try to run ffmpeg to extract a frame at 1s. - const args = ["-y", "-ss", "1", "-i", sourcePath, "-frames:v", "1", "-q:v", "2", outPath]; + const args = ["-y", "-ss", ss, "-i", sourcePath, "-frames:v", "1", "-q:v", "2", outPath]; const child = spawn("ffmpeg", args, { stdio: "ignore" }); child.on("error", () => resolve(false)); child.on("close", (code) => { @@ -598,6 +637,14 @@ async function generateVideoThumbnail(sourcePath: string, outPath: string): Prom }); } +async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise { + const durationSec = await probeVideoDurationSeconds(sourcePath); + const seekSec = randomThumbnailSeekSeconds(durationSec); + if (await ffmpegExtractOneFrame(sourcePath, outPath, seekSec)) return true; + if (seekSec > 0.02) return ffmpegExtractOneFrame(sourcePath, outPath, 0); + return false; +} + async function ensureThumbnailForMedia(filePath: string): Promise { try { const stats = await stat(filePath); From ead441bc73ae707615e553d3ddc1020fab186d8c Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 14:43:48 -0500 Subject: [PATCH 5/9] feat(controller-library): enhance home shelf functionality and featured game handling - Introduced a new state management for home root plane, allowing dynamic switching between "spotlight" and "actions" views. - Added logic to display a featured game on the home shelf, improving user engagement with recently played titles. - Updated the ControllerLibraryPage and related components to support the new home root plane and featured game features. - Enhanced navigation and interaction logic for the home dual shelf, ensuring a seamless user experience when accessing featured content. These changes significantly improve the user interface and interaction flow within the controller library, providing a more engaging experience for users. --- .../controllerMode/ControllerLibraryPage.tsx | 142 +++++++++++++++-- .../ControllerLibraryLayout.tsx | 19 ++- .../controllerLibrary/FooterHints.tsx | 63 ++++++-- .../HomeSubscriptionMeta.tsx | 58 +++++++ .../controllerLibrary/SpotlightShelfBand.tsx | 82 ++++++++++ .../controllerLibrary/TopLevelMenuTrack.tsx | 24 ++- .../TopLevelShelfSection.tsx | 149 ++++++++++-------- .../controllerLibrary/actions/actionRouter.ts | 6 +- .../actions/allCategoryActions.ts | 8 + .../controllerLibrary/actions/contracts.ts | 6 + .../actions/optionsActions.ts | 37 +++-- .../events/actionHandlers.ts | 78 ++++++++- .../events/navigationHandlers.ts | 45 +++++- .../controllerMode/controllerLibrary/types.ts | 5 + .../useControllerLibraryEvents.ts | 17 ++ .../useControllerLibraryGameDerivations.ts | 46 +++++- .../useControllerLibraryLayoutMotion.ts | 11 +- 17 files changed, 665 insertions(+), 131 deletions(-) create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/SpotlightShelfBand.tsx diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 6072e43b6..3c8378eb1 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -50,11 +50,11 @@ import type { Direction, GameSubcategory, GamesHubReturnSnapshot, + HomeRootPlane, LibrarySortId, MediaSubcategory, SettingsSubcategory, SoundKind, - SpotlightEntry, TopCategory, } from "./controllerLibrary/types"; @@ -131,6 +131,7 @@ export function ControllerLibraryPage({ const [detailRailIndex, setDetailRailIndex] = useState(0); const [librarySortId, setLibrarySortId] = useState(() => readLibrarySortId()); const [gamesRootPlane, setGamesRootPlane] = useState<"spotlight" | "categories">("spotlight"); + const [homeRootPlane, setHomeRootPlane] = useState("spotlight"); const [spotlightIndex, setSpotlightIndex] = useState(0); const [optionsOpen, setOptionsOpen] = useState(false); const [optionsEntries, setOptionsEntries] = useState>([]); @@ -140,7 +141,7 @@ export function ControllerLibraryPage({ const [mediaListRefreshNonce, setMediaListRefreshNonce] = useState(0); /** Local captures for the focused game; loaded when hub opens so Media tab need not be visited first */ const [gameHubScreenshotUrls, setGameHubScreenshotUrls] = useState([]); - /** Random capture for Home Snapshot resume tile; falls back to poster in TopLevelMenuTrack */ + /** Random capture for Home last-played resume tile; falls back to poster in TopLevelMenuTrack */ const [homeResumeSnapshotUrl, setHomeResumeSnapshotUrl] = useState(null); const gamesHubReturnSnapshotRef = useRef(null); const spotlightTrackRef = useRef(null); @@ -281,6 +282,7 @@ export function ControllerLibraryPage({ selectedIndex, selectedGame, selectedVariantId, + featuredHomeGame, } = useControllerLibraryGameDerivations({ games, favoriteGameIds, @@ -288,6 +290,7 @@ export function ControllerLibraryPage({ topCategory, currentStreamingGame, homeShelfGameTitle: currentTabGame?.title ?? null, + resumeContextGameId: currentTabGame?.id ?? null, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -494,21 +497,32 @@ export function ControllerLibraryPage({ }, [spotlightEntries, topCategory, gameSubcategory]); + const gamesHubDisplayGame = useMemo((): GameInfo | null => { + if (!gamesHubOpen) return null; + if (topCategory === "current") { + return games.find((g) => g.id === selectedGameId) ?? null; + } + if (topCategory === "all" && gameSubcategory !== "root") { + return selectedGame; + } + return null; + }, [gamesHubOpen, topCategory, gameSubcategory, games, selectedGameId, selectedGame]); + useEffect(() => { - if (!gamesHubOpen || !selectedGame?.title?.trim()) { + if (!gamesHubOpen || !gamesHubDisplayGame?.title?.trim()) { setGameHubScreenshotUrls([]); return; } let cancelled = false; - void loadScreenshotUrlsForGameTitle(selectedGame.title).then((urls) => { + void loadScreenshotUrlsForGameTitle(gamesHubDisplayGame.title).then((urls) => { if (!cancelled) setGameHubScreenshotUrls(urls); }); return () => { cancelled = true; }; - }, [gamesHubOpen, selectedGame?.id, selectedGame?.title]); + }, [gamesHubOpen, gamesHubDisplayGame?.id, gamesHubDisplayGame?.title]); useEffect(() => { const game = currentTabGame; @@ -539,6 +553,7 @@ export function ControllerLibraryPage({ const topLevelShelfActive = !gamesShelfBrowseActive && !mediaShelfBrowseActive && + !(topCategory === "current" && gamesHubOpen) && (topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || @@ -547,6 +562,15 @@ export function ControllerLibraryPage({ topCategory === "all" && gameSubcategory === "root" && (games.length > 0 || Boolean(cloudSessionResumable && onResumeCloudSession)); + const homeDualShelf = + topCategory === "current" && !inStreamMenu && spotlightEntries.length > 0; + + const featuredIsFavorite = Boolean(featuredHomeGame && favoriteGameIdSet.has(featuredHomeGame.id)); + + useEffect(() => { + if (topCategory !== "current") return; + if (!inStreamMenu) setHomeRootPlane("spotlight"); + }, [topCategory, inStreamMenu]); const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); /** Media browse: no secondary “detail” row (down used to open Open folder / Media hub cards). */ const canEnterDetailRow = false; @@ -571,6 +595,12 @@ export function ControllerLibraryPage({ const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory).label, [topCategory]); const selectedTopLevelItemLabel = useMemo(() => { if (!topLevelShelfActive) return selectedCategoryLabel; + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return entry.title; + if (spotlightEntryHasGame(entry)) return entry.game.title; + return "Recently played"; + } if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return entry.title; @@ -583,7 +613,20 @@ export function ControllerLibraryPage({ if (topCategory === "settings" && active?.label) return active.label; if (topCategory === "current" && active?.label) return active.label; return selectedCategoryLabel; - }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, mediaSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex]); + }, [ + topLevelShelfActive, + selectedCategoryLabel, + displayItems, + topLevelShelfIndex, + topCategory, + gameSubcategory, + mediaSubcategory, + gamesRootPlane, + homeDualShelf, + homeRootPlane, + spotlightEntries, + spotlightIndex, + ]); const detailRailItems = useMemo>(() => { if (topCategory === "media" && mediaSubcategory !== "root") { const current = mediaAssetItems[selectedMediaIndex]; @@ -597,16 +640,16 @@ export function ControllerLibraryPage({ }, [topCategory, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); const gamesHubTiles = useMemo(() => { - if (!selectedGame || topCategory !== "all" || gameSubcategory === "root") return []; - const fav = favoriteGameIdSet.has(selectedGame.id); + if (!gamesHubDisplayGame) return []; + const fav = favoriteGameIdSet.has(gamesHubDisplayGame.id); const tiles: Array<{ id: string; title: string; subtitle: string; disabled?: boolean }> = [ { id: "play", - title: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + title: currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? "Switch" : "Play", subtitle: - inStreamMenu && currentStreamingGame && currentStreamingGame.id !== selectedGame.id + inStreamMenu && currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? `Switch from ${currentStreamingGame.title}` - : currentStreamingGame && currentStreamingGame.id !== selectedGame.id + : currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? "Switch to this title" : "Launch now", }, @@ -616,20 +659,29 @@ export function ControllerLibraryPage({ subtitle: "Library", }, ]; - if (selectedGame.variants.length > 1) { + if (gamesHubDisplayGame.variants.length > 1) { tiles.push({ id: "version", title: "Version", subtitle: "Cycle stream variant" }); } tiles.push({ id: "activities", title: "Activities", subtitle: "Coming soon", disabled: true }); tiles.push({ id: "progress", title: "Progress", subtitle: "Coming soon", disabled: true }); return tiles; - }, [topCategory, gameSubcategory, selectedGame, favoriteGameIdSet, currentStreamingGame, inStreamMenu]); + }, [gamesHubDisplayGame, favoriteGameIdSet, currentStreamingGame, inStreamMenu]); useEffect(() => { const n = gamesHubTiles.length; if (n === 0) return; setGamesHubFocusIndex((i) => Math.max(0, Math.min(n - 1, i))); - }, [gamesHubTiles.length, selectedGame?.id]); + }, [gamesHubTiles.length, gamesHubDisplayGame?.id]); const focusMotionKey = useMemo(() => { + if (topCategory === "current" && gamesHubOpen) { + return `game-${gamesHubDisplayGame?.id ?? "none"}`; + } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return `home-spotlight-resume-${entry.busy ? "busy" : "idle"}`; + if (spotlightEntryHasGame(entry)) return `home-spotlight-${entry.game.id}`; + return `home-spotlight-empty-${spotlightIndex}`; + } if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return `spotlight-resume-${entry.busy ? "busy" : "idle"}`; @@ -639,7 +691,22 @@ export function ControllerLibraryPage({ if (topCategory === "all" && gameSubcategory !== "root") return `game-${selectedGame?.id ?? "none"}`; if (topCategory === "media" && mediaSubcategory !== "root") return `media-${selectedMediaIndex}-${mediaAssetItems[selectedMediaIndex]?.id ?? "none"}`; return `menu-${topCategory}-${topLevelShelfIndex}`; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); + }, [ + topCategory, + gameSubcategory, + gamesRootPlane, + homeDualShelf, + homeRootPlane, + gamesHubOpen, + gamesHubDisplayGame?.id, + spotlightEntries, + spotlightIndex, + selectedGame?.id, + topLevelShelfIndex, + mediaSubcategory, + selectedMediaIndex, + mediaAssetItems, + ]); const { listTranslateX, spotlightShelfTranslateX, @@ -660,6 +727,7 @@ export function ControllerLibraryPage({ selectedIndex, selectedMediaIndex, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength: spotlightEntries.length, itemsContainerRef, @@ -795,12 +863,17 @@ export function ControllerLibraryPage({ optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -831,6 +904,7 @@ export function ControllerLibraryPage({ setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -875,6 +949,9 @@ export function ControllerLibraryPage({ }; const heroBackdropUrl = useMemo(() => { + if (topCategory === "current" && gamesHubOpen && gamesHubDisplayGame?.imageUrl) { + return gamesHubDisplayGame.imageUrl; + } if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntries.length > 0) { const cur = spotlightEntries[spotlightIndex]; if (cur?.kind === "cloudResume" && cur.coverUrl) return cur.coverUrl; @@ -885,6 +962,16 @@ export function ControllerLibraryPage({ } return null; } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "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 currentTabGame?.imageUrl ?? null; if (topCategory === "media") { @@ -894,7 +981,21 @@ export function ControllerLibraryPage({ } if (currentTabGame?.imageUrl) return currentTabGame.imageUrl; return selectedGame?.imageUrl ?? null; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame, currentTabGame, selectedMediaItem, mediaThumbById]); + }, [ + topCategory, + gameSubcategory, + gamesRootPlane, + homeDualShelf, + homeRootPlane, + gamesHubOpen, + gamesHubDisplayGame, + spotlightEntries, + spotlightIndex, + selectedGame, + currentTabGame, + selectedMediaItem, + mediaThumbById, + ]); const themeRgbForTrack = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; const maxBitrateMbpsForTrack = settings.maxBitrateMbps ?? 75; @@ -909,6 +1010,7 @@ export function ControllerLibraryPage({ topLevelShelfIndex={topLevelShelfIndex} gameCategoryPreviewById={gameCategoryPreviewById} currentStreamingImageUrl={homeResumeSnapshotUrl ?? currentTabGame?.imageUrl} + featuredPreviewImageUrl={featuredHomeGame?.imageUrl ?? null} settingsSubcategory={settingsSubcategory} editingBandwidth={editingBandwidth} maxBitrateMbpsForTrack={maxBitrateMbpsForTrack} @@ -933,6 +1035,7 @@ export function ControllerLibraryPage({ gameCategoryPreviewById, homeResumeSnapshotUrl, currentTabGame?.imageUrl, + featuredHomeGame?.imageUrl, editingBandwidth, editingThemeChannel, settingsSubcategory, @@ -976,6 +1079,7 @@ export function ControllerLibraryPage({ getCategoryIcon={getCategoryIcon} gameSubcategory={gameSubcategory} gamesHubOpen={gamesHubOpen} + gamesHubDisplayGame={gamesHubDisplayGame} selectedGame={selectedGame} gameHubScreenshotUrls={gameHubScreenshotUrls} playtimeData={playtimeData} @@ -993,6 +1097,10 @@ export function ControllerLibraryPage({ topLevelShelfActive={topLevelShelfActive} selectedTopLevelItemLabel={selectedTopLevelItemLabel} gamesRootPlane={gamesRootPlane} + homeRootPlane={homeRootPlane} + homeDualShelf={homeDualShelf} + featuredHomeGame={featuredHomeGame} + featuredIsFavorite={featuredIsFavorite} spotlightEntries={spotlightEntries} spotlightIndex={spotlightIndex} displayItems={displayItems} @@ -1022,7 +1130,7 @@ export function ControllerLibraryPage({ topLevelRowBehaviorActive={topLevelRowBehaviorActive} settingsSubcategory={settingsSubcategory} editingThemeChannel={editingThemeChannel} - selectedGameForHints={selectedGame} + selectedGameForHints={gamesHubDisplayGame ?? selectedGame} controllerType={controllerType} renderFaceButton={renderFaceButton} /> diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx index cc0d3140c..e13e05468 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx @@ -34,6 +34,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element getCategoryIcon, gameSubcategory, gamesHubOpen, + gamesHubDisplayGame, selectedGame, gameHubScreenshotUrls, playtimeData, @@ -51,11 +52,15 @@ export function ControllerLibraryLayout(props: Record): JSX.Element topLevelShelfActive, selectedTopLevelItemLabel, gamesRootPlane, + homeRootPlane, spotlightEntries, spotlightIndex, displayItems, topLevelShelfIndex, gamesDualShelf, + homeDualShelf, + featuredHomeGame, + featuredIsFavorite, cloudSessionResumable, onResumeCloudSession, spotlightTrackRef, @@ -162,14 +167,14 @@ export function ControllerLibraryLayout(props: Record): JSX.Element getCategoryIcon={getCategoryIcon} /> - {topCategory === "all" && gameSubcategory !== "root" && gamesHubOpen && selectedGame ? ( + {gamesHubOpen && gamesHubDisplayGame && ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") ? ( ): JSX.Element topCategory={topCategory} gameSubcategory={gameSubcategory} gamesRootPlane={gamesRootPlane} + homeRootPlane={homeRootPlane ?? "spotlight"} spotlightEntries={spotlightEntries} spotlightIndex={spotlightIndex} displayItems={displayItems} topLevelShelfIndex={topLevelShelfIndex} currentTabGame={currentTabGame} + featuredHomeGame={featuredHomeGame ?? null} + featuredIsFavorite={Boolean(featuredIsFavorite)} playtimeData={playtimeData} gamesDualShelf={gamesDualShelf} + homeDualShelf={Boolean(homeDualShelf)} + inStreamMenu={inStreamMenu} + subscriptionInfo={subscriptionInfo} cloudSessionResumable={cloudSessionResumable} onResumeCloudSession={onResumeCloudSession} spotlightTrackRef={spotlightTrackRef} @@ -254,6 +265,8 @@ export function ControllerLibraryLayout(props: Record): JSX.Element gameSubcategory={gameSubcategory} gamesHubOpen={gamesHubOpen} gamesRootPlane={gamesRootPlane} + homeRootPlane={homeRootPlane ?? "spotlight"} + homeDualShelf={Boolean(homeDualShelf)} spotlightEntries={spotlightEntries} spotlightIndex={spotlightIndex} currentStreamingGame={currentStreamingGame} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx index 6a2f81c5d..c059e0ea7 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx @@ -2,7 +2,7 @@ import type { JSX } from "react"; import { ButtonA, ButtonB, ButtonPSCircle, ButtonPSCross } from "../ControllerButtons"; import { spotlightEntryHasGame } from "./helpers"; import type { GameInfo } from "@shared/gfn"; -import type { GameSubcategory, SettingsSubcategory, SpotlightEntry } from "./types"; +import type { GameSubcategory, HomeRootPlane, SettingsSubcategory, SpotlightEntry } from "./types"; interface FooterHintsProps { localVideoPlayerOpen?: boolean; @@ -14,6 +14,8 @@ interface FooterHintsProps { gameSubcategory: GameSubcategory; gamesHubOpen: boolean; gamesRootPlane: "spotlight" | "categories"; + homeRootPlane?: HomeRootPlane; + homeDualShelf?: boolean; spotlightEntries: SpotlightEntry[]; spotlightIndex: number; currentStreamingGame: GameInfo | null | undefined; @@ -32,6 +34,8 @@ export function FooterHints({ gameSubcategory, gamesHubOpen, gamesRootPlane, + homeRootPlane = "spotlight", + homeDualShelf = false, spotlightEntries, spotlightIndex, currentStreamingGame, @@ -57,19 +61,45 @@ export function FooterHints({ return (
{topLevelRowBehaviorActive ? ( - <> -
- {controllerType === "ps" ? ( - - ) : ( - - )} - Select -
-
L1 Prev Section
-
R1 Next Section
- - ) : topCategory === "current" ? ( + topCategory === "current" && homeDualShelf ? ( + <> +
Rows · ↑ / ↓
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + + {homeRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + ? spotlightEntries[spotlightIndex].busy + ? "Please wait" + : "Resume session" + : homeRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ? "Game hub" + : homeRootPlane === "spotlight" + ? "Enter" + : "Select"} + +
+
L1 Prev Section
+
R1 Next Section
+ + ) : ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+
L1 Prev Section
+
R1 Next Section
+ + ) + ) : topCategory === "current" && !gamesHubOpen ? (
{controllerType === "ps" ? ( @@ -182,7 +212,8 @@ export function FooterHints({ )} - ) : topCategory === "all" && gameSubcategory !== "root" ? ( + ) : (topCategory === "all" && gameSubcategory !== "root") || + (topCategory === "current" && gamesHubOpen) ? ( gamesHubOpen ? ( <>
Actions · Left / Right
@@ -200,7 +231,7 @@ export function FooterHints({ ) : ( )} - Back + {topCategory === "current" ? "Back to Home" : "Back"}
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx new file mode 100644 index 000000000..d9d26e0c4 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx @@ -0,0 +1,58 @@ +import type { JSX } from "react"; +import { Clock, Shield } from "lucide-react"; +import type { SubscriptionInfo } from "@shared/gfn"; +import { formatRemainingPlaytimeFromSubscription } from "../../../utils/usePlaytime"; + +function formatRenewalRelative(isoEnd: string | undefined): string | null { + if (!isoEnd?.trim()) return null; + const end = Date.parse(isoEnd); + if (!Number.isFinite(end)) return null; + const now = Date.now(); + const diffMs = end - now; + const dayMs = 86_400_000; + const days = Math.round(diffMs / dayMs); + if (days < 0) return "Period ended"; + if (days === 0) return "Renews today"; + if (days === 1) return "Renews tomorrow"; + return `Renews in ${days} days`; +} + +interface HomeSubscriptionMetaProps { + subscriptionInfo: SubscriptionInfo | null; +} + +export function HomeSubscriptionMeta({ subscriptionInfo }: HomeSubscriptionMetaProps): JSX.Element | null { + if (!subscriptionInfo) return null; + + const tier = subscriptionInfo.membershipTier?.trim() || "Membership"; + const timeText = subscriptionInfo.isUnlimited + ? "Unlimited" + : `${formatRemainingPlaytimeFromSubscription(subscriptionInfo, 0)} left`; + const renewal = formatRenewalRelative(subscriptionInfo.currentSpanEndDateTime); + const state = subscriptionInfo.state?.trim(); + const warnState = state && state.toUpperCase() !== "ACTIVE"; + const blocked = subscriptionInfo.isGamePlayAllowed === false; + + return ( +
+ + + {tier} + + + + {timeText} + + {renewal ? ( + + {renewal} + + ) : null} + {warnState || blocked ? ( + + {blocked ? "Play may be restricted" : `Status: ${state}`} + + ) : null} +
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/SpotlightShelfBand.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/SpotlightShelfBand.tsx new file mode 100644 index 000000000..b5bf54289 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/SpotlightShelfBand.tsx @@ -0,0 +1,82 @@ +import type { JSX, RefObject } from "react"; +import { SHELF_IMAGE_PROPS } from "./constants"; +import type { SpotlightEntry } from "./types"; + +export interface SpotlightShelfBandProps { + spotlightTrackRef: RefObject; + spotlightShelfTranslateX: number; + spotlightEntries: SpotlightEntry[]; + spotlightIndex: number; + /** When true, spotlight row receives focus styling and tile `active` state. */ + spotlightPlaneActive: boolean; + shelfLabel: string; + ariaLabel?: string; +} + +export function SpotlightShelfBand({ + spotlightTrackRef, + spotlightShelfTranslateX, + spotlightEntries, + spotlightIndex, + spotlightPlaneActive, + shelfLabel, + ariaLabel = "Recently played games", +}: SpotlightShelfBandProps): JSX.Element { + return ( +
+
+ {shelfLabel} +
+
+
+ {spotlightEntries.map((entry, idx) => { + const isActive = spotlightPlaneActive && idx === spotlightIndex; + if (entry.kind === "cloudResume") { + return ( +
+
+ {entry.coverUrl ? ( + + ) : ( +
+ )} +
+ {entry.busy ? "Connecting…" : "Resume"} +
+
+
+ ); + } + const game = entry.game; + const key = game ? game.id : `recent-empty-${idx}`; + return ( +
+
+ {game?.imageUrl ? :
} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx index 9382161f8..4e3b270d2 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx @@ -11,6 +11,8 @@ interface TopLevelMenuTrackProps { topLevelShelfIndex: number; gameCategoryPreviewById: Record; currentStreamingImageUrl?: string; + /** Home “Featured” tile poster (separate from resume snapshot). */ + featuredPreviewImageUrl?: string | null; settingsSubcategory: string; editingBandwidth: boolean; maxBitrateMbpsForTrack: number; @@ -36,6 +38,7 @@ export function TopLevelMenuTrack({ topLevelShelfIndex, gameCategoryPreviewById, currentStreamingImageUrl, + featuredPreviewImageUrl = null, settingsSubcategory, editingBandwidth, maxBitrateMbpsForTrack, @@ -66,15 +69,16 @@ export function TopLevelMenuTrack({ const themeChannelForRow = item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; const isGameRootTile = topCategory === "all" && gameSubcategory === "root"; const isCurrentResumeTile = topCategory === "current" && item.id === "resume"; + const isCurrentFeaturedTile = topCategory === "current" && item.id === "featured"; const isSettingsTile = topCategory === "settings"; const previewThumbs = isGameRootTile ? (gameCategoryPreviewById[item.id] ?? []) : []; return (
{isCurrentResumeTile ? (
@@ -84,7 +88,19 @@ export function TopLevelMenuTrack({
)}
- Snapshot + Last played +
+
+ ) : null} + {isCurrentFeaturedTile ? ( +
+ {featuredPreviewImageUrl ? ( + + ) : ( +
+ )} +
+ Featured
) : null} @@ -100,7 +116,7 @@ export function TopLevelMenuTrack({ ))}
) : null} - {isCurrentResumeTile ? null :
{item.label}
} + {isCurrentResumeTile || isCurrentFeaturedTile ? null :
{item.label}
} {item.value ? (
{item.id === "bandwidth" && settingsSubcategory !== "root" ? ( diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx index 503ac3ec5..e811b655b 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx @@ -1,10 +1,11 @@ import type { JSX, RefObject } from "react"; -import type { GameInfo } from "@shared/gfn"; -import { Clock, Calendar, Repeat2 } from "lucide-react"; -import { SHELF_IMAGE_PROPS } from "./constants"; +import type { GameInfo, SubscriptionInfo } from "@shared/gfn"; +import { Clock, Calendar, Repeat2, Star } from "lucide-react"; import { spotlightEntryHasGame } from "./helpers"; -import type { SpotlightEntry } from "./types"; +import type { HomeRootPlane, SpotlightEntry } from "./types"; import { formatLastPlayed, formatPlaytime, type PlaytimeStore } from "../../../utils/usePlaytime"; +import { HomeSubscriptionMeta } from "./HomeSubscriptionMeta"; +import { SpotlightShelfBand } from "./SpotlightShelfBand"; interface TopLevelShelfSectionProps { topLevelShelfActive: boolean; @@ -13,13 +14,20 @@ interface TopLevelShelfSectionProps { topCategory: string; gameSubcategory: string; gamesRootPlane: "spotlight" | "categories"; + homeRootPlane?: HomeRootPlane; spotlightEntries: SpotlightEntry[]; spotlightIndex: number; displayItems: Array<{ id?: string }>; topLevelShelfIndex: number; currentTabGame?: GameInfo | null; + featuredHomeGame?: GameInfo | null; + /** When focused tile is featured, true if that game is in favorites. */ + featuredIsFavorite?: boolean; playtimeData: PlaytimeStore; gamesDualShelf: boolean; + homeDualShelf?: boolean; + inStreamMenu?: boolean; + subscriptionInfo?: SubscriptionInfo | null; cloudSessionResumable?: boolean; onResumeCloudSession?: () => void; spotlightTrackRef: RefObject; @@ -34,13 +42,19 @@ export function TopLevelShelfSection({ topCategory, gameSubcategory, gamesRootPlane, + homeRootPlane = "spotlight", spotlightEntries, spotlightIndex, displayItems, topLevelShelfIndex, currentTabGame, + featuredHomeGame = null, + featuredIsFavorite = false, playtimeData, gamesDualShelf, + homeDualShelf = false, + inStreamMenu = false, + subscriptionInfo = null, cloudSessionResumable, onResumeCloudSession, spotlightTrackRef, @@ -49,11 +63,19 @@ export function TopLevelShelfSection({ }: TopLevelShelfSectionProps): JSX.Element | null { if (!topLevelShelfActive) return null; + const showDualShelf = gamesDualShelf || homeDualShelf; + const gamesSpotlightPlane = gamesRootPlane === "spotlight"; + const homeSpotlightPlane = homeRootPlane === "spotlight"; + const shelfLabel = + cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"; + + const focusedId = displayItems[topLevelShelfIndex]?.id; + return (

{selectedTopLevelItemLabel}

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

{(() => { const se = spotlightEntries[spotlightIndex]; @@ -69,7 +91,24 @@ export function TopLevelShelfSection({ })()}

) : null} - {topCategory === "current" && displayItems[topLevelShelfIndex]?.id === "resume" && currentTabGame ? ( + {topCategory === "current" && homeSpotlightPlane && homeDualShelf ? ( +

+ {(() => { + 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" && !inStreamMenu ? : null} + {topCategory === "current" && focusedId === "resume" && currentTabGame ? (
{(() => { const record = playtimeData[currentTabGame.id]; @@ -97,66 +136,48 @@ export function TopLevelShelfSection({ })()}
) : null} + {topCategory === "current" && focusedId === "featured" && featuredHomeGame ? ( +
+ {(() => { + const record = playtimeData[featuredHomeGame.id]; + const totalSecs = record?.totalSeconds ?? 0; + const genres = featuredHomeGame.genres?.filter(Boolean).slice(0, 2).join(" · "); + return ( + <> + {totalSecs === 0 ? ( + Never played + ) : ( + + + {formatPlaytime(totalSecs)} + + )} + + + {featuredIsFavorite ? "Favorite · featured pick" : "Featured pick"} + + {genres ? ( + {genres} + ) : null} + + ); + })()} +
+ ) : null}
- {gamesDualShelf ? ( + {showDualShelf ? (
-
-
- - {cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"} - -
-
-
- {spotlightEntries.map((entry, idx) => { - const isActive = gamesRootPlane === "spotlight" && idx === spotlightIndex; - if (entry.kind === "cloudResume") { - return ( -
-
- {entry.coverUrl ? ( - - ) : ( -
- )} -
- {entry.busy ? "Connecting…" : "Resume"} -
-
-
- ); - } - const game = entry.game; - const key = game ? game.id : `recent-empty-${idx}`; - return ( -
-
- {game?.imageUrl ? :
} -
-
- ); - })} -
-
-
+
{topLevelMenuTrack}
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts index 8c56e944d..a3f0f6a7b 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts @@ -69,7 +69,11 @@ export function routeCancel(context: { if (context.topCategory === "media" && context.media) { return handleMediaCancelAction(context.media); } - if (context.topCategory === "all" && context.all) { + if ( + context.all && + (context.topCategory === "all" || + (context.topCategory === "current" && context.all.gamesHubOpen)) + ) { return handleAllCancelAction(context.all); } return false; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts index 3e5431d70..eba1ad4c7 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts @@ -107,6 +107,8 @@ export function handleAllCancelAction(context: AllCancelContext): boolean { throttledOnSelectGame, lastRootGameIndex, playUiSound, + setCategoryIndex, + setHomeRootPlane, } = context; if (gamesHubOpen) { @@ -123,6 +125,12 @@ export function handleAllCancelAction(context: AllCancelContext): boolean { if (snap.restoreSelectedGameId) { throttledOnSelectGame(snap.restoreSelectedGameId); } + if (snap.restoreCategoryIndex != null && setCategoryIndex) { + setCategoryIndex(snap.restoreCategoryIndex); + } + if (snap.restoreHomeRootPlane != null && setHomeRootPlane) { + setHomeRootPlane(snap.restoreHomeRootPlane); + } } return true; } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts index 8cc105c23..1dc5fff11 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts @@ -2,6 +2,7 @@ import type { ControllerThemeStyle, GameInfo, MediaListingEntry, Settings } from import type { GameSubcategory, GamesHubReturnSnapshot, + HomeRootPlane, LibrarySortId, MediaSubcategory, SettingsSubcategory, @@ -24,6 +25,9 @@ export interface OptionsActionContext { selectedMediaIndex: number; mediaAssetItems: MediaListingEntry[]; selectedGame: GameInfo | null; + /** When Game Hub is open from Home, the focused title (Games browse uses `selectedGame`). */ + gamesHubDisplayGame?: GameInfo | null; + gamesHubOpen?: boolean; currentStreamingGame?: GameInfo | null; favoriteGameIdSet: Set; setOptionsEntries: (entries: OptionEntry[]) => void; @@ -151,6 +155,8 @@ export interface AllCancelContext { setSpotlightIndex: (index: number) => void; throttledOnSelectGame: (id: string) => void; playUiSound: (kind: SoundKind) => void; + setCategoryIndex?: (index: number) => void; + setHomeRootPlane?: (plane: HomeRootPlane) => void; } export type ControllerLibrarySettings = { diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts index 31bdac0cc..a646b40ff 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts @@ -5,6 +5,8 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { const { gamesShelfBrowseActive, selectedGame, + gamesHubDisplayGame = null, + gamesHubOpen = false, currentStreamingGame, favoriteGameIdSet, mediaShelfBrowseActive, @@ -24,16 +26,22 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { } = context; const entries: Array<{ id: string; label: string }> = []; - if (gamesShelfBrowseActive && selectedGame) { + const gameForHubOptions = + gamesShelfBrowseActive && selectedGame + ? selectedGame + : topCategory === "current" && gamesHubOpen && gamesHubDisplayGame + ? gamesHubDisplayGame + : null; + if (gameForHubOptions) { entries.push({ id: "play", - label: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + label: currentStreamingGame && currentStreamingGame.id !== gameForHubOptions.id ? "Switch" : "Play", }); entries.push({ id: "favorite", - label: favoriteGameIdSet.has(selectedGame.id) ? "Remove favorite" : "Add favorite", + label: favoriteGameIdSet.has(gameForHubOptions.id) ? "Remove favorite" : "Add favorite", }); - if (selectedGame.variants.length > 1) { + if (gameForHubOptions.variants.length > 1) { entries.push({ id: "variant", label: "Change version" }); } } else if ( @@ -88,6 +96,9 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo optionsEntries, optionsFocusIndex, selectedGame, + gamesHubDisplayGame = null, + gamesHubOpen = false, + topCategory, onPlayGame, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -125,24 +136,26 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo playUiSound("move"); return true; } - if (opt.id === "play" && selectedGame) { - onPlayGame(selectedGame); + const gameForOption = + topCategory === "current" && gamesHubOpen && gamesHubDisplayGame ? gamesHubDisplayGame : selectedGame; + if (opt.id === "play" && gameForOption) { + onPlayGame(gameForOption); gamesHubReturnSnapshotRef.current = null; setGamesHubOpen(false); setOptionsOpen(false); playUiSound("confirm"); return true; } - if (opt.id === "favorite" && selectedGame) { - onToggleFavoriteGame(selectedGame.id); + if (opt.id === "favorite" && gameForOption) { + onToggleFavoriteGame(gameForOption.id); setOptionsOpen(false); playUiSound("confirm"); return true; } - 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); + if (opt.id === "variant" && gameForOption && gameForOption.variants.length > 1) { + const idx = gameForOption.variants.findIndex((v) => v.id === selectedVariantId); + const next = gameForOption.variants[(idx + 1) % gameForOption.variants.length]; + onSelectGameVariant(gameForOption.id, next.id); setOptionsOpen(false); playUiSound("confirm"); return true; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts index 4a9d56171..9022143e0 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts @@ -84,6 +84,7 @@ export function createActionHandlers( favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -120,7 +121,11 @@ export function createActionHandlers( setLibrarySortId, setCategoryIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, + featuredHomeGame, + homeDualShelf, + homeRootPlane, localVideoPlayerOpen, closeLocalVideoPlayer, openLocalVideoPlayer, @@ -140,6 +145,8 @@ export function createActionHandlers( selectedMediaIndex, mediaAssetItems, selectedGame, + gamesHubDisplayGame, + gamesHubOpen, currentStreamingGame, favoriteGameIdSet, setOptionsEntries, @@ -209,6 +216,8 @@ export function createActionHandlers( selectedMediaIndex, mediaAssetItems, selectedGame, + gamesHubDisplayGame, + gamesHubOpen, currentStreamingGame, favoriteGameIdSet, setOptionsEntries, @@ -235,14 +244,19 @@ export function createActionHandlers( })) return; if (localVideoPlayerOpen) return; - if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root" && selectedGame) { + if ( + gamesHubOpen && + gamesHubDisplayGame && + ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") + ) { const tile = gamesHubTiles[gamesHubFocusIndex]; + const hubGame = gamesHubDisplayGame; if (!tile || tile.disabled) { playUiSound("move"); return; } if (tile.id === "play") { - onPlayGame(selectedGame); + onPlayGame(hubGame); gamesHubReturnSnapshotRef.current = null; setGamesHubOpen(false); setGamesHubFocusIndex(0); @@ -250,14 +264,14 @@ export function createActionHandlers( return; } if (tile.id === "favorite") { - onToggleFavoriteGame(selectedGame.id); + onToggleFavoriteGame(hubGame.id); playUiSound("confirm"); return; } - if (tile.id === "version" && selectedGame.variants.length > 1) { - const idx = selectedGame.variants.findIndex((v: { id: string }) => v.id === selectedVariantId); - const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; - onSelectGameVariant(selectedGame.id, next.id); + if (tile.id === "version" && hubGame.variants.length > 1) { + const idx = hubGame.variants.findIndex((v: { id: string }) => v.id === selectedVariantId); + const next = hubGame.variants[(idx + 1) % hubGame.variants.length]; + onSelectGameVariant(hubGame.id, next.id); playUiSound("confirm"); return; } @@ -323,8 +337,49 @@ export function createActionHandlers( return; } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") { + if (!entry.busy && onResumeCloudSession) { + onResumeCloudSession(); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + return; + } + if (spotlightEntryHasGame(entry)) { + const game = entry.game; + if (game) { + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: game.id, + restoreHomeRootPlane: homeRootPlane, + }; + throttledOnSelectGame(game.id); + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + return; + } + playUiSound("move"); + return; + } + if (topCategory === "current") { const item = displayItems[selectedSettingIndex]; + if (item?.id === "featured" && featuredHomeGame && onPlayGame) { + onPlayGame(featuredHomeGame); + playUiSound("confirm"); + return; + } if (item?.id === "resume" && currentTabGame && onResumeGame) { onResumeGame(currentTabGame); playUiSound("confirm"); @@ -501,7 +556,10 @@ export function createActionHandlers( e.preventDefault(); return; } - if (topCategory === "all" && gameSubcategory !== "root") { + if ( + (topCategory === "all" && gameSubcategory !== "root") || + (topCategory === "current" && gamesHubOpen) + ) { routeCancel({ topCategory, all: { @@ -517,6 +575,10 @@ export function createActionHandlers( setSpotlightIndex, throttledOnSelectGame, playUiSound, + setCategoryIndex: (idx: number) => { + setCategoryIndex(idx); + }, + setHomeRootPlane, }, }); e.preventDefault(); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts index 3f5247c86..a3e74786a 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts @@ -57,10 +57,13 @@ export function createNavigationHandlers( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, onSettingChange, @@ -80,6 +83,7 @@ export function createNavigationHandlers( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -94,6 +98,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); setEditingStreamVolume(false); @@ -196,7 +201,11 @@ export function createNavigationHandlers( return; } - if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root") { + if ( + gamesHubOpen && + gamesHubDisplayGame && + ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") + ) { const n = gamesHubTiles.length; if (n === 0) return; if (direction === "left") { @@ -227,6 +236,9 @@ export function createNavigationHandlers( if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf) { setGamesRootPlane("spotlight"); } + if (topCategory === "current" && homeDualShelf) { + setHomeRootPlane("spotlight"); + } return; } return; @@ -348,7 +360,17 @@ export function createNavigationHandlers( if (topLevelRowBehaviorActive) { const isGamesRoot = topCategory === "all" && gameSubcategory === "root"; + const isHomeDual = topCategory === "current" && homeDualShelf; const itemCount = displayItems.length; + if (isHomeDual && homeRootPlane === "spotlight" && (direction === "left" || direction === "right")) { + const delta = direction === "left" ? -1 : 1; + const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); + if (next !== spotlightIndex) { + playUiSound("move"); + setSpotlightIndex(next); + } + return; + } if (isGamesRoot && gamesDualShelf && gamesRootPlane === "spotlight" && (direction === "left" || direction === "right")) { const delta = direction === "left" ? -1 : 1; const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); @@ -372,6 +394,25 @@ export function createNavigationHandlers( } if (direction === "up" || direction === "down") { + if (isHomeDual) { + if (direction === "up") { + if (homeRootPlane === "actions") { + playUiSound("move"); + setHomeRootPlane("spotlight"); + return; + } + if (homeRootPlane === "spotlight" && canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + return; + } + } + if (direction === "down" && homeRootPlane === "spotlight") { + playUiSound("move"); + setHomeRootPlane("actions"); + return; + } + } if (isGamesRoot && gamesDualShelf) { if (direction === "up") { if (gamesRootPlane === "categories") { @@ -426,6 +467,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); return; @@ -439,6 +481,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); return; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts index 536eef020..d6685c59a 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts @@ -70,12 +70,17 @@ export type MediaSubcategory = "root" | "Videos" | "Screenshots"; export type GameSubcategory = "root" | "all" | "favorites" | `genre:${string}`; export type LibrarySortId = "recent" | "az" | "za" | "favoritesFirst"; +export type HomeRootPlane = "spotlight" | "actions"; + export type GamesHubReturnSnapshot = { gameSubcategory: GameSubcategory; selectedGameSubcategoryIndex: number; gamesRootPlane: "spotlight" | "categories"; spotlightIndex: number; restoreSelectedGameId?: string; + /** When hub was opened from Home spotlight, restore this top tab on back. */ + restoreCategoryIndex?: number; + restoreHomeRootPlane?: HomeRootPlane; }; export type SpotlightEntry = diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts index 040bbd70c..82ca981fe 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts @@ -65,12 +65,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -101,6 +106,7 @@ export function useControllerLibraryEvents( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -174,12 +180,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -210,6 +221,7 @@ export function useControllerLibraryEvents( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -299,12 +311,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries.length, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame?.id, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame?.id, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts index 60e405513..08246e062 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts @@ -18,6 +18,8 @@ type UseControllerLibraryGameDerivationsArgs = { currentStreamingGame?: GameInfo | null; /** Shown as the first row label on the Home shelf (stream or last-played title). */ homeShelfGameTitle?: string | null; + /** Resume / Home context game id — excluded from Featured pick. */ + resumeContextGameId?: string | null; gameSubcategory: GameSubcategory; selectedGameId: string; selectedVariantByGameId: Record; @@ -62,10 +64,21 @@ type UseControllerLibraryGameDerivationsResult = { selectedVariantId: string; selectedGameDescription: string; selectedGameSessionState: string | null; + featuredHomeGame: GameInfo | null; }; const isNonEmptyString = (value: string | undefined): value is string => typeof value === "string" && value.length > 0; +function pickFeaturedGameDeterministic(pool: GameInfo[]): GameInfo { + const sorted = [...pool].sort((a, b) => a.id.localeCompare(b.id)); + const seed = sorted.map((g) => g.id).join("|"); + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = (h * 31 + seed.charCodeAt(i)) | 0; + } + return sorted[Math.abs(h) % sorted.length]!; +} + export function useControllerLibraryGameDerivations({ games, favoriteGameIds, @@ -73,6 +86,7 @@ export function useControllerLibraryGameDerivations({ topCategory, currentStreamingGame, homeShelfGameTitle, + resumeContextGameId = null, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -92,6 +106,20 @@ export function useControllerLibraryGameDerivations({ const favoriteGameIdSet = useMemo(() => new Set(favoriteGameIds), [favoriteGameIds]); const favoriteGames = useMemo(() => games.filter((game) => favoriteGameIdSet.has(game.id)), [games, favoriteGameIdSet]); + const featuredHomeGame = useMemo((): GameInfo | null => { + const excludeId = resumeContextGameId ?? undefined; + const candidates = excludeId ? games.filter((g) => g.id !== excludeId) : [...games]; + if (candidates.length === 0) return null; + const playSecs = (id: string) => playtimeData[id]?.totalSeconds ?? 0; + const favUnplayed = candidates.filter((g) => favoriteGameIdSet.has(g.id) && playSecs(g.id) === 0); + if (favUnplayed.length > 0) return pickFeaturedGameDeterministic(favUnplayed); + const anyUnplayed = candidates.filter((g) => playSecs(g.id) === 0); + if (anyUnplayed.length > 0) return pickFeaturedGameDeterministic(anyUnplayed); + const favs = candidates.filter((g) => favoriteGameIdSet.has(g.id)); + if (favs.length > 0) return pickFeaturedGameDeterministic(favs); + return pickFeaturedGameDeterministic(candidates); + }, [games, favoriteGameIdSet, playtimeData, resumeContextGameId]); + const allGenres = useMemo(() => { const genreSet = new Set(); for (const game of games) { @@ -113,14 +141,27 @@ export function useControllerLibraryGameDerivations({ ] : []; const resumeLabel = homeShelfGameTitle?.trim() || "Last played"; + const homeHead: Array<{ id: string; label: string; value: string }> = [{ id: "resume", label: resumeLabel, value: "" }]; + if (!inStreamMenu && featuredHomeGame) { + homeHead.push({ id: "featured", label: featuredHomeGame.title, value: "" }); + } return [ - { id: "resume", label: resumeLabel, value: "" }, + ...homeHead, ...streamExtras, ...(inStreamMenu ? [{ id: "closeGame", label: endSessionConfirm ? "End session (confirm)" : "Close Game", value: "" }] : []), ]; - }, [inStreamMenu, endSessionConfirm, streamMenuMicOn, streamMenuMicLevel, streamMenuVolume, streamMenuIsFullscreen, homeShelfGameTitle]); + }, [ + inStreamMenu, + endSessionConfirm, + streamMenuMicOn, + streamMenuMicLevel, + streamMenuVolume, + streamMenuIsFullscreen, + homeShelfGameTitle, + featuredHomeGame, + ]); const mediaRootItems = useMemo( () => [ @@ -324,5 +365,6 @@ export function useControllerLibraryGameDerivations({ selectedVariantId, selectedGameDescription, selectedGameSessionState, + featuredHomeGame, }; } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts index fc9475f91..cf5350b8d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts @@ -15,6 +15,7 @@ type UseControllerLibraryLayoutMotionArgs = { selectedIndex: number; selectedMediaIndex: number; gamesDualShelf: boolean; + homeDualShelf: boolean; spotlightIndex: number; spotlightEntriesLength: number; itemsContainerRef: RefObject; @@ -52,6 +53,7 @@ export function useControllerLibraryLayoutMotion({ selectedIndex, selectedMediaIndex, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength, itemsContainerRef, @@ -105,11 +107,13 @@ export function useControllerLibraryLayoutMotion({ useLayoutEffect(() => { const gamesRoot = topCategory === "all" && gameSubcategory === "root"; - if (!gamesRoot || !gamesDualShelf) { + const homeDualRoot = topCategory === "current" && homeDualShelf; + const dualShelfActive = (gamesRoot && gamesDualShelf) || homeDualRoot; + if (!dualShelfActive) { setSpotlightShelfTranslateX(0); setGamesRootMenuTranslateX(0); } - if (gamesRoot && gamesDualShelf) { + if (dualShelfActive) { setSpotlightShelfTranslateX(computeShelfTranslateXClamped(spotlightTrackRef.current, spotlightIndex)); setGamesRootMenuTranslateX(computeShelfTranslateXClamped(itemsContainerRef.current, topLevelShelfIndex)); setListTranslateY(0); @@ -144,6 +148,7 @@ export function useControllerLibraryLayoutMotion({ topCategory, gameSubcategory, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength, itemsContainerRef, @@ -166,7 +171,7 @@ export function useControllerLibraryLayoutMotion({ } as 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} ${topCategory === "settings" ? "xmb-ps5-section-settings" : ""} ${topCategory === "settings" && settingsSubcategory === "root" ? "xmb-ps5-settings-root" : ""} ${topCategory === "settings" && settingsSubcategory !== "root" ? "xmb-ps5-settings-sub" : ""}`; - const menuShelfTranslateX = gamesDualShelf ? gamesRootMenuTranslateX : listTranslateX; + const menuShelfTranslateX = gamesDualShelf || homeDualShelf ? gamesRootMenuTranslateX : listTranslateX; return { isEntering, From fae34dd19a4b63c39cee9a52f16703e8a16667f0 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 15:02:58 -0500 Subject: [PATCH 6/9] feat(microphone): enhance microphone management and UI feedback - Integrated live microphone track handling across components, allowing for real-time audio level monitoring during streaming sessions. - Updated the App component to pass the microphone track to relevant child components, improving audio control visibility. - Refactored StreamView and ControllerLibraryPage to utilize the new microphone track for enhanced user feedback on audio levels. - Improved the MicrophoneManager to manage microphone processing and gain settings more effectively, ensuring better audio quality during streams. These changes significantly enhance the user experience by providing clearer audio feedback and more control over microphone settings during streaming. --- opennow-stable/src/renderer/src/App.tsx | 1 + .../renderer/src/components/StreamView.tsx | 120 +--------------- .../controllerMode/ControllerLibraryPage.tsx | 3 + .../StreamMenuMicLevelField.tsx | 69 ++++++++++ .../controllerLibrary/TopLevelMenuTrack.tsx | 28 ++-- .../controllerMode/controllerLibrary/types.ts | 2 + .../src/renderer/src/gfn/microphoneManager.ts | 129 ++++++++++++++++-- .../src/renderer/src/gfn/webrtcClient.ts | 4 +- .../src/renderer/src/hooks/useMicMeter.ts | 121 ++++++++++++++++ 9 files changed, 329 insertions(+), 148 deletions(-) create mode 100644 opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx create mode 100644 opennow-stable/src/renderer/src/hooks/useMicMeter.ts diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 259201c78..2844a3e2a 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -4648,6 +4648,7 @@ export function App(): JSX.Element { onStreamMenuVolumeChange={handleStreamVolumeChange} streamMenuMicLevel={streamMicLevel} onStreamMenuMicLevelChange={handleStreamMicLevelChange} + streamMicTrack={clientRef.current?.getMicTrack() ?? null} onStreamMenuToggleMicrophone={handleToggleStreamMicrophone} onStreamMenuToggleFullscreen={() => { void toggleSessionFullscreen(); diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index a28ab4960..f3a82c9f6 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -11,6 +11,7 @@ import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import { RemainingPlaytimeIndicator, SessionElapsedIndicator } from "./ElapsedSessionIndicators"; import type { MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo } from "@shared/gfn"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; +import { useMicMeter } from "../hooks/useMicMeter"; const ANTI_AFK_TOGGLE_ACK_MS = 5000; @@ -532,121 +533,6 @@ function VideoFocusOnReady({ return null; } -function useMicMeter( - canvasRef: React.RefObject, - track: MediaStreamTrack | null, - active: boolean, -): void { - const pendingCloseRef = useRef | null>(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!active || !track || !canvas) return; - - const ctx2d = canvas.getContext("2d"); - if (!ctx2d) return; - - const dpr = window.devicePixelRatio || 1; - canvas.width = Math.round(canvas.clientWidth * dpr); - canvas.height = Math.round(canvas.clientHeight * dpr); - const W = canvas.width; - const H = canvas.height; - if (W <= 0 || H <= 0) { - return; - } - - let audioCtx: AudioContext | null = null; - let source: MediaStreamAudioSourceNode | null = null; - let analyser: AnalyserNode | null = null; - let tickTimer: number | null = null; - let dead = false; - - const start = async () => { - if (pendingCloseRef.current) { - try { - await pendingCloseRef.current; - } catch { - // Ignore close errors from previous contexts. - } - } - if (dead) { - return; - } - - try { - audioCtx = new AudioContext(); - await audioCtx.resume().catch(() => undefined); - if (dead) { - return; - } - - analyser = audioCtx.createAnalyser(); - analyser.fftSize = 256; - analyser.smoothingTimeConstant = 0.65; - source = audioCtx.createMediaStreamSource(new MediaStream([track])); - source.connect(analyser); - - const buf = new Uint8Array(analyser.frequencyBinCount); - const SEG = 20; - const GAP = Math.round(2 * dpr); - const bw = (W - GAP * (SEG - 1)) / SEG; - const radius = Math.min(3 * dpr, bw / 2); - const frameIntervalMs = 33; - - const frame = () => { - if (dead || !analyser) return; - tickTimer = window.setTimeout(frame, frameIntervalMs); - analyser.getByteTimeDomainData(buf); - - let sum = 0; - for (let i = 0; i < buf.length; i++) { - const v = ((buf[i] ?? 128) - 128) / 128; - sum += v * v; - } - const rms = Math.sqrt(sum / buf.length); - const level = Math.min(1, rms * 5.5); - const filled = Math.round(level * SEG); - - ctx2d.clearRect(0, 0, W, H); - for (let i = 0; i < SEG; i++) { - const x = i * (bw + GAP); - if (i < filled) { - ctx2d.fillStyle = - i < SEG * 0.7 ? "#58d98a" : i < SEG * 0.9 ? "#fbbf24" : "#f87171"; - } else { - ctx2d.fillStyle = "rgba(255,255,255,0.07)"; - } - ctx2d.beginPath(); - ctx2d.roundRect(x, 0, Math.max(1, bw), H, radius); - ctx2d.fill(); - } - }; - - frame(); - } catch (e) { - console.warn("[MicMeter]", e); - } - }; - - void start(); - - return () => { - dead = true; - if (tickTimer !== null) { - window.clearTimeout(tickTimer); - } - source?.disconnect(); - analyser?.disconnect(); - if (audioCtx && audioCtx.state !== "closed") { - pendingCloseRef.current = audioCtx - .close() - .catch(() => undefined) - .then(() => undefined); - } - }; - }, [track, active, canvasRef]); -} - export function StreamView({ videoRef, audioRef, @@ -1641,13 +1527,13 @@ export function StreamView({ {microphoneMode !== "disabled" && (
- Input Level + Send level
{!micTrack && Mic not active — check mode and permissions.}
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 3c8378eb1..97caaff4b 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -93,6 +93,7 @@ export function ControllerLibraryPage({ onStreamMenuVolumeChange, streamMenuMicLevel = 1, onStreamMenuMicLevelChange, + streamMicTrack = null, onStreamMenuToggleMicrophone, onStreamMenuToggleFullscreen, streamMenuMicOn = false, @@ -1025,6 +1026,7 @@ export function ControllerLibraryPage({ onStreamMenuVolumeChange={onStreamMenuVolumeChange} editingStreamVolume={editingStreamVolume} controllerType={controllerType} + streamMicTrack={streamMicTrack} /> ), [ topCategory, @@ -1052,6 +1054,7 @@ export function ControllerLibraryPage({ editingStreamVolume, editingStreamMicLevel, controllerType, + streamMicTrack, ]); return ( diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx new file mode 100644 index 000000000..70778a31c --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx @@ -0,0 +1,69 @@ +import type { JSX } from "react"; +import { useRef } from "react"; +import { useMicMeter } from "../../../hooks/useMicMeter"; + +export function StreamMenuMicLevelField({ + streamMenuMicLevel, + onStreamMenuMicLevelChange, + editingStreamMicLevel, + isRowSelected, + micTrack, + controllerType, +}: { + streamMenuMicLevel?: number; + onStreamMenuMicLevelChange?: (value: number) => void; + editingStreamMicLevel: boolean; + isRowSelected: boolean; + micTrack?: MediaStreamTrack | null; + controllerType: "ps" | "xbox" | "nintendo" | "generic"; +}): JSX.Element { + const canvasRef = useRef(null); + const trackLive = Boolean(micTrack && micTrack.readyState === "live"); + const meterActive = trackLive && (editingStreamMicLevel || isRowSelected); + useMicMeter(canvasRef, micTrack ?? null, meterActive); + + return ( +
+
+ onStreamMenuMicLevelChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100)))} + aria-label="Microphone level" + style={editingStreamMicLevel ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + + {`${Math.round((streamMenuMicLevel ?? 1) * 100)}%`} + {editingStreamMicLevel ? " • Editing ←/→" : controllerType === "ps" ? " • □ to adjust" : " • X to adjust"} + +
+
+ + Send level (what others hear) + + + {!trackLive ? ( + + No send audio — unmute mic or check permissions to test + + ) : null} +
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx index 4e3b270d2..626d9a575 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx @@ -1,6 +1,7 @@ import type { JSX, RefObject } from "react"; import { PREVIEW_TILE_COUNT, SHELF_IMAGE_PROPS } from "./constants"; import { clampRgbByte } from "./helpers"; +import { StreamMenuMicLevelField } from "./StreamMenuMicLevelField"; interface TopLevelMenuTrackProps { itemsContainerRef: RefObject; @@ -27,6 +28,8 @@ interface TopLevelMenuTrackProps { onStreamMenuVolumeChange?: ((value: number) => void) | undefined; editingStreamVolume: boolean; controllerType: "ps" | "xbox" | "nintendo" | "generic"; + /** Live capture track while streaming; drives the mic test meter on the Mic level row. */ + streamMicTrack?: MediaStreamTrack | null; } export function TopLevelMenuTrack({ @@ -53,6 +56,7 @@ export function TopLevelMenuTrack({ onStreamMenuVolumeChange, editingStreamVolume, controllerType, + streamMicTrack = null, }: TopLevelMenuTrackProps): JSX.Element { return (
) : item.id === "streamMicLevel" && inStreamMenu ? ( -
- onStreamMenuMicLevelChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100)))} - aria-label="Microphone level" - style={editingStreamMicLevel ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} - /> - - {`${Math.round((streamMenuMicLevel ?? 1) * 100)}%`} - {editingStreamMicLevel ? " • Editing ←/→" : controllerType === "ps" ? " • □ to adjust" : " • X to adjust"} - -
+ ) : item.id === "streamVolume" && inStreamMenu ? (
void; streamMenuMicLevel?: number; onStreamMenuMicLevelChange?: (level01: number) => void; + /** Live mic track from the streaming client; used for the in-stream mic level test meter. */ + streamMicTrack?: MediaStreamTrack | null; onStreamMenuToggleMicrophone?: () => void; onStreamMenuToggleFullscreen?: () => void; streamMenuMicOn?: boolean; diff --git a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts index 7131beb38..451399f03 100644 --- a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts +++ b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts @@ -29,10 +29,17 @@ export class MicrophoneManager { private onStateChangeCallback: ((state: MicStateChange) => void) | null = null; private sampleRate: number = 48000; // Official client uses 48kHz + /** Web Audio graph: raw getUserMedia → GainNode → track sent to WebRTC (`volume` constraints are rarely supported for input). */ + private micProcessCtx: AudioContext | null = null; + private micMediaSource: MediaStreamAudioSourceNode | null = null; + private micGainNode: GainNode | null = null; + private outboundMicTrack: MediaStreamTrack | null = null; + // Track if we should auto-retry with different devices on failure private attemptedDevices: Set = new Set(); private readonly handleMicStreamInactive = (): void => { console.log("[Microphone] Stream inactive"); + this.tearDownMicProcessing(); this.detachMicStreamListeners(this.micStream); this.attemptedDevices.clear(); this.micStream = null; @@ -94,12 +101,24 @@ export class MicrophoneManager { if (!this.pc) { return; } - const track = this.micStream?.getAudioTracks()[0]; - if (!track) { - await this.ensurePlaceholderSender(); + if (this.outboundMicTrack && this.outboundMicTrack.readyState === "live") { + await this.addTrackToPeerConnection(this.outboundMicTrack); return; } - await this.addTrackToPeerConnection(track); + if (this.micStream) { + const out = this.buildMicProcessingPipeline(this.micStream); + if (out) { + await this.addTrackToPeerConnection(out); + return; + } + const raw = this.micStream.getAudioTracks()[0]; + if (raw) { + await this.addTrackToPeerConnection(raw); + void this.applyTrackMicLevel(raw); + return; + } + } + await this.ensurePlaceholderSender(); } /** @@ -199,11 +218,15 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; - await this.applyTrackMicLevel(track); - // Add track to peer connection if available + const outbound = this.buildMicProcessingPipeline(stream); + const sendTrack = outbound ?? track; + if (!outbound) { + void this.applyTrackMicLevel(track); + } + if (this.pc) { - await this.addTrackToPeerConnection(track); + await this.addTrackToPeerConnection(sendTrack); } this.setState("started", track.label); @@ -269,9 +292,13 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; - await this.applyTrackMicLevel(track); - if (this.pc && track) { - await this.addTrackToPeerConnection(track); + const outbound = this.buildMicProcessingPipeline(stream); + const sendTrack = outbound ?? track; + if (!outbound) { + void this.applyTrackMicLevel(track); + } + if (this.pc && sendTrack) { + await this.addTrackToPeerConnection(sendTrack); } this.setState("started", track?.label); return; @@ -404,7 +431,73 @@ export class MicrophoneManager { } } + private tearDownMicProcessing(): void { + try { + this.micMediaSource?.disconnect(); + this.micMediaSource = null; + this.micGainNode?.disconnect(); + this.micGainNode = null; + this.outboundMicTrack = null; + const ctx = this.micProcessCtx; + this.micProcessCtx = null; + if (ctx && ctx.state !== "closed") { + void ctx.close(); + } + } catch { + // ignore + } + } + + /** + * Build mic → GainNode → MediaStreamDestination; {@link outboundMicTrack} is what we attach to WebRTC. + * Raw {@link micStream} remains for mute state and {@link getTrack} (UI meter). + */ + private buildMicProcessingPipeline(rawStream: MediaStream): MediaStreamTrack | null { + this.tearDownMicProcessing(); + if (!rawStream.getAudioTracks()[0]) { + return null; + } + + const AudioCtx = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioCtx) { + return null; + } + + try { + let ctx: AudioContext; + try { + ctx = new AudioCtx({ sampleRate: this.sampleRate }); + } catch { + ctx = new AudioCtx(); + } + void ctx.resume().catch(() => undefined); + + const src = ctx.createMediaStreamSource(rawStream); + const gain = ctx.createGain(); + gain.gain.value = this.micLevel; + const dest = ctx.createMediaStreamDestination(); + src.connect(gain).connect(dest); + + const out = dest.stream.getAudioTracks()[0] ?? null; + if (!out) { + void ctx.close(); + return null; + } + + this.micProcessCtx = ctx; + this.micMediaSource = src; + this.micGainNode = gain; + this.outboundMicTrack = out; + return out; + } catch (e) { + console.warn("[Microphone] Web Audio gain pipeline failed:", e); + this.tearDownMicProcessing(); + return null; + } + } + private clearMicStream(): void { + this.tearDownMicProcessing(); if (!this.micStream) { return; } @@ -440,9 +533,14 @@ export class MicrophoneManager { setMicLevel(level01: number): void { this.micLevel = Math.max(0, Math.min(1, Number.isFinite(level01) ? level01 : 1)); + if (this.micGainNode) { + this.micGainNode.gain.value = this.micLevel; + return; + } const track = this.micStream?.getAudioTracks()[0] ?? null; - if (!track) return; - void this.applyTrackMicLevel(track); + if (track) { + void this.applyTrackMicLevel(track); + } } getMicLevel(): number { @@ -499,9 +597,14 @@ export class MicrophoneManager { } /** - * Get active microphone track if available + * MediaStreamTrack suitable for metering and local monitoring: post-gain audio that matches + * what is attached to the WebRTC sender when the processing pipeline is active; otherwise + * the raw capture track (fallback when Web Audio routing failed). */ getTrack(): MediaStreamTrack | null { + if (this.outboundMicTrack && this.outboundMicTrack.readyState === "live") { + return this.outboundMicTrack; + } return this.micStream?.getAudioTracks()[0] ?? null; } diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 9bd8f38ef..34fb1caff 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -4348,8 +4348,8 @@ export class GfnWebRtcClient { } /** - * Return the live audio track from the microphone stream, or null if - * the mic has not been started or has been stopped. + * Live audio track for UI metering / local recording mix: post-gain send path when available + * (same levels the remote session hears), else raw capture. */ getMicTrack(): MediaStreamTrack | null { return this.micManager?.getTrack() ?? null; diff --git a/opennow-stable/src/renderer/src/hooks/useMicMeter.ts b/opennow-stable/src/renderer/src/hooks/useMicMeter.ts new file mode 100644 index 000000000..eafd5e531 --- /dev/null +++ b/opennow-stable/src/renderer/src/hooks/useMicMeter.ts @@ -0,0 +1,121 @@ +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; + +/** + * Draws a segmented RMS level meter for a live microphone track (time-domain). + * Shared by stream sidebar and controller in-stream mic level row. + */ +export function useMicMeter( + canvasRef: RefObject, + track: MediaStreamTrack | null, + active: boolean, +): void { + const pendingCloseRef = useRef | null>(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!active || !track || !canvas) return; + + const ctx2d = canvas.getContext("2d"); + if (!ctx2d) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + const W = canvas.width; + const H = canvas.height; + if (W <= 0 || H <= 0) { + return; + } + + let audioCtx: AudioContext | null = null; + let source: MediaStreamAudioSourceNode | null = null; + let analyser: AnalyserNode | null = null; + let tickTimer: number | null = null; + let dead = false; + + const start = async () => { + if (pendingCloseRef.current) { + try { + await pendingCloseRef.current; + } catch { + // Ignore close errors from previous contexts. + } + } + if (dead) { + return; + } + + try { + audioCtx = new AudioContext(); + await audioCtx.resume().catch(() => undefined); + if (dead) { + return; + } + + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.65; + source = audioCtx.createMediaStreamSource(new MediaStream([track])); + source.connect(analyser); + + const buf = new Uint8Array(analyser.frequencyBinCount); + const SEG = 20; + const GAP = Math.round(2 * dpr); + const bw = (W - GAP * (SEG - 1)) / SEG; + const radius = Math.min(3 * dpr, bw / 2); + const frameIntervalMs = 33; + + const frame = () => { + if (dead || !analyser) return; + tickTimer = window.setTimeout(frame, frameIntervalMs); + analyser.getByteTimeDomainData(buf); + + let sum = 0; + for (let i = 0; i < buf.length; i++) { + const v = ((buf[i] ?? 128) - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / buf.length); + const level = Math.min(1, rms * 5.5); + const filled = Math.round(level * SEG); + + ctx2d.clearRect(0, 0, W, H); + for (let i = 0; i < SEG; i++) { + const x = i * (bw + GAP); + if (i < filled) { + ctx2d.fillStyle = + i < SEG * 0.7 ? "#58d98a" : i < SEG * 0.9 ? "#fbbf24" : "#f87171"; + } else { + ctx2d.fillStyle = "rgba(255,255,255,0.07)"; + } + ctx2d.beginPath(); + ctx2d.roundRect(x, 0, Math.max(1, bw), H, radius); + ctx2d.fill(); + } + }; + + frame(); + } catch (e) { + console.warn("[MicMeter]", e); + } + }; + + void start(); + + return () => { + dead = true; + if (tickTimer !== null) { + window.clearTimeout(tickTimer); + } + source?.disconnect(); + analyser?.disconnect(); + if (audioCtx && audioCtx.state !== "closed") { + pendingCloseRef.current = audioCtx + .close() + .catch(() => undefined) + .then(() => undefined); + } + }; + }, [track, active, canvasRef]); +} From e21175f82ca8be13a5825bfcf9464c3b35b36450 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 17:41:28 -0500 Subject: [PATCH 7/9] feat(controller): enhance controller badge visibility and CSS transitions - Added state management for badge visibility in the ControllerIndicator component, allowing it to hide after 5 seconds of inactivity when gamepads are connected. - Introduced a new CSS class to manage hidden state with opacity and pointer events, improving user experience during controller interactions. - Updated styles for the game card store chip to include a transition effect for smoother visual feedback. These changes improve the responsiveness and visual clarity of the controller indicator, enhancing user engagement during gameplay. --- .../renderer/src/components/StreamView.tsx | 30 ++++++++++++++++++- opennow-stable/src/renderer/src/styles.css | 6 ++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index f3a82c9f6..f9672cf59 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -297,13 +297,41 @@ function ControllerIndicator({ diagnosticsStore, (stats) => stats.connectedGamepads, ); + const [badgeVisible, setBadgeVisible] = useState(true); + const hideTimerRef = useRef(null); + + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + if (connectedGamepads > 0) { + setBadgeVisible(true); + hideTimerRef.current = window.setTimeout(() => { + setBadgeVisible(false); + hideTimerRef.current = null; + }, 5000); + } else { + setBadgeVisible(true); + } + return () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [connectedGamepads]); if (isConnecting || connectedGamepads <= 0) { return null; } return ( -
+
{connectedGamepads > 1 && {connectedGamepads}}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 2d8f93ec5..17e609bd9 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -5028,6 +5028,12 @@ button.game-card-store-chip.owned.active:hover { border-radius: var(--r-md); z-index: 1001; color: var(--accent); + transition: opacity 0.4s var(--ease); +} + +.sv-ctrl--hidden { + opacity: 0; + pointer-events: none; } .sv-ctrl-n { From b5d5045447c20411aed9af160166b20d860d76d9 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 17:47:09 -0500 Subject: [PATCH 8/9] feat(controller-library): add game backdrop setting and enhance library visuals - Introduced a new setting for controllerLibraryGameBackdrop, allowing backdrop art to be displayed based on the focused game or shelf. - Updated the ControllerLibraryPage and related components to utilize the new backdrop setting, improving visual engagement. - Enhanced the ControllerGameHub and FooterHints components to conditionally render elements based on the backdrop setting. - Refactored various components to support the new gamesDualShelf logic, ensuring a consistent user experience across the library interface. These changes significantly enhance the visual presentation and user interaction within the controller library, providing a more immersive experience for users. --- opennow-stable/src/main/settings.ts | 3 + opennow-stable/src/renderer/src/App.tsx | 3 + .../controllerMode/ControllerGameHub.tsx | 5 +- .../controllerMode/ControllerLibraryPage.tsx | 55 +++++++++++-------- .../ControllerLibraryLayout.tsx | 6 +- .../controllerLibrary/FooterHints.tsx | 9 ++- .../TopLevelShelfSection.tsx | 2 +- .../controllerLibrary/actions/contracts.ts | 3 + .../actions/optionsActions.ts | 9 ++- .../actions/settingsActions.ts | 8 +++ .../events/actionHandlers.ts | 2 + .../controllerMode/controllerLibrary/types.ts | 1 + opennow-stable/src/shared/gfn.ts | 5 ++ 13 files changed, 82 insertions(+), 29 deletions(-) diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index f3a0cab17..20c17ddc5 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -83,6 +83,8 @@ export interface Settings { controllerThemeStyle: ControllerThemeStyle; /** Controller-mode library background tint */ controllerThemeColor: ControllerThemeRgb; + /** When true, library/hub/loading may show game- or shelf-driven backdrop art */ + controllerLibraryGameBackdrop: boolean; /** Auto-load controller library at startup when controller mode is enabled */ autoLoadControllerLibrary: boolean; /** Automatically enter fullscreen when controller-mode triggers it */ @@ -172,6 +174,7 @@ const DEFAULT_SETTINGS: Settings = { controllerBackgroundAnimations: false, controllerThemeStyle: "aurora", controllerThemeColor: { r: 124, g: 241, b: 177 }, + controllerLibraryGameBackdrop: true, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 2844a3e2a..a05fe3fd0 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -922,6 +922,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: false, controllerThemeStyle: "aurora", controllerThemeColor: { r: 124, g: 241, b: 177 }, + controllerLibraryGameBackdrop: true, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], @@ -4685,6 +4686,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: settings.controllerBackgroundAnimations, controllerThemeStyle: settings.controllerThemeStyle, controllerThemeColor: settings.controllerThemeColor, + controllerLibraryGameBackdrop: settings.controllerLibraryGameBackdrop, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, @@ -4861,6 +4863,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: settings.controllerBackgroundAnimations, controllerThemeStyle: settings.controllerThemeStyle, controllerThemeColor: settings.controllerThemeColor, + controllerLibraryGameBackdrop: settings.controllerLibraryGameBackdrop, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx index fc68e7f86..1c40decb8 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx @@ -29,6 +29,8 @@ export interface ControllerGameHubProps { focusIndex: number; /** In-stream overlay: copy emphasizes switching away from the active title */ inStreamMenu?: boolean; + /** When false, omit the full-bleed blurred hero; poster row and copy remain */ + showHeroBackdrop?: boolean; } export function ControllerGameHub({ @@ -41,6 +43,7 @@ export function ControllerGameHub({ tiles, focusIndex, inStreamMenu = false, + showHeroBackdrop = true, }: ControllerGameHubProps): JSX.Element { const record = playtimeData[game.id]; const totalSecs = record?.totalSeconds ?? 0; @@ -65,7 +68,7 @@ export function ControllerGameHub({ return (
- {heroBackdropUrl ? ( + {showHeroBackdrop && heroBackdropUrl ? (
) : null}
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 97caaff4b..fc6069b3a 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -131,7 +131,7 @@ export function ControllerLibraryPage({ 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 [gamesRootPlane, setGamesRootPlane] = useState<"spotlight" | "categories">("categories"); const [homeRootPlane, setHomeRootPlane] = useState("spotlight"); const [spotlightIndex, setSpotlightIndex] = useState(0); const [optionsOpen, setOptionsOpen] = useState(false); @@ -437,6 +437,11 @@ export function ControllerLibraryPage({ Theme: [ { id: "themeColor", label: "Color", value: `RGB ${themeRgb.r}, ${themeRgb.g}, ${themeRgb.b}` }, { id: "themeStyle", label: "Style", value: CONTROLLER_THEME_STYLE_LABEL[themeStyleResolved] }, + { + id: "libraryGameBackdrop", + label: "Match background to game", + value: (settings.controllerLibraryGameBackdrop !== false) ? "On" : "Off", + }, ], ThemeColor: [ { id: "themeR", label: "Red", value: `${themeRgb.r}` }, @@ -487,17 +492,6 @@ export function ControllerLibraryPage({ 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 gamesHubDisplayGame = useMemo((): GameInfo | null => { if (!gamesHubOpen) return null; if (topCategory === "current") { @@ -559,10 +553,8 @@ export function ControllerLibraryPage({ topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); - const gamesDualShelf = - topCategory === "all" && - gameSubcategory === "root" && - (games.length > 0 || Boolean(cloudSessionResumable && onResumeCloudSession)); + /** Games root: category row only (no Recently played / spotlight strip; that lives on Home). */ + const gamesDualShelf = false; const homeDualShelf = topCategory === "current" && !inStreamMenu && spotlightEntries.length > 0; @@ -602,7 +594,7 @@ export function ControllerLibraryPage({ if (spotlightEntryHasGame(entry)) return entry.game.title; return "Recently played"; } - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return entry.title; if (spotlightEntryHasGame(entry)) return entry.game.title; @@ -623,6 +615,7 @@ export function ControllerLibraryPage({ gameSubcategory, mediaSubcategory, gamesRootPlane, + gamesDualShelf, homeDualShelf, homeRootPlane, spotlightEntries, @@ -683,7 +676,7 @@ export function ControllerLibraryPage({ if (spotlightEntryHasGame(entry)) return `home-spotlight-${entry.game.id}`; return `home-spotlight-empty-${spotlightIndex}`; } - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return `spotlight-resume-${entry.busy ? "busy" : "idle"}`; if (spotlightEntryHasGame(entry)) return `spotlight-${entry.game.id}`; @@ -702,6 +695,7 @@ export function ControllerLibraryPage({ gamesHubDisplayGame?.id, spotlightEntries, spotlightIndex, + gamesDualShelf, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, @@ -739,7 +733,7 @@ export function ControllerLibraryPage({ }); useEffect(() => { if (topCategory !== "all") { - setGamesRootPlane("spotlight"); + setGamesRootPlane("categories"); setSpotlightIndex(0); } }, [topCategory]); @@ -949,11 +943,19 @@ export function ControllerLibraryPage({ : ; }; - const heroBackdropUrl = useMemo(() => { + const libraryGameBackdropOn = settings.controllerLibraryGameBackdrop !== false; + + const heroBackdropUrlRaw = useMemo(() => { if (topCategory === "current" && gamesHubOpen && gamesHubDisplayGame?.imageUrl) { return gamesHubDisplayGame.imageUrl; } - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntries.length > 0) { + if ( + topCategory === "all" && + gameSubcategory === "root" && + gamesDualShelf && + 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; @@ -990,6 +992,7 @@ export function ControllerLibraryPage({ homeRootPlane, gamesHubOpen, gamesHubDisplayGame, + gamesDualShelf, spotlightEntries, spotlightIndex, selectedGame, @@ -998,6 +1001,12 @@ export function ControllerLibraryPage({ mediaThumbById, ]); + const heroBackdropUrl = useMemo(() => { + if (libraryGameBackdropOn) return heroBackdropUrlRaw; + if (topCategory === "media") return heroBackdropUrlRaw; + return null; + }, [libraryGameBackdropOn, topCategory, heroBackdropUrlRaw]); + const themeRgbForTrack = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; const maxBitrateMbpsForTrack = settings.maxBitrateMbps ?? 75; @@ -1069,8 +1078,10 @@ export function ControllerLibraryPage({ currentTabGame={currentTabGame} inStreamMenu={inStreamMenu} endSessionConfirm={endSessionConfirm} - parallaxBackdropTiles={parallaxBackdropTiles} + parallaxBackdropTiles={libraryGameBackdropOn ? parallaxBackdropTiles : []} heroBackdropUrl={heroBackdropUrl} + loadingBackdropImageUrl={libraryGameBackdropOn ? currentTabGame?.imageUrl ?? null : null} + gameHubShowHeroBackdrop={libraryGameBackdropOn} settings={settings} subscriptionInfo={subscriptionInfo} sessionStartedAtMs={sessionStartedAtMs} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx index e13e05468..0b482da0d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx @@ -23,6 +23,8 @@ export function ControllerLibraryLayout(props: Record): JSX.Element endSessionConfirm, parallaxBackdropTiles, heroBackdropUrl, + loadingBackdropImageUrl, + gameHubShowHeroBackdrop = true, settings, subscriptionInfo, sessionStartedAtMs, @@ -98,7 +100,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element
); @@ -178,6 +180,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element tiles={gamesHubTiles} focusIndex={gamesHubFocusIndex} inStreamMenu={inStreamMenu} + showHeroBackdrop={gameHubShowHeroBackdrop} /> ) : null} @@ -265,6 +268,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element gameSubcategory={gameSubcategory} gamesHubOpen={gamesHubOpen} gamesRootPlane={gamesRootPlane} + gamesDualShelf={Boolean(gamesDualShelf)} homeRootPlane={homeRootPlane ?? "spotlight"} homeDualShelf={Boolean(homeDualShelf)} spotlightEntries={spotlightEntries} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx index c059e0ea7..0389efe20 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx @@ -14,6 +14,8 @@ interface FooterHintsProps { gameSubcategory: GameSubcategory; gamesHubOpen: boolean; gamesRootPlane: "spotlight" | "categories"; + /** Games tab spotlight row; when false, root Games uses categories row only. */ + gamesDualShelf?: boolean; homeRootPlane?: HomeRootPlane; homeDualShelf?: boolean; spotlightEntries: SpotlightEntry[]; @@ -34,6 +36,7 @@ export function FooterHints({ gameSubcategory, gamesHubOpen, gamesRootPlane, + gamesDualShelf = true, homeRootPlane = "spotlight", homeDualShelf = false, spotlightEntries, @@ -256,16 +259,16 @@ export function FooterHints({ )} - {gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + {gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" ? spotlightEntries[spotlightIndex].busy ? "Please wait" : "Resume session" - : gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + : gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? "View in library" : "Enter"}
- {gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? ( + {gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? (
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
) : null}
L1 Prev Section
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx index e811b655b..978109514 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx @@ -75,7 +75,7 @@ export function TopLevelShelfSection({

{selectedTopLevelItemLabel}

- {topCategory === "all" && gameSubcategory === "root" && gamesSpotlightPlane ? ( + {topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesSpotlightPlane ? (

{(() => { const se = spotlightEntries[spotlightIndex]; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts index 1dc5fff11..178ca359e 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts @@ -20,6 +20,8 @@ export interface OptionsActionContext { topCategory: TopCategory; gameSubcategory: GameSubcategory; gamesRootPlane: "spotlight" | "categories"; + /** When false, Games root has no spotlight row. */ + gamesDualShelf?: boolean; spotlightEntries: SpotlightEntry[]; spotlightIndex: number; selectedMediaIndex: number; @@ -174,4 +176,5 @@ export type ControllerLibrarySettings = { maxBitrateMbps?: number; controllerThemeStyle?: ControllerThemeStyle; controllerThemeColor?: { r: number; g: number; b: number }; + controllerLibraryGameBackdrop?: boolean; }; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts index a646b40ff..de3b2fa19 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts @@ -15,6 +15,7 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { topCategory, gameSubcategory, gamesRootPlane, + gamesDualShelf = true, spotlightEntries, spotlightIndex, spotlightEntryHasGame, @@ -56,7 +57,13 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { entries.push({ id: "mediaDelete", label: "Delete File" }); entries.push({ id: "mediaRegenThumb", label: "Regen Thumbnail" }); } - } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex])) { + } else if ( + topCategory === "all" && + gameSubcategory === "root" && + gamesDualShelf && + gamesRootPlane === "spotlight" && + spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ) { entries.push({ id: "openLibrary", label: "View in library" }); } if (entries.length === 0) return false; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts index fccd0bd23..de650cc01 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts @@ -57,6 +57,14 @@ export function handleSettingsActivateAction(context: SettingsActivateContext): } if (settingsSubcategory === "Theme") { const item = displayItems[selectedSettingIndex]; + if (item?.id === "libraryGameBackdrop" && onSettingChange) { + onSettingChange( + "controllerLibraryGameBackdrop" as never, + !(settings.controllerLibraryGameBackdrop !== false) as never, + ); + playUiSound("confirm"); + return true; + } if (item?.id === "themeColor") { setLastThemeRootIndex(selectedSettingIndex); setSettingsSubcategory("ThemeColor"); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts index 9022143e0..bde30fd6d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts @@ -125,6 +125,7 @@ export function createActionHandlers( setSpotlightIndex, featuredHomeGame, homeDualShelf, + gamesDualShelf, homeRootPlane, localVideoPlayerOpen, closeLocalVideoPlayer, @@ -140,6 +141,7 @@ export function createActionHandlers( topCategory, gameSubcategory, gamesRootPlane, + gamesDualShelf, spotlightEntries, spotlightIndex, selectedMediaIndex, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts index 1f080bee3..9cefb47ce 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts @@ -37,6 +37,7 @@ export interface ControllerLibraryPageProps { maxBitrateMbps?: number; controllerThemeStyle?: ControllerThemeStyle; controllerThemeColor?: { r: number; g: number; b: number }; + controllerLibraryGameBackdrop?: boolean; }; resolutionOptions?: string[]; fpsOptions?: number[]; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 058e94ea0..7f3fda76d 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -173,6 +173,11 @@ export interface Settings { controllerThemeStyle: ControllerThemeStyle; /** Controller-mode library background tint (applied per style preset) */ controllerThemeColor: ControllerThemeRgb; + /** + * When true, controller library/hub/loading layers may show art from the focused game or shelf. + * Theme color/style presets still apply when false. + */ + controllerLibraryGameBackdrop: boolean; /** When true, the app will automatically enter fullscreen when controller mode triggers it */ autoFullScreen: boolean; favoriteGameIds: string[]; From 28f0a8d0ac305643765717d6c92c70e80afd4ee5 Mon Sep 17 00:00:00 2001 From: Jared Date: Sun, 3 May 2026 17:50:41 -0500 Subject: [PATCH 9/9] refactor(TopLevelShelfSection): simplify recently played label - Updated the label for recently played titles in the TopLevelShelfSection component to enhance clarity and conciseness. - Removed redundant text to streamline the user experience when displaying recently played games. These changes improve the readability of the interface and provide a cleaner presentation of game titles. --- .../controllerMode/controllerLibrary/TopLevelShelfSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx index 978109514..0ddf497f3 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelShelfSection.tsx @@ -85,7 +85,7 @@ export function TopLevelShelfSection({ : "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"; } return "Recently played · Empty slot — play games to fill your shelf"; })()} @@ -101,7 +101,7 @@ export function TopLevelShelfSection({ : "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"; } return "Recently played · Empty slot — play games to fill your shelf"; })()}