From a13dcf976f9724c0f8fc82ca5e73973bac3d875d Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Thu, 9 Jul 2026 10:21:23 -0500 Subject: [PATCH 1/4] Add mouse fallback to controller bottom hints --- .../src/renderer/src/components/HomePage.tsx | 24 +++++++++++++++--- .../renderer/src/components/LibraryPage.tsx | 25 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/HomePage.tsx b/opennow-stable/src/renderer/src/components/HomePage.tsx index f52ed9871..f35c35a7e 100644 --- a/opennow-stable/src/renderer/src/components/HomePage.tsx +++ b/opennow-stable/src/renderer/src/components/HomePage.tsx @@ -651,10 +651,26 @@ export const HomePage = memo(function HomePage({ diff --git a/opennow-stable/src/renderer/src/components/LibraryPage.tsx b/opennow-stable/src/renderer/src/components/LibraryPage.tsx index a7954271e..c78895522 100644 --- a/opennow-stable/src/renderer/src/components/LibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/LibraryPage.tsx @@ -955,11 +955,26 @@ export const LibraryPage = memo(function LibraryPage({ From b2098a98a56030aa56c0241206a64b45eefb60ea Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Thu, 9 Jul 2026 14:33:14 -0500 Subject: [PATCH 2/4] controller: wire bottom-hint buttons + controller mode overlays --- .../src/renderer/src/components/HomePage.tsx | 40 +- .../renderer/src/components/LibraryPage.tsx | 2490 +++++++++-------- 2 files changed, 1278 insertions(+), 1252 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/HomePage.tsx b/opennow-stable/src/renderer/src/components/HomePage.tsx index f35c35a7e..1853ad0e1 100644 --- a/opennow-stable/src/renderer/src/components/HomePage.tsx +++ b/opennow-stable/src/renderer/src/components/HomePage.tsx @@ -461,7 +461,7 @@ export const HomePage = memo(function HomePage({ if (pressed & controllerButton.west) setControllerSearchOpen(true); if (pressed & controllerButton.leftShoulder) onPreviousControllerPage?.(); if (pressed & controllerButton.rightShoulder) onNextControllerPage?.(); - if (pressed & controllerButton.menu) cycleControllerVariant(); + if (pressed & controllerButton.menu) onNextControllerPage?.(); if (pressed & controllerButton.up) focusControllerTile(rowIndex - 1, columnIndex); if (pressed & controllerButton.down) focusControllerTile(rowIndex + 1, columnIndex); if (pressed & controllerButton.left) focusControllerTile(rowIndex, columnIndex - 1); @@ -655,7 +655,7 @@ export const HomePage = memo(function HomePage({ A {t("app.actions.select")} - @@ -667,10 +667,10 @@ export const HomePage = memo(function HomePage({ X {t("app.actions.search")} - + @@ -693,18 +693,22 @@ export const HomePage = memo(function HomePage({ transition={panelSpring} > {t("app.actions.search")} - onSearchChange(event.target.value)} - placeholder={t("home.searchPlaceholder")} - className="controller-search-input" - /> -

{t("app.actions.back")}

- - - )} + onSearchChange(event.target.value)} + placeholder={t("home.searchPlaceholder")} + className="controller-search-input" + /> +
+ +
+ + + )}
)} diff --git a/opennow-stable/src/renderer/src/components/LibraryPage.tsx b/opennow-stable/src/renderer/src/components/LibraryPage.tsx index c78895522..67cbd6469 100644 --- a/opennow-stable/src/renderer/src/components/LibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/LibraryPage.tsx @@ -1,1234 +1,1256 @@ -import { Library, Search, Clock, Gamepad2, Loader2, ArrowUpDown, MoreHorizontal, Menu, Filter, ChevronDown, X } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; -import type { JSX } from "react"; -import { AnimatePresence, m } from "motion/react"; -import type { CatalogSortOption, GameInfo } from "@shared/gfn"; -import { getStoreDisplayName, getStoreIconComponent, normalizeStoreKey } from "./GameCard"; -import { GameCardListItem, useCatalogCardActionsRef } from "./GameCardListItem"; -import type { PlaytimeData } from "../lib/gameCatalog"; -import { useTranslation } from "../i18n"; -import { formatCatalogLastPlayed } from "../utils/lastPlayedFormat"; -import { controllerButton, readControllerGamepadButtons } from "../utils/controllerGamepad"; -import { pageTransition, panelSpring } from "./MotionProvider"; -import { SelectDropdown } from "./ui/SelectDropdown"; - -const CONTROLLER_HERO_ROTATION_MS = 8000; -const CONTROLLER_MOVE_REPEAT_MS = 140; -const CONTROLLER_Y_HOLD_MS = 350; - -const CONTROLLER_HERO_BACKGROUND_KEYS = [ - "MARQUEE_HERO_IMAGE", - "FEATURE_IMAGE", - "HERO_IMAGE", - "TV_BANNER", - "KEY_ART", - "KEY_IMAGE", -] as const; - -interface ControllerStoreFilterItem { - id: string; - title: string; -} - -interface LibraryFilterOption { - id: string; - label: string; - count: number; -} - -interface LibraryFilterGroup { - id: string; - label: string; - options: LibraryFilterOption[]; -} - -type LibraryTranslation = (key: string, values?: Record) => string; - -export interface LibraryPageProps { - games: GameInfo[]; - allGames: GameInfo[]; - playtimeData: PlaytimeData; - searchQuery: string; - onSearchChange: (query: string) => void; - onPlayGame: (game: GameInfo) => void; - onBuyGame?: (game: GameInfo, selectedVariantId?: string) => void; - isLoading: boolean; - selectedGameId: string; - onSelectGame: (id: string) => void; - selectedVariantByGameId: Record; - onSelectGameVariant: (gameId: string, variantId: string) => void; - libraryCount: number; - sortOptions: CatalogSortOption[]; - selectedSortId: string; - onSortChange: (sortId: string) => void; - controllerMode?: boolean; - featuredGames?: GameInfo[]; - activeSessionAppIds?: number[]; - onPreviousControllerPage?: () => void; - onNextControllerPage?: () => void; -} - -function appendUnique(values: string[], candidate: string | undefined): void { - if (!candidate || values.includes(candidate)) return; - values.push(candidate); -} - -function appendImageType(values: string[], game: GameInfo, type: string): void { - for (const candidate of game.imageUrlsByType?.[type] ?? []) { - appendUnique(values, candidate); - } -} - -function getControllerHeroBackgroundCandidates(game: GameInfo): string[] { - const candidates: string[] = []; - for (const type of CONTROLLER_HERO_BACKGROUND_KEYS) { - appendImageType(candidates, game, type); - } - appendUnique(candidates, game.heroImageUrl); - appendUnique(candidates, game.imageUrl); - for (const candidate of game.screenshotUrls ?? []) appendUnique(candidates, candidate); - appendUnique(candidates, game.screenshotUrl); - return candidates; -} - -function getControllerHeroLogoUrl(game: GameInfo): string | undefined { - return game.imageUrlsByType?.GAME_LOGO?.find(Boolean); -} - -function getGameLogoUrl(game: GameInfo): string | undefined { - return game.imageUrlsByType?.GAME_LOGO?.find(Boolean); -} - -function getControllerFeaturedGames(featuredGames: GameInfo[], fallbackGames: GameInfo[]): GameInfo[] { - const source = featuredGames.length > 0 ? featuredGames : fallbackGames; - return source.slice(0, 6); -} - -function getGameStoreSummary(game: GameInfo, fallback: string): string { - const stores = [...new Set((game.availableStores?.length ? game.availableStores : game.variants.map((variant) => variant.store)).filter(Boolean))]; - if (stores.length === 0) return fallback; - const visible = stores.slice(0, 3).join(", "); - return stores.length > 3 ? `${visible} +${stores.length - 3}` : visible; -} - -function getSelectedVariantStoreLabel(game: GameInfo, selectedVariantId: string | undefined, fallback: string): string { - const selectedVariant = game.variants.find((variant) => variant.id === selectedVariantId) - ?? game.variants[game.selectedVariantIndex] - ?? game.variants[0]; - return selectedVariant?.store ? getStoreDisplayName(selectedVariant.store) : fallback; -} - -function getPlayerSummary(game: GameInfo): string | null { - const parts: string[] = []; - if (game.maxLocalPlayers && game.maxLocalPlayers > 0) parts.push(`Local ${game.maxLocalPlayers}`); - if (game.maxOnlinePlayers && game.maxOnlinePlayers > 0) parts.push(`Online ${game.maxOnlinePlayers}`); - return parts.length > 0 ? parts.join(" / ") : null; -} - -function gameMatchesActiveSession(game: GameInfo, activeSessionAppIds: number[]): boolean { - if (activeSessionAppIds.length === 0) return false; - const appIds = new Set(activeSessionAppIds.map(String)); - if (game.launchAppId && appIds.has(game.launchAppId)) return true; - if (appIds.has(game.id)) return true; - return game.variants.some((variant) => appIds.has(variant.id)); -} - -function gameMatchesStoreFilter(game: GameInfo, filterId: string): boolean { - if (filterId === "library") return true; - const store = filterId.slice("store:".length); - return game.variants.some((variant) => variant.store === store) || (game.availableStores ?? []).includes(store); -} - -function normalizeLibraryFilterValue(value: string | undefined): string { - return (value ?? "") - .trim() - .toLowerCase() - .replace(/['’]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -function formatLibraryFilterLabel(value: string): string { - return value - .trim() - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .toLowerCase() - .replace(/\b\w/g, (match) => match.toUpperCase()); -} - -function getGameCatalogSkuText(game: GameInfo): string { - const values = Object.values(game.catalogSkuStrings ?? {}); - const parts: string[] = []; - for (const value of values) { - if (typeof value === "string") { - parts.push(value); - } else if (Array.isArray(value)) { - parts.push(...value.filter((item): item is string => typeof item === "string")); - } - } - return parts.join(" "); -} - -function gameRequiresInstallToPlay(game: GameInfo): boolean { - const haystack = [ - game.playType, - game.playabilityState, - ...(game.featureLabels ?? []), - getGameCatalogSkuText(game), - ].join(" ").toLowerCase(); - return haystack.includes("install"); -} - -function getGamePlayTypeFilter(game: GameInfo, t: LibraryTranslation): LibraryFilterOption | null { - if (gameRequiresInstallToPlay(game)) { - return { id: "play:install-to-play", label: t("library.filterOptions.installToPlay"), count: 0 }; - } - if (!game.playType?.trim()) return null; - const normalized = normalizeLibraryFilterValue(game.playType); - if (!normalized) return null; - if (normalized.includes("instant") || normalized.includes("stream") || normalized.includes("cloud")) { - return { id: "play:cloud-launch", label: t("library.filterOptions.cloudLaunch"), count: 0 }; - } - const label = formatLibraryFilterLabel(game.playType); - const key = normalizeLibraryFilterValue(label); - return key ? { id: `play:${key}`, label, count: 0 } : null; -} - -function getGameStoreFilters(game: GameInfo): Array<{ id: string; label: string }> { - const stores = game.availableStores?.length ? game.availableStores : game.variants.map((variant) => variant.store); - const seen = new Set(); - const filters: Array<{ id: string; label: string }> = []; - for (const store of stores) { - if (!store.trim()) continue; - const key = normalizeStoreKey(store); - if (seen.has(key)) continue; - seen.add(key); - filters.push({ id: `platform:${key}`, label: getStoreDisplayName(store) }); - } - return filters; -} - -function getControlFilter(control: string, t: LibraryTranslation): { id: string; label: string } | null { - const normalized = normalizeLibraryFilterValue(control); - if (!normalized) return null; - if (normalized.includes("keyboard") || normalized.includes("mouse")) { - return { id: "controls:keyboard-mouse", label: t("library.filterOptions.keyboardMouse") }; - } - if (normalized.includes("gamepad") || normalized.includes("controller")) { - return { id: "controls:controller", label: t("library.filterOptions.controller") }; - } - if (normalized.includes("touch")) { - return { id: "controls:touch", label: t("library.filterOptions.touch") }; - } - const label = formatLibraryFilterLabel(control); - const key = normalizeLibraryFilterValue(label); - return key ? { id: `controls:${key}`, label } : null; -} - -function getGameControlFilters(game: GameInfo, t: LibraryTranslation): Array<{ id: string; label: string }> { - const controls = [ - ...(game.supportedControls ?? []), - ...game.variants.flatMap((variant) => variant.supportedControls), - ]; - const seen = new Set(); - const filters: Array<{ id: string; label: string }> = []; - for (const control of controls) { - const filter = getControlFilter(control, t); - if (!filter || seen.has(filter.id)) continue; - seen.add(filter.id); - filters.push(filter); - } - return filters; -} - -function upsertLibraryFilterOption(options: Map, id: string, label: string): void { - const existing = options.get(id); - if (existing) { - existing.count += 1; - return; - } - options.set(id, { id, label, count: 1 }); -} - -function mapLibraryFilterOptions(options: Map): LibraryFilterOption[] { - return [...options.values()].sort((left, right) => left.label.localeCompare(right.label)); -} - -function gameHasLibraryActivity(game: GameInfo, playtimeData: PlaytimeData): boolean { - if (game.lastPlayed) return true; - const record = playtimeData[game.id]; - if (!record) return false; - return Boolean(record.lastPlayedAt) || (record.totalSeconds ?? 0) > 0 || (record.sessionCount ?? 0) > 0; -} - -function getLibraryFilterGroups(games: GameInfo[], playtimeData: PlaytimeData, t: LibraryTranslation): LibraryFilterGroup[] { - const playTypeOptions = new Map(); - const platformOptions = new Map(); - const controlOptions = new Map(); - const activityOptions = new Map(); - - for (const game of games) { - const playTypeOption = getGamePlayTypeFilter(game, t); - if (playTypeOption) upsertLibraryFilterOption(playTypeOptions, playTypeOption.id, playTypeOption.label); - - for (const option of getGameStoreFilters(game)) { - upsertLibraryFilterOption(platformOptions, option.id, option.label); - } - - for (const option of getGameControlFilters(game, t)) { - upsertLibraryFilterOption(controlOptions, option.id, option.label); - } - - const hasActivity = gameHasLibraryActivity(game, playtimeData); - upsertLibraryFilterOption( - activityOptions, - hasActivity ? "activity:played" : "activity:never-played", - hasActivity ? t("library.filterOptions.played") : t("library.filterOptions.neverPlayed"), - ); - } - - return [ - { id: "play", label: t("library.filterGroups.playType"), options: mapLibraryFilterOptions(playTypeOptions) }, - { id: "platform", label: t("library.filterGroups.platform"), options: mapLibraryFilterOptions(platformOptions) }, - { id: "controls", label: t("library.filterGroups.controls"), options: mapLibraryFilterOptions(controlOptions) }, - { id: "activity", label: t("library.filterGroups.activity"), options: mapLibraryFilterOptions(activityOptions) }, - ].filter((group) => group.options.length > 0); -} - -function getLibraryFilterGroupId(filterId: string): string { - const separatorIndex = filterId.indexOf(":"); - return separatorIndex >= 0 ? filterId.slice(0, separatorIndex) : filterId; -} - -function gameMatchesLibraryFilter(game: GameInfo, filterId: string, playtimeData: PlaytimeData, t: LibraryTranslation): boolean { - const [groupId, value] = filterId.split(":"); - if (!value) return true; - - if (groupId === "play") { - return getGamePlayTypeFilter(game, t)?.id === filterId; - } - - if (groupId === "platform") { - return getGameStoreFilters(game).some((option) => option.id === filterId); - } - - if (groupId === "controls") { - return getGameControlFilters(game, t).some((option) => option.id === filterId); - } - - if (groupId === "activity") { - const hasActivity = gameHasLibraryActivity(game, playtimeData); - return value === "played" ? hasActivity : !hasActivity; - } - - return true; -} - -function gameMatchesLibraryFilters(game: GameInfo, selectedFilterIds: string[], playtimeData: PlaytimeData, t: LibraryTranslation): boolean { - if (selectedFilterIds.length === 0) return true; - const selectedByGroup = new Map(); - for (const filterId of selectedFilterIds) { - const groupId = getLibraryFilterGroupId(filterId); - selectedByGroup.set(groupId, [...(selectedByGroup.get(groupId) ?? []), filterId]); - } - for (const groupFilterIds of selectedByGroup.values()) { - if (!groupFilterIds.some((filterId) => gameMatchesLibraryFilter(game, filterId, playtimeData, t))) return false; - } - return true; -} - -function getLibraryFilterOptionById(groups: LibraryFilterGroup[], id: string): LibraryFilterOption | undefined { - for (const group of groups) { - const option = group.options.find((candidate) => candidate.id === id); - if (option) return option; - } - return undefined; -} - -function getControllerStoreFilterItems(games: GameInfo[], allStoresLabel: string): ControllerStoreFilterItem[] { - const stores = new Set(); - for (const game of games) { - for (const store of game.availableStores ?? []) { - if (store.trim()) stores.add(store); - } - for (const variant of game.variants) { - if (variant.store.trim()) stores.add(variant.store); - } - } - - return [ - { id: "library", title: allStoresLabel }, - ...[...stores].sort((left, right) => left.localeCompare(right)).map((store) => ({ id: `store:${store}`, title: store })), - ]; -} - -function ControllerGameCard({ - game, - isSelected, - selectedVariantId, - onSelect, - onPlay, -}: { - game: GameInfo; - isSelected: boolean; - selectedVariantId?: string; - onSelect: () => void; - onPlay: () => void; -}): JSX.Element { - const selectedVariant = game.variants.find((variant) => variant.id === selectedVariantId) ?? game.variants[game.selectedVariantIndex] ?? game.variants[0]; - const store = selectedVariant?.store ?? game.availableStores?.[0] ?? ""; - const StoreIcon = getStoreIconComponent(store); - const logoUrl = getGameLogoUrl(game); - - return ( - - - {game.imageUrl ? : {game.title.slice(0, 1)}} - - {store && ( - - - - )} - - {logoUrl ? {game.title} : game.title} - - - ); -} - -export const LibraryPage = memo(function LibraryPage({ - games, - allGames, - playtimeData, - searchQuery, - onSearchChange, - onPlayGame, - onBuyGame, - isLoading, - selectedGameId, - onSelectGame, - selectedVariantByGameId, - onSelectGameVariant, - libraryCount, - sortOptions, - selectedSortId, - onSortChange, - controllerMode = false, - featuredGames = [], - activeSessionAppIds = [], - onPreviousControllerPage, - onNextControllerPage, -}: LibraryPageProps): JSX.Element { - const { t } = useTranslation(); - const catalogActionsRef = useCatalogCardActionsRef({ - onPlayGame, - onSelectGame, - onSelectGameVariant, - }); - const [controllerHeroIndex, setControllerHeroIndex] = useState(0); - const [detailsGame, setDetailsGame] = useState(null); - const [controllerStoreFilterId, setControllerStoreFilterId] = useState("library"); - const [controllerStoreFilterOpen, setControllerStoreFilterOpen] = useState(false); - const [controllerSearchOpen, setControllerSearchOpen] = useState(false); - const [focusedControllerStoreFilterIndex, setFocusedControllerStoreFilterIndex] = useState(0); - const [selectedLibraryFilterIds, setSelectedLibraryFilterIds] = useState([]); - const controllerSearchInputRef = useRef(null); - const gamepadPreviousButtonsRef = useRef(0); - const gamepadLastMoveAtRef = useRef(0); - const gamepadFrameRef = useRef(null); - const controllerYPressedAtRef = useRef(0); - const controllerYConsumedByHoldRef = useRef(false); - const controllerGameRowRef = useRef(null); - const controllerInputStateRef = useRef({ - detailsGame: null as GameInfo | null, - selectedControllerGame: undefined as GameInfo | undefined, - selectedControllerGameIndex: 0, - controllerStoreFilterOpen: false, - focusedControllerStoreFilterIndex: 0, - controllerStoreFilterItems: [] as ControllerStoreFilterItem[], - focusControllerGame: (_index: number): void => {}, - cycleSelectedVariant: (): void => {}, - cycleControllerStoreFilter: (): void => {}, - moveControllerStoreFilterFocusBy: (_delta: number): void => {}, - hideControllerStoreFilterOverlay: (_applySelection: boolean): void => {}, - showControllerStoreFilterOverlay: (): void => {}, - onPlayGame: (_game: GameInfo): void => {}, - }); - - useEffect(() => { - if (!controllerMode || !controllerSearchOpen) return; - controllerSearchInputRef.current?.focus(); - }, [controllerMode, controllerSearchOpen]); - - const librarySearchHasQuery = searchQuery.trim().length > 0; - const libraryFilterGroups = useMemo( - () => getLibraryFilterGroups(allGames, playtimeData, t), - [allGames, playtimeData, t], - ); - const visibleLibraryGames = useMemo( - () => games.filter((game) => gameMatchesLibraryFilters(game, selectedLibraryFilterIds, playtimeData, t)), - [games, playtimeData, selectedLibraryFilterIds, t], - ); - const activeLibraryFilterOptions = useMemo( - () => selectedLibraryFilterIds - .map((filterId) => getLibraryFilterOptionById(libraryFilterGroups, filterId)) - .filter((option): option is LibraryFilterOption => Boolean(option)), - [libraryFilterGroups, selectedLibraryFilterIds], - ); - const hasActiveLibraryFilters = activeLibraryFilterOptions.length > 0; - const libraryCountLabel = hasActiveLibraryFilters || librarySearchHasQuery - ? t("library.filteredGameCount", { shown: visibleLibraryGames.length, total: libraryCount, count: libraryCount }) - : t("library.gameCount", { count: libraryCount }); - - useEffect(() => { - const availableFilterIds = new Set(libraryFilterGroups.flatMap((group) => group.options.map((option) => option.id))); - setSelectedLibraryFilterIds((previous) => { - const next = previous.filter((filterId) => availableFilterIds.has(filterId)); - return next.length === previous.length ? previous : next; - }); - }, [libraryFilterGroups]); - - useEffect(() => { - if (controllerMode || visibleLibraryGames.length === 0) return; - if (visibleLibraryGames.some((game) => game.id === selectedGameId)) return; - onSelectGame(visibleLibraryGames[0].id); - }, [controllerMode, onSelectGame, selectedGameId, visibleLibraryGames]); - - const toggleLibraryFilter = (filterId: string): void => { - setSelectedLibraryFilterIds((previous) => ( - previous.includes(filterId) - ? previous.filter((selectedFilterId) => selectedFilterId !== filterId) - : [...previous, filterId] - )); - }; - - const clearLibraryFilters = (): void => { - setSelectedLibraryFilterIds([]); - }; - - const controllerStoreFilterItems = useMemo( - () => getControllerStoreFilterItems(games, t("library.allStores")), - [games, t], - ); - const controllerGames = useMemo( - () => controllerStoreFilterId === "library" ? games : games.filter((game) => gameMatchesStoreFilter(game, controllerStoreFilterId)), - [controllerStoreFilterId, games], - ); - const controllerFeaturedGames = useMemo( - () => getControllerFeaturedGames(featuredGames, controllerGames), - [featuredGames, controllerGames], - ); - - useEffect(() => { - if (!controllerMode) return; - setControllerHeroIndex(0); - }, [controllerMode, controllerFeaturedGames]); - - useEffect(() => { - if (controllerMode) return; - gamepadPreviousButtonsRef.current = 0; - gamepadLastMoveAtRef.current = 0; - }, [controllerMode]); - - useEffect(() => { - if (!controllerMode || controllerFeaturedGames.length <= 1) return; - const interval = window.setInterval(() => { - setControllerHeroIndex((index) => (index + 1) % controllerFeaturedGames.length); - }, CONTROLLER_HERO_ROTATION_MS); - return () => window.clearInterval(interval); - }, [controllerMode, controllerFeaturedGames.length]); - - useEffect(() => { - if (!controllerMode || games.length === 0) return; - if (controllerGames.some((game) => game.id === selectedGameId)) return; - onSelectGame(controllerGames[0]?.id ?? games[0].id); - }, [controllerGames, controllerMode, games, onSelectGame, selectedGameId]); - - useEffect(() => { - if (controllerStoreFilterItems.some((item) => item.id === controllerStoreFilterId)) return; - setControllerStoreFilterId("library"); - setFocusedControllerStoreFilterIndex(0); - }, [controllerStoreFilterId, controllerStoreFilterItems]); - - const selectedControllerGameIndex = Math.max(0, controllerGames.findIndex((game) => game.id === selectedGameId)); - const selectedControllerGame = controllerGames[selectedControllerGameIndex] ?? controllerGames[0]; - - const focusControllerGame = (index: number): void => { - if (controllerGames.length === 0) return; - const nextIndex = Math.max(0, Math.min(index, controllerGames.length - 1)); - const nextGame = controllerGames[nextIndex]; - onSelectGame(nextGame.id); - window.requestAnimationFrame(() => { - const row = controllerGameRowRef.current; - const card = row?.querySelector(`[data-controller-game-id="${CSS.escape(nextGame.id)}"]`); - card?.scrollIntoView({ inline: "nearest", block: "nearest", behavior: "auto" }); - }); - }; - - const cycleGameVariant = (game: GameInfo | undefined): void => { - if (!game || game.variants.length <= 1) return; - const activeVariantId = selectedVariantByGameId[game.id]; - const activeIndex = Math.max(0, game.variants.findIndex((variant) => variant.id === activeVariantId)); - const nextVariant = game.variants[(activeIndex + 1) % game.variants.length]; - if (nextVariant) onSelectGameVariant(game.id, nextVariant.id); - }; - - const cycleSelectedVariant = (): void => { - cycleGameVariant(selectedControllerGame); - }; - - const cycleControllerStoreFilter = (): void => { - if (controllerStoreFilterItems.length <= 1) return; - const activeIndex = Math.max(0, controllerStoreFilterItems.findIndex((item) => item.id === controllerStoreFilterId)); - const nextItem = controllerStoreFilterItems[(activeIndex + 1) % controllerStoreFilterItems.length]; - setControllerStoreFilterId(nextItem.id); - setFocusedControllerStoreFilterIndex((activeIndex + 1) % controllerStoreFilterItems.length); - setControllerHeroIndex(0); - }; - - const showControllerStoreFilterOverlay = (): void => { - const activeIndex = Math.max(0, controllerStoreFilterItems.findIndex((item) => item.id === controllerStoreFilterId)); - setFocusedControllerStoreFilterIndex(activeIndex); - setControllerStoreFilterOpen(true); - }; - - const moveControllerStoreFilterFocusBy = (delta: number): void => { - if (controllerStoreFilterItems.length === 0) return; - setFocusedControllerStoreFilterIndex((index) => Math.max(0, Math.min(index + delta, controllerStoreFilterItems.length - 1))); - }; - - const hideControllerStoreFilterOverlay = (applySelection: boolean): void => { - if (applySelection) { - const item = controllerStoreFilterItems[focusedControllerStoreFilterIndex] ?? controllerStoreFilterItems[0]; - if (item) { - setControllerStoreFilterId(item.id); - setControllerHeroIndex(0); - } - } - setControllerStoreFilterOpen(false); - }; - - useEffect(() => { - controllerInputStateRef.current = { - detailsGame, - selectedControllerGame, - selectedControllerGameIndex, - controllerStoreFilterOpen, - focusedControllerStoreFilterIndex, - controllerStoreFilterItems, - focusControllerGame, - cycleSelectedVariant, - cycleControllerStoreFilter, - moveControllerStoreFilterFocusBy, - hideControllerStoreFilterOverlay, - showControllerStoreFilterOverlay, - onPlayGame, - }; - }, [controllerStoreFilterItems, controllerStoreFilterOpen, detailsGame, focusedControllerStoreFilterIndex, focusControllerGame, cycleSelectedVariant, cycleControllerStoreFilter, moveControllerStoreFilterFocusBy, hideControllerStoreFilterOverlay, showControllerStoreFilterOverlay, onPlayGame, selectedControllerGame, selectedControllerGameIndex]); - - useEffect(() => { - if (!controllerMode) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (detailsGame) { - if (event.key === "Escape" || event.key.toLowerCase() === "b") { - event.preventDefault(); - setDetailsGame(null); - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onPlayGame(detailsGame); - } - return; - } - if (controllerSearchOpen) { - if (event.key === "Escape") { - event.preventDefault(); - setControllerSearchOpen(false); - } - return; - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - focusControllerGame(selectedControllerGameIndex - 1); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - focusControllerGame(selectedControllerGameIndex + 1); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - cycleSelectedVariant(); - } else if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - if (selectedControllerGame) onPlayGame(selectedControllerGame); - } else if (event.key.toLowerCase() === "x") { - event.preventDefault(); - setControllerSearchOpen(true); - } else if (event.key.toLowerCase() === "b" || event.key === "Escape") { - event.preventDefault(); - onPreviousControllerPage?.(); - } else if (event.key === "[") { - event.preventDefault(); - onPreviousControllerPage?.(); - } else if (event.key === "]") { - event.preventDefault(); - onNextControllerPage?.(); - } else if (event.key.toLowerCase() === "i" || event.key.toLowerCase() === "m") { - event.preventDefault(); - if (selectedControllerGame) setDetailsGame(selectedControllerGame); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [controllerMode, controllerSearchOpen, detailsGame, onNextControllerPage, onPlayGame, onPreviousControllerPage, selectedControllerGame, selectedControllerGameIndex]); - - useEffect(() => { - if (!controllerMode) return; - const readButtons = (): number => { - const pad = navigator.getGamepads?.().find((gamepad): gamepad is Gamepad => Boolean(gamepad)); - return readControllerGamepadButtons(pad); - }; - - const handleGamepadFrame = () => { - const buttons = readButtons(); - let pressed = buttons & ~gamepadPreviousButtonsRef.current; - const released = gamepadPreviousButtonsRef.current & ~buttons; - const moveMask = controllerButton.up | controllerButton.down | controllerButton.left | controllerButton.right; - const yButton = controllerButton.north; - const now = performance.now(); - const activeMoves = buttons & moveMask; - const pressedMoves = pressed & moveMask; - if (pressedMoves) { - gamepadLastMoveAtRef.current = now; - } else if (activeMoves && now - gamepadLastMoveAtRef.current > CONTROLLER_MOVE_REPEAT_MS) { - pressed |= activeMoves; - gamepadLastMoveAtRef.current = now; - } - - const { - detailsGame: currentDetailsGame, - selectedControllerGame: currentSelectedGame, - selectedControllerGameIndex: currentSelectedIndex, - controllerStoreFilterOpen: storeFilterOpen, - focusControllerGame: focusGame, - cycleSelectedVariant: cycleVariant, - cycleControllerStoreFilter: cycleStoreFilter, - moveControllerStoreFilterFocusBy: moveStoreFilter, - hideControllerStoreFilterOverlay: hideStoreFilter, - showControllerStoreFilterOverlay: showStoreFilter, - onPlayGame: playGame, - } = controllerInputStateRef.current; - - if (pressed & yButton) { - controllerYPressedAtRef.current = now; - controllerYConsumedByHoldRef.current = false; - } - - if ((buttons & yButton) && !controllerYConsumedByHoldRef.current && now - controllerYPressedAtRef.current >= CONTROLLER_Y_HOLD_MS) { - controllerYConsumedByHoldRef.current = true; - showStoreFilter(); - } - - if (controllerSearchOpen) { - if (pressed & controllerButton.east) setControllerSearchOpen(false); - gamepadPreviousButtonsRef.current = buttons; - gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); - return; - } - - if (storeFilterOpen) { - if (pressed & controllerButton.up) moveStoreFilter(-1); - if (pressed & controllerButton.down) moveStoreFilter(1); - if (pressed & controllerButton.east) hideStoreFilter(false); - if (released & yButton) hideStoreFilter(true); - gamepadPreviousButtonsRef.current = buttons; - gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); - return; - } - - if (currentDetailsGame) { - if (pressed & controllerButton.south) playGame(currentDetailsGame); - if (pressed & controllerButton.east) setDetailsGame(null); - } else { - if ((released & yButton) && !controllerYConsumedByHoldRef.current) cycleStoreFilter(); - if (pressed & controllerButton.south) { - if (currentSelectedGame) playGame(currentSelectedGame); - } - if (pressed & controllerButton.east) onPreviousControllerPage?.(); - if (pressed & controllerButton.west) setControllerSearchOpen(true); - if (pressed & controllerButton.leftShoulder) onPreviousControllerPage?.(); - if (pressed & controllerButton.rightShoulder) onNextControllerPage?.(); - if (pressed & controllerButton.menu) { - if (currentSelectedGame) setDetailsGame(currentSelectedGame); - } - if (pressed & controllerButton.left) focusGame(currentSelectedIndex - 1); - if (pressed & controllerButton.right) focusGame(currentSelectedIndex + 1); - if (pressed & controllerButton.down) cycleVariant(); - } - gamepadPreviousButtonsRef.current = buttons; - - gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); - }; - - const startGamepadNavigation = () => { - if (gamepadFrameRef.current !== null) return; - gamepadPreviousButtonsRef.current = readButtons(); - gamepadLastMoveAtRef.current = performance.now(); - gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); - }; - - const stopGamepadNavigation = () => { - if (gamepadFrameRef.current !== null) { - window.cancelAnimationFrame(gamepadFrameRef.current); - gamepadFrameRef.current = null; - } - gamepadPreviousButtonsRef.current = 0; - gamepadLastMoveAtRef.current = 0; - }; - - const handleDisconnect = () => { - const hasConnectedPad = navigator.getGamepads?.().some(Boolean) ?? false; - if (!hasConnectedPad) stopGamepadNavigation(); - }; - - window.addEventListener("gamepadconnected", startGamepadNavigation); - window.addEventListener("gamepaddisconnected", handleDisconnect); - startGamepadNavigation(); - - return () => { - window.removeEventListener("gamepadconnected", startGamepadNavigation); - window.removeEventListener("gamepaddisconnected", handleDisconnect); - stopGamepadNavigation(); - }; - }, [controllerMode, controllerSearchOpen, onNextControllerPage, onPreviousControllerPage]); - - const libraryGridItems = useMemo( - () => visibleLibraryGames.map((game) => ( -
- - {game.lastPlayed && ( -
- - {formatCatalogLastPlayed(t, game.lastPlayed)} -
- )} -
- )), - [catalogActionsRef, selectedGameId, selectedVariantByGameId, t, visibleLibraryGames], - ); - - if (controllerMode) { - const featuredGame = controllerFeaturedGames[controllerHeroIndex] ?? selectedControllerGame; - const heroImageUrl = featuredGame ? getControllerHeroBackgroundCandidates(featuredGame)[0] : undefined; - const heroLogoUrl = featuredGame ? getControllerHeroLogoUrl(featuredGame) : undefined; - const heroSelectedVariantId = featuredGame ? selectedVariantByGameId[featuredGame.id] : undefined; - const heroStoreLabel = featuredGame ? getSelectedVariantStoreLabel(featuredGame, selectedVariantByGameId[featuredGame.id], t("library.storeNotListed")) : ""; - const featuredGameHasActiveSession = featuredGame ? gameMatchesActiveSession(featuredGame, activeSessionAppIds) : false; - const heroShouldBuy = Boolean(featuredGame && !featuredGameHasActiveSession && !featuredGame.isInLibrary); - const dotCount = Math.min(Math.max(controllerFeaturedGames.length, 1), 6); - const activeDotIndex = dotCount > 0 && controllerFeaturedGames.length > 0 ? Math.min(controllerHeroIndex, dotCount - 1) : 0; - - return ( -
- {isLoading ? ( -
- -

{t("library.empty.loadingLibrary")}

-
- ) : libraryCount === 0 ? ( -
- -

{t("library.empty.libraryEmpty")}

-

{t("library.empty.ownedGamesAppearHere")}

-
- ) : featuredGame ? ( - <> -
- - {heroImageUrl ? ( - - ) : ( - - )} - -
- - - {heroLogoUrl ? ( - {featuredGame.title} - ) : ( -

{featuredGame.title}

- )} -
- - {heroStoreLabel && {heroStoreLabel}} - -
-
-
-
- - - -
-
-

{t("library.controllerTitle")}

- {t("library.gameCount", { count: controllerGames.length })} -
- {controllerGames.length === 0 ? ( -
- -

{t("library.empty.noGamesFound")}

-

{t("library.empty.noGamesMatch", { query: searchQuery })}

-
- ) : ( -
- {controllerGames.map((game) => ( -
- onSelectGame(game.id)} - onPlay={() => onPlayGame(game)} - selectedVariantId={selectedVariantByGameId[game.id]} - /> -
- ))} -
- )} -
- - - - - {controllerStoreFilterOpen && ( - - - {t("library.storeFilter")} -

{t("library.chooseStore")}

-

{t("library.storeFilterHint")}

-
- {controllerStoreFilterItems.map((item, index) => ( - - ))} -
-
-
- )} -
- - - {controllerSearchOpen && ( - - - {t("app.actions.search")} - onSearchChange(event.target.value)} - placeholder={t("library.searchPlaceholder")} - className="controller-search-input" - /> -

{t("app.actions.back")}

-
-
- )} -
- - - {detailsGame && ( - - -

{detailsGame.title}

-

{t("library.selectedStore", { store: getGameStoreSummary(detailsGame, t("library.storeNotListed")) })}

-

{detailsGame.description || detailsGame.longDescription || detailsGame.featureLabels?.join(" / ") || t("library.loadingGameDetails")}

-
- {detailsGame.developerName && {t("library.developer", { developer: detailsGame.developerName })}} - {detailsGame.publisherName && {t("library.publisher", { publisher: detailsGame.publisherName })}} - {getPlayerSummary(detailsGame) && {t("library.players", { players: getPlayerSummary(detailsGame) })}} - {detailsGame.supportedControls?.length ? {t("library.controls", { controls: detailsGame.supportedControls.slice(0, 4).join(", ") })} : null} - {detailsGame.nvidiaTech?.length ? {t("library.nvidiaTech", { tech: detailsGame.nvidiaTech.slice(0, 4).join(", ") })} : null} - {detailsGame.genres?.length ? {t("library.genres", { genres: detailsGame.genres.slice(0, 4).join(", ") })} : null} - {detailsGame.contentRatings?.length ? {t("library.rating", { rating: detailsGame.contentRatings.slice(0, 2).join(", ") })} : null} -
-
- - -
-
-
- )} -
- - ) : null} -
- ); - } - - return ( -
-
-
- -

{t("library.title")}

-
- -
- - onSearchChange(e.target.value)} - placeholder={t("library.searchPlaceholder")} - className="library-search-input" - /> -
- - {libraryFilterGroups.length > 0 && ( -
- - - - {t("library.filters")} - - {selectedLibraryFilterIds.length > 0 && {selectedLibraryFilterIds.length}} - - -
- {libraryFilterGroups.map((group) => ( -
-
{group.label}
-
- {group.options.map((option) => { - const active = selectedLibraryFilterIds.includes(option.id); - return ( - - ); - })} -
-
- ))} -
-
- )} - - {sortOptions.length > 0 && ( -
- - ({ value: option.id, label: option.label }))} - onChange={onSortChange} - ariaLabel={t("library.sortAriaLabel")} - /> -
- )} - - {libraryCountLabel} -
- - - {activeLibraryFilterOptions.length > 0 && ( - - {t("library.activeFilters")} - {activeLibraryFilterOptions.map((option) => ( - - ))} - - - )} - - -
- {isLoading ? ( -
- -

{t("library.empty.loadingLibrary")}

-
- ) : libraryCount === 0 ? ( -
- -

{t("library.empty.libraryEmpty")}

-

{t("library.empty.ownedGamesAppearHere")}

-
- ) : visibleLibraryGames.length === 0 ? ( -
- -

{hasActiveLibraryFilters && !librarySearchHasQuery ? t("library.empty.noFilteredGames") : t("library.empty.noGamesFound")}

-

- {librarySearchHasQuery - ? t("library.empty.noGamesMatch", { query: searchQuery }) - : hasActiveLibraryFilters - ? t("library.empty.tryAdjustingFilters") - : t("library.empty.noGamesMatch", { query: searchQuery })} -

-
- ) : ( -
- {libraryGridItems} -
- )} -
-
- ); -}); +import { Library, Search, Clock, Gamepad2, Loader2, ArrowUpDown, MoreHorizontal, Menu, Filter, ChevronDown, X } from "lucide-react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import type { JSX } from "react"; +import { AnimatePresence, m } from "motion/react"; +import type { CatalogSortOption, GameInfo } from "@shared/gfn"; +import { getStoreDisplayName, getStoreIconComponent, normalizeStoreKey } from "./GameCard"; +import { GameCardListItem, useCatalogCardActionsRef } from "./GameCardListItem"; +import type { PlaytimeData } from "../lib/gameCatalog"; +import { useTranslation } from "../i18n"; +import { formatCatalogLastPlayed } from "../utils/lastPlayedFormat"; +import { controllerButton, readControllerGamepadButtons } from "../utils/controllerGamepad"; +import { pageTransition, panelSpring } from "./MotionProvider"; +import { SelectDropdown } from "./ui/SelectDropdown"; + +const CONTROLLER_HERO_ROTATION_MS = 8000; +const CONTROLLER_MOVE_REPEAT_MS = 140; +const CONTROLLER_Y_HOLD_MS = 350; + +const CONTROLLER_HERO_BACKGROUND_KEYS = [ + "MARQUEE_HERO_IMAGE", + "FEATURE_IMAGE", + "HERO_IMAGE", + "TV_BANNER", + "KEY_ART", + "KEY_IMAGE", +] as const; + +interface ControllerStoreFilterItem { + id: string; + title: string; +} + +interface LibraryFilterOption { + id: string; + label: string; + count: number; +} + +interface LibraryFilterGroup { + id: string; + label: string; + options: LibraryFilterOption[]; +} + +type LibraryTranslation = (key: string, values?: Record) => string; + +export interface LibraryPageProps { + games: GameInfo[]; + allGames: GameInfo[]; + playtimeData: PlaytimeData; + searchQuery: string; + onSearchChange: (query: string) => void; + onPlayGame: (game: GameInfo) => void; + onBuyGame?: (game: GameInfo, selectedVariantId?: string) => void; + isLoading: boolean; + selectedGameId: string; + onSelectGame: (id: string) => void; + selectedVariantByGameId: Record; + onSelectGameVariant: (gameId: string, variantId: string) => void; + libraryCount: number; + sortOptions: CatalogSortOption[]; + selectedSortId: string; + onSortChange: (sortId: string) => void; + controllerMode?: boolean; + featuredGames?: GameInfo[]; + activeSessionAppIds?: number[]; + onPreviousControllerPage?: () => void; + onNextControllerPage?: () => void; +} + +function appendUnique(values: string[], candidate: string | undefined): void { + if (!candidate || values.includes(candidate)) return; + values.push(candidate); +} + +function appendImageType(values: string[], game: GameInfo, type: string): void { + for (const candidate of game.imageUrlsByType?.[type] ?? []) { + appendUnique(values, candidate); + } +} + +function getControllerHeroBackgroundCandidates(game: GameInfo): string[] { + const candidates: string[] = []; + for (const type of CONTROLLER_HERO_BACKGROUND_KEYS) { + appendImageType(candidates, game, type); + } + appendUnique(candidates, game.heroImageUrl); + appendUnique(candidates, game.imageUrl); + for (const candidate of game.screenshotUrls ?? []) appendUnique(candidates, candidate); + appendUnique(candidates, game.screenshotUrl); + return candidates; +} + +function getControllerHeroLogoUrl(game: GameInfo): string | undefined { + return game.imageUrlsByType?.GAME_LOGO?.find(Boolean); +} + +function getGameLogoUrl(game: GameInfo): string | undefined { + return game.imageUrlsByType?.GAME_LOGO?.find(Boolean); +} + +function getControllerFeaturedGames(featuredGames: GameInfo[], fallbackGames: GameInfo[]): GameInfo[] { + const source = featuredGames.length > 0 ? featuredGames : fallbackGames; + return source.slice(0, 6); +} + +function getGameStoreSummary(game: GameInfo, fallback: string): string { + const stores = [...new Set((game.availableStores?.length ? game.availableStores : game.variants.map((variant) => variant.store)).filter(Boolean))]; + if (stores.length === 0) return fallback; + const visible = stores.slice(0, 3).join(", "); + return stores.length > 3 ? `${visible} +${stores.length - 3}` : visible; +} + +function getSelectedVariantStoreLabel(game: GameInfo, selectedVariantId: string | undefined, fallback: string): string { + const selectedVariant = game.variants.find((variant) => variant.id === selectedVariantId) + ?? game.variants[game.selectedVariantIndex] + ?? game.variants[0]; + return selectedVariant?.store ? getStoreDisplayName(selectedVariant.store) : fallback; +} + +function getPlayerSummary(game: GameInfo): string | null { + const parts: string[] = []; + if (game.maxLocalPlayers && game.maxLocalPlayers > 0) parts.push(`Local ${game.maxLocalPlayers}`); + if (game.maxOnlinePlayers && game.maxOnlinePlayers > 0) parts.push(`Online ${game.maxOnlinePlayers}`); + return parts.length > 0 ? parts.join(" / ") : null; +} + +function gameMatchesActiveSession(game: GameInfo, activeSessionAppIds: number[]): boolean { + if (activeSessionAppIds.length === 0) return false; + const appIds = new Set(activeSessionAppIds.map(String)); + if (game.launchAppId && appIds.has(game.launchAppId)) return true; + if (appIds.has(game.id)) return true; + return game.variants.some((variant) => appIds.has(variant.id)); +} + +function gameMatchesStoreFilter(game: GameInfo, filterId: string): boolean { + if (filterId === "library") return true; + const store = filterId.slice("store:".length); + return game.variants.some((variant) => variant.store === store) || (game.availableStores ?? []).includes(store); +} + +function normalizeLibraryFilterValue(value: string | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/['’]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function formatLibraryFilterLabel(value: string): string { + return value + .trim() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .toLowerCase() + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + +function getGameCatalogSkuText(game: GameInfo): string { + const values = Object.values(game.catalogSkuStrings ?? {}); + const parts: string[] = []; + for (const value of values) { + if (typeof value === "string") { + parts.push(value); + } else if (Array.isArray(value)) { + parts.push(...value.filter((item): item is string => typeof item === "string")); + } + } + return parts.join(" "); +} + +function gameRequiresInstallToPlay(game: GameInfo): boolean { + const haystack = [ + game.playType, + game.playabilityState, + ...(game.featureLabels ?? []), + getGameCatalogSkuText(game), + ].join(" ").toLowerCase(); + return haystack.includes("install"); +} + +function getGamePlayTypeFilter(game: GameInfo, t: LibraryTranslation): LibraryFilterOption | null { + if (gameRequiresInstallToPlay(game)) { + return { id: "play:install-to-play", label: t("library.filterOptions.installToPlay"), count: 0 }; + } + if (!game.playType?.trim()) return null; + const normalized = normalizeLibraryFilterValue(game.playType); + if (!normalized) return null; + if (normalized.includes("instant") || normalized.includes("stream") || normalized.includes("cloud")) { + return { id: "play:cloud-launch", label: t("library.filterOptions.cloudLaunch"), count: 0 }; + } + const label = formatLibraryFilterLabel(game.playType); + const key = normalizeLibraryFilterValue(label); + return key ? { id: `play:${key}`, label, count: 0 } : null; +} + +function getGameStoreFilters(game: GameInfo): Array<{ id: string; label: string }> { + const stores = game.availableStores?.length ? game.availableStores : game.variants.map((variant) => variant.store); + const seen = new Set(); + const filters: Array<{ id: string; label: string }> = []; + for (const store of stores) { + if (!store.trim()) continue; + const key = normalizeStoreKey(store); + if (seen.has(key)) continue; + seen.add(key); + filters.push({ id: `platform:${key}`, label: getStoreDisplayName(store) }); + } + return filters; +} + +function getControlFilter(control: string, t: LibraryTranslation): { id: string; label: string } | null { + const normalized = normalizeLibraryFilterValue(control); + if (!normalized) return null; + if (normalized.includes("keyboard") || normalized.includes("mouse")) { + return { id: "controls:keyboard-mouse", label: t("library.filterOptions.keyboardMouse") }; + } + if (normalized.includes("gamepad") || normalized.includes("controller")) { + return { id: "controls:controller", label: t("library.filterOptions.controller") }; + } + if (normalized.includes("touch")) { + return { id: "controls:touch", label: t("library.filterOptions.touch") }; + } + const label = formatLibraryFilterLabel(control); + const key = normalizeLibraryFilterValue(label); + return key ? { id: `controls:${key}`, label } : null; +} + +function getGameControlFilters(game: GameInfo, t: LibraryTranslation): Array<{ id: string; label: string }> { + const controls = [ + ...(game.supportedControls ?? []), + ...game.variants.flatMap((variant) => variant.supportedControls), + ]; + const seen = new Set(); + const filters: Array<{ id: string; label: string }> = []; + for (const control of controls) { + const filter = getControlFilter(control, t); + if (!filter || seen.has(filter.id)) continue; + seen.add(filter.id); + filters.push(filter); + } + return filters; +} + +function upsertLibraryFilterOption(options: Map, id: string, label: string): void { + const existing = options.get(id); + if (existing) { + existing.count += 1; + return; + } + options.set(id, { id, label, count: 1 }); +} + +function mapLibraryFilterOptions(options: Map): LibraryFilterOption[] { + return [...options.values()].sort((left, right) => left.label.localeCompare(right.label)); +} + +function gameHasLibraryActivity(game: GameInfo, playtimeData: PlaytimeData): boolean { + if (game.lastPlayed) return true; + const record = playtimeData[game.id]; + if (!record) return false; + return Boolean(record.lastPlayedAt) || (record.totalSeconds ?? 0) > 0 || (record.sessionCount ?? 0) > 0; +} + +function getLibraryFilterGroups(games: GameInfo[], playtimeData: PlaytimeData, t: LibraryTranslation): LibraryFilterGroup[] { + const playTypeOptions = new Map(); + const platformOptions = new Map(); + const controlOptions = new Map(); + const activityOptions = new Map(); + + for (const game of games) { + const playTypeOption = getGamePlayTypeFilter(game, t); + if (playTypeOption) upsertLibraryFilterOption(playTypeOptions, playTypeOption.id, playTypeOption.label); + + for (const option of getGameStoreFilters(game)) { + upsertLibraryFilterOption(platformOptions, option.id, option.label); + } + + for (const option of getGameControlFilters(game, t)) { + upsertLibraryFilterOption(controlOptions, option.id, option.label); + } + + const hasActivity = gameHasLibraryActivity(game, playtimeData); + upsertLibraryFilterOption( + activityOptions, + hasActivity ? "activity:played" : "activity:never-played", + hasActivity ? t("library.filterOptions.played") : t("library.filterOptions.neverPlayed"), + ); + } + + return [ + { id: "play", label: t("library.filterGroups.playType"), options: mapLibraryFilterOptions(playTypeOptions) }, + { id: "platform", label: t("library.filterGroups.platform"), options: mapLibraryFilterOptions(platformOptions) }, + { id: "controls", label: t("library.filterGroups.controls"), options: mapLibraryFilterOptions(controlOptions) }, + { id: "activity", label: t("library.filterGroups.activity"), options: mapLibraryFilterOptions(activityOptions) }, + ].filter((group) => group.options.length > 0); +} + +function getLibraryFilterGroupId(filterId: string): string { + const separatorIndex = filterId.indexOf(":"); + return separatorIndex >= 0 ? filterId.slice(0, separatorIndex) : filterId; +} + +function gameMatchesLibraryFilter(game: GameInfo, filterId: string, playtimeData: PlaytimeData, t: LibraryTranslation): boolean { + const [groupId, value] = filterId.split(":"); + if (!value) return true; + + if (groupId === "play") { + return getGamePlayTypeFilter(game, t)?.id === filterId; + } + + if (groupId === "platform") { + return getGameStoreFilters(game).some((option) => option.id === filterId); + } + + if (groupId === "controls") { + return getGameControlFilters(game, t).some((option) => option.id === filterId); + } + + if (groupId === "activity") { + const hasActivity = gameHasLibraryActivity(game, playtimeData); + return value === "played" ? hasActivity : !hasActivity; + } + + return true; +} + +function gameMatchesLibraryFilters(game: GameInfo, selectedFilterIds: string[], playtimeData: PlaytimeData, t: LibraryTranslation): boolean { + if (selectedFilterIds.length === 0) return true; + const selectedByGroup = new Map(); + for (const filterId of selectedFilterIds) { + const groupId = getLibraryFilterGroupId(filterId); + selectedByGroup.set(groupId, [...(selectedByGroup.get(groupId) ?? []), filterId]); + } + for (const groupFilterIds of selectedByGroup.values()) { + if (!groupFilterIds.some((filterId) => gameMatchesLibraryFilter(game, filterId, playtimeData, t))) return false; + } + return true; +} + +function getLibraryFilterOptionById(groups: LibraryFilterGroup[], id: string): LibraryFilterOption | undefined { + for (const group of groups) { + const option = group.options.find((candidate) => candidate.id === id); + if (option) return option; + } + return undefined; +} + +function getControllerStoreFilterItems(games: GameInfo[], allStoresLabel: string): ControllerStoreFilterItem[] { + const stores = new Set(); + for (const game of games) { + for (const store of game.availableStores ?? []) { + if (store.trim()) stores.add(store); + } + for (const variant of game.variants) { + if (variant.store.trim()) stores.add(variant.store); + } + } + + return [ + { id: "library", title: allStoresLabel }, + ...[...stores].sort((left, right) => left.localeCompare(right)).map((store) => ({ id: `store:${store}`, title: store })), + ]; +} + +function ControllerGameCard({ + game, + isSelected, + selectedVariantId, + onSelect, + onPlay, +}: { + game: GameInfo; + isSelected: boolean; + selectedVariantId?: string; + onSelect: () => void; + onPlay: () => void; +}): JSX.Element { + const selectedVariant = game.variants.find((variant) => variant.id === selectedVariantId) ?? game.variants[game.selectedVariantIndex] ?? game.variants[0]; + const store = selectedVariant?.store ?? game.availableStores?.[0] ?? ""; + const StoreIcon = getStoreIconComponent(store); + const logoUrl = getGameLogoUrl(game); + + return ( + + + {game.imageUrl ? : {game.title.slice(0, 1)}} + + {store && ( + + + + )} + + {logoUrl ? {game.title} : game.title} + + + ); +} + +export const LibraryPage = memo(function LibraryPage({ + games, + allGames, + playtimeData, + searchQuery, + onSearchChange, + onPlayGame, + onBuyGame, + isLoading, + selectedGameId, + onSelectGame, + selectedVariantByGameId, + onSelectGameVariant, + libraryCount, + sortOptions, + selectedSortId, + onSortChange, + controllerMode = false, + featuredGames = [], + activeSessionAppIds = [], + onPreviousControllerPage, + onNextControllerPage, +}: LibraryPageProps): JSX.Element { + const { t } = useTranslation(); + const catalogActionsRef = useCatalogCardActionsRef({ + onPlayGame, + onSelectGame, + onSelectGameVariant, + }); + const [controllerHeroIndex, setControllerHeroIndex] = useState(0); + const [detailsGame, setDetailsGame] = useState(null); + const [controllerStoreFilterId, setControllerStoreFilterId] = useState("library"); + const [controllerStoreFilterOpen, setControllerStoreFilterOpen] = useState(false); + const [controllerSearchOpen, setControllerSearchOpen] = useState(false); + const [focusedControllerStoreFilterIndex, setFocusedControllerStoreFilterIndex] = useState(0); + const [selectedLibraryFilterIds, setSelectedLibraryFilterIds] = useState([]); + const controllerSearchInputRef = useRef(null); + const gamepadPreviousButtonsRef = useRef(0); + const gamepadLastMoveAtRef = useRef(0); + const gamepadFrameRef = useRef(null); + const controllerYPressedAtRef = useRef(0); + const controllerYConsumedByHoldRef = useRef(false); + const controllerGameRowRef = useRef(null); + const controllerInputStateRef = useRef({ + detailsGame: null as GameInfo | null, + selectedControllerGame: undefined as GameInfo | undefined, + selectedControllerGameIndex: 0, + controllerStoreFilterOpen: false, + focusedControllerStoreFilterIndex: 0, + controllerStoreFilterItems: [] as ControllerStoreFilterItem[], + focusControllerGame: (_index: number): void => {}, + cycleSelectedVariant: (): void => {}, + cycleControllerStoreFilter: (): void => {}, + moveControllerStoreFilterFocusBy: (_delta: number): void => {}, + hideControllerStoreFilterOverlay: (_applySelection: boolean): void => {}, + showControllerStoreFilterOverlay: (): void => {}, + onPlayGame: (_game: GameInfo): void => {}, + }); + + useEffect(() => { + if (!controllerMode || !controllerSearchOpen) return; + controllerSearchInputRef.current?.focus(); + }, [controllerMode, controllerSearchOpen]); + + const librarySearchHasQuery = searchQuery.trim().length > 0; + const libraryFilterGroups = useMemo( + () => getLibraryFilterGroups(allGames, playtimeData, t), + [allGames, playtimeData, t], + ); + const visibleLibraryGames = useMemo( + () => games.filter((game) => gameMatchesLibraryFilters(game, selectedLibraryFilterIds, playtimeData, t)), + [games, playtimeData, selectedLibraryFilterIds, t], + ); + const activeLibraryFilterOptions = useMemo( + () => selectedLibraryFilterIds + .map((filterId) => getLibraryFilterOptionById(libraryFilterGroups, filterId)) + .filter((option): option is LibraryFilterOption => Boolean(option)), + [libraryFilterGroups, selectedLibraryFilterIds], + ); + const hasActiveLibraryFilters = activeLibraryFilterOptions.length > 0; + const libraryCountLabel = hasActiveLibraryFilters || librarySearchHasQuery + ? t("library.filteredGameCount", { shown: visibleLibraryGames.length, total: libraryCount, count: libraryCount }) + : t("library.gameCount", { count: libraryCount }); + + useEffect(() => { + const availableFilterIds = new Set(libraryFilterGroups.flatMap((group) => group.options.map((option) => option.id))); + setSelectedLibraryFilterIds((previous) => { + const next = previous.filter((filterId) => availableFilterIds.has(filterId)); + return next.length === previous.length ? previous : next; + }); + }, [libraryFilterGroups]); + + useEffect(() => { + if (controllerMode || visibleLibraryGames.length === 0) return; + if (visibleLibraryGames.some((game) => game.id === selectedGameId)) return; + onSelectGame(visibleLibraryGames[0].id); + }, [controllerMode, onSelectGame, selectedGameId, visibleLibraryGames]); + + const toggleLibraryFilter = (filterId: string): void => { + setSelectedLibraryFilterIds((previous) => ( + previous.includes(filterId) + ? previous.filter((selectedFilterId) => selectedFilterId !== filterId) + : [...previous, filterId] + )); + }; + + const clearLibraryFilters = (): void => { + setSelectedLibraryFilterIds([]); + }; + + const controllerStoreFilterItems = useMemo( + () => getControllerStoreFilterItems(games, t("library.allStores")), + [games, t], + ); + const controllerGames = useMemo( + () => controllerStoreFilterId === "library" ? games : games.filter((game) => gameMatchesStoreFilter(game, controllerStoreFilterId)), + [controllerStoreFilterId, games], + ); + const controllerFeaturedGames = useMemo( + () => getControllerFeaturedGames(featuredGames, controllerGames), + [featuredGames, controllerGames], + ); + + useEffect(() => { + if (!controllerMode) return; + setControllerHeroIndex(0); + }, [controllerMode, controllerFeaturedGames]); + + useEffect(() => { + if (controllerMode) return; + gamepadPreviousButtonsRef.current = 0; + gamepadLastMoveAtRef.current = 0; + }, [controllerMode]); + + useEffect(() => { + if (!controllerMode || controllerFeaturedGames.length <= 1) return; + const interval = window.setInterval(() => { + setControllerHeroIndex((index) => (index + 1) % controllerFeaturedGames.length); + }, CONTROLLER_HERO_ROTATION_MS); + return () => window.clearInterval(interval); + }, [controllerMode, controllerFeaturedGames.length]); + + useEffect(() => { + if (!controllerMode || games.length === 0) return; + if (controllerGames.some((game) => game.id === selectedGameId)) return; + onSelectGame(controllerGames[0]?.id ?? games[0].id); + }, [controllerGames, controllerMode, games, onSelectGame, selectedGameId]); + + useEffect(() => { + if (controllerStoreFilterItems.some((item) => item.id === controllerStoreFilterId)) return; + setControllerStoreFilterId("library"); + setFocusedControllerStoreFilterIndex(0); + }, [controllerStoreFilterId, controllerStoreFilterItems]); + + const selectedControllerGameIndex = Math.max(0, controllerGames.findIndex((game) => game.id === selectedGameId)); + const selectedControllerGame = controllerGames[selectedControllerGameIndex] ?? controllerGames[0]; + + const focusControllerGame = (index: number): void => { + if (controllerGames.length === 0) return; + const nextIndex = Math.max(0, Math.min(index, controllerGames.length - 1)); + const nextGame = controllerGames[nextIndex]; + onSelectGame(nextGame.id); + window.requestAnimationFrame(() => { + const row = controllerGameRowRef.current; + const card = row?.querySelector(`[data-controller-game-id="${CSS.escape(nextGame.id)}"]`); + card?.scrollIntoView({ inline: "nearest", block: "nearest", behavior: "auto" }); + }); + }; + + const cycleGameVariant = (game: GameInfo | undefined): void => { + if (!game || game.variants.length <= 1) return; + const activeVariantId = selectedVariantByGameId[game.id]; + const activeIndex = Math.max(0, game.variants.findIndex((variant) => variant.id === activeVariantId)); + const nextVariant = game.variants[(activeIndex + 1) % game.variants.length]; + if (nextVariant) onSelectGameVariant(game.id, nextVariant.id); + }; + + const cycleSelectedVariant = (): void => { + cycleGameVariant(selectedControllerGame); + }; + + const cycleControllerStoreFilter = (): void => { + if (controllerStoreFilterItems.length <= 1) return; + const activeIndex = Math.max(0, controllerStoreFilterItems.findIndex((item) => item.id === controllerStoreFilterId)); + const nextItem = controllerStoreFilterItems[(activeIndex + 1) % controllerStoreFilterItems.length]; + setControllerStoreFilterId(nextItem.id); + setFocusedControllerStoreFilterIndex((activeIndex + 1) % controllerStoreFilterItems.length); + setControllerHeroIndex(0); + }; + + const showControllerStoreFilterOverlay = (): void => { + const activeIndex = Math.max(0, controllerStoreFilterItems.findIndex((item) => item.id === controllerStoreFilterId)); + setFocusedControllerStoreFilterIndex(activeIndex); + setControllerStoreFilterOpen(true); + }; + + const moveControllerStoreFilterFocusBy = (delta: number): void => { + if (controllerStoreFilterItems.length === 0) return; + setFocusedControllerStoreFilterIndex((index) => Math.max(0, Math.min(index + delta, controllerStoreFilterItems.length - 1))); + }; + + const hideControllerStoreFilterOverlay = (applySelection: boolean): void => { + if (applySelection) { + const item = controllerStoreFilterItems[focusedControllerStoreFilterIndex] ?? controllerStoreFilterItems[0]; + if (item) { + setControllerStoreFilterId(item.id); + setControllerHeroIndex(0); + } + } + setControllerStoreFilterOpen(false); + }; + + useEffect(() => { + controllerInputStateRef.current = { + detailsGame, + selectedControllerGame, + selectedControllerGameIndex, + controllerStoreFilterOpen, + focusedControllerStoreFilterIndex, + controllerStoreFilterItems, + focusControllerGame, + cycleSelectedVariant, + cycleControllerStoreFilter, + moveControllerStoreFilterFocusBy, + hideControllerStoreFilterOverlay, + showControllerStoreFilterOverlay, + onPlayGame, + }; + }, [controllerStoreFilterItems, controllerStoreFilterOpen, detailsGame, focusedControllerStoreFilterIndex, focusControllerGame, cycleSelectedVariant, cycleControllerStoreFilter, moveControllerStoreFilterFocusBy, hideControllerStoreFilterOverlay, showControllerStoreFilterOverlay, onPlayGame, selectedControllerGame, selectedControllerGameIndex]); + + useEffect(() => { + if (!controllerMode) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (detailsGame) { + if (event.key === "Escape" || event.key.toLowerCase() === "b") { + event.preventDefault(); + setDetailsGame(null); + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onPlayGame(detailsGame); + } + return; + } + if (controllerSearchOpen) { + if (event.key === "Escape") { + event.preventDefault(); + setControllerSearchOpen(false); + } + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + focusControllerGame(selectedControllerGameIndex - 1); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + focusControllerGame(selectedControllerGameIndex + 1); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + cycleSelectedVariant(); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (selectedControllerGame) onPlayGame(selectedControllerGame); + } else if (event.key.toLowerCase() === "x") { + event.preventDefault(); + setControllerSearchOpen(true); + } else if (event.key.toLowerCase() === "b" || event.key === "Escape") { + event.preventDefault(); + onPreviousControllerPage?.(); + } else if (event.key === "[") { + event.preventDefault(); + onPreviousControllerPage?.(); + } else if (event.key === "]") { + event.preventDefault(); + onNextControllerPage?.(); + } else if (event.key.toLowerCase() === "i" || event.key.toLowerCase() === "m") { + event.preventDefault(); + if (selectedControllerGame) setDetailsGame(selectedControllerGame); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [controllerMode, controllerSearchOpen, detailsGame, onNextControllerPage, onPlayGame, onPreviousControllerPage, selectedControllerGame, selectedControllerGameIndex]); + + useEffect(() => { + if (!controllerMode) return; + const readButtons = (): number => { + const pad = navigator.getGamepads?.().find((gamepad): gamepad is Gamepad => Boolean(gamepad)); + return readControllerGamepadButtons(pad); + }; + + const handleGamepadFrame = () => { + const buttons = readButtons(); + let pressed = buttons & ~gamepadPreviousButtonsRef.current; + const released = gamepadPreviousButtonsRef.current & ~buttons; + const moveMask = controllerButton.up | controllerButton.down | controllerButton.left | controllerButton.right; + const yButton = controllerButton.north; + const now = performance.now(); + const activeMoves = buttons & moveMask; + const pressedMoves = pressed & moveMask; + if (pressedMoves) { + gamepadLastMoveAtRef.current = now; + } else if (activeMoves && now - gamepadLastMoveAtRef.current > CONTROLLER_MOVE_REPEAT_MS) { + pressed |= activeMoves; + gamepadLastMoveAtRef.current = now; + } + + const { + detailsGame: currentDetailsGame, + selectedControllerGame: currentSelectedGame, + selectedControllerGameIndex: currentSelectedIndex, + controllerStoreFilterOpen: storeFilterOpen, + focusControllerGame: focusGame, + cycleSelectedVariant: cycleVariant, + cycleControllerStoreFilter: cycleStoreFilter, + moveControllerStoreFilterFocusBy: moveStoreFilter, + hideControllerStoreFilterOverlay: hideStoreFilter, + showControllerStoreFilterOverlay: showStoreFilter, + onPlayGame: playGame, + } = controllerInputStateRef.current; + + if (pressed & yButton) { + controllerYPressedAtRef.current = now; + controllerYConsumedByHoldRef.current = false; + } + + if ((buttons & yButton) && !controllerYConsumedByHoldRef.current && now - controllerYPressedAtRef.current >= CONTROLLER_Y_HOLD_MS) { + controllerYConsumedByHoldRef.current = true; + showStoreFilter(); + } + + if (controllerSearchOpen) { + if (pressed & controllerButton.east) setControllerSearchOpen(false); + gamepadPreviousButtonsRef.current = buttons; + gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); + return; + } + + if (storeFilterOpen) { + if (pressed & controllerButton.up) moveStoreFilter(-1); + if (pressed & controllerButton.down) moveStoreFilter(1); + if (pressed & controllerButton.east) hideStoreFilter(false); + if (released & yButton) hideStoreFilter(true); + gamepadPreviousButtonsRef.current = buttons; + gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); + return; + } + + if (currentDetailsGame) { + if (pressed & controllerButton.south) playGame(currentDetailsGame); + if (pressed & controllerButton.east) setDetailsGame(null); + } else { + if ((released & yButton) && !controllerYConsumedByHoldRef.current) cycleStoreFilter(); + if (pressed & controllerButton.south) { + if (currentSelectedGame) playGame(currentSelectedGame); + } + if (pressed & controllerButton.east) onPreviousControllerPage?.(); + if (pressed & controllerButton.west) setControllerSearchOpen(true); + if (pressed & controllerButton.leftShoulder) onPreviousControllerPage?.(); + if (pressed & controllerButton.rightShoulder) onNextControllerPage?.(); + if (pressed & controllerButton.menu) { + if (currentSelectedGame) setDetailsGame(currentSelectedGame); + } + if (pressed & controllerButton.left) focusGame(currentSelectedIndex - 1); + if (pressed & controllerButton.right) focusGame(currentSelectedIndex + 1); + if (pressed & controllerButton.down) cycleVariant(); + } + gamepadPreviousButtonsRef.current = buttons; + + gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); + }; + + const startGamepadNavigation = () => { + if (gamepadFrameRef.current !== null) return; + gamepadPreviousButtonsRef.current = readButtons(); + gamepadLastMoveAtRef.current = performance.now(); + gamepadFrameRef.current = window.requestAnimationFrame(handleGamepadFrame); + }; + + const stopGamepadNavigation = () => { + if (gamepadFrameRef.current !== null) { + window.cancelAnimationFrame(gamepadFrameRef.current); + gamepadFrameRef.current = null; + } + gamepadPreviousButtonsRef.current = 0; + gamepadLastMoveAtRef.current = 0; + }; + + const handleDisconnect = () => { + const hasConnectedPad = navigator.getGamepads?.().some(Boolean) ?? false; + if (!hasConnectedPad) stopGamepadNavigation(); + }; + + window.addEventListener("gamepadconnected", startGamepadNavigation); + window.addEventListener("gamepaddisconnected", handleDisconnect); + startGamepadNavigation(); + + return () => { + window.removeEventListener("gamepadconnected", startGamepadNavigation); + window.removeEventListener("gamepaddisconnected", handleDisconnect); + stopGamepadNavigation(); + }; + }, [controllerMode, controllerSearchOpen, onNextControllerPage, onPreviousControllerPage]); + + const libraryGridItems = useMemo( + () => visibleLibraryGames.map((game) => ( +
+ + {game.lastPlayed && ( +
+ + {formatCatalogLastPlayed(t, game.lastPlayed)} +
+ )} +
+ )), + [catalogActionsRef, selectedGameId, selectedVariantByGameId, t, visibleLibraryGames], + ); + + if (controllerMode) { + const featuredGame = controllerFeaturedGames[controllerHeroIndex] ?? selectedControllerGame; + const heroImageUrl = featuredGame ? getControllerHeroBackgroundCandidates(featuredGame)[0] : undefined; + const heroLogoUrl = featuredGame ? getControllerHeroLogoUrl(featuredGame) : undefined; + const heroSelectedVariantId = featuredGame ? selectedVariantByGameId[featuredGame.id] : undefined; + const heroStoreLabel = featuredGame ? getSelectedVariantStoreLabel(featuredGame, selectedVariantByGameId[featuredGame.id], t("library.storeNotListed")) : ""; + const featuredGameHasActiveSession = featuredGame ? gameMatchesActiveSession(featuredGame, activeSessionAppIds) : false; + const heroShouldBuy = Boolean(featuredGame && !featuredGameHasActiveSession && !featuredGame.isInLibrary); + const dotCount = Math.min(Math.max(controllerFeaturedGames.length, 1), 6); + const activeDotIndex = dotCount > 0 && controllerFeaturedGames.length > 0 ? Math.min(controllerHeroIndex, dotCount - 1) : 0; + + return ( +
+ {isLoading ? ( +
+ +

{t("library.empty.loadingLibrary")}

+
+ ) : libraryCount === 0 ? ( +
+ +

{t("library.empty.libraryEmpty")}

+

{t("library.empty.ownedGamesAppearHere")}

+
+ ) : featuredGame ? ( + <> +
+ + {heroImageUrl ? ( + + ) : ( + + )} + +
+ + + {heroLogoUrl ? ( + {featuredGame.title} + ) : ( +

{featuredGame.title}

+ )} +
+ + {heroStoreLabel && {heroStoreLabel}} + +
+
+
+
+ + + +
+
+

{t("library.controllerTitle")}

+ {t("library.gameCount", { count: controllerGames.length })} +
+ {controllerGames.length === 0 ? ( +
+ +

{t("library.empty.noGamesFound")}

+

{t("library.empty.noGamesMatch", { query: searchQuery })}

+
+ ) : ( +
+ {controllerGames.map((game) => ( +
+ onSelectGame(game.id)} + onPlay={() => onPlayGame(game)} + selectedVariantId={selectedVariantByGameId[game.id]} + /> +
+ ))} +
+ )} +
+ + + + + {controllerStoreFilterOpen && ( + + + {t("library.storeFilter")} +

{t("library.chooseStore")}

+

{t("library.storeFilterHint")}

+
+ {controllerStoreFilterItems.map((item, index) => ( + + ))} +
+
+
+ )} +
+ + + {controllerSearchOpen && ( + + + {t("app.actions.search")} + onSearchChange(event.target.value)} + placeholder={t("library.searchPlaceholder")} + className="controller-search-input" + /> +
+ +
+
+
+ )} +
+ + + {detailsGame && ( + + +

{detailsGame.title}

+

{t("library.selectedStore", { store: getGameStoreSummary(detailsGame, t("library.storeNotListed")) })}

+

{detailsGame.description || detailsGame.longDescription || detailsGame.featureLabels?.join(" / ") || t("library.loadingGameDetails")}

+
+ {detailsGame.developerName && {t("library.developer", { developer: detailsGame.developerName })}} + {detailsGame.publisherName && {t("library.publisher", { publisher: detailsGame.publisherName })}} + {getPlayerSummary(detailsGame) && {t("library.players", { players: getPlayerSummary(detailsGame) })}} + {detailsGame.supportedControls?.length ? {t("library.controls", { controls: detailsGame.supportedControls.slice(0, 4).join(", ") })} : null} + {detailsGame.nvidiaTech?.length ? {t("library.nvidiaTech", { tech: detailsGame.nvidiaTech.slice(0, 4).join(", ") })} : null} + {detailsGame.genres?.length ? {t("library.genres", { genres: detailsGame.genres.slice(0, 4).join(", ") })} : null} + {detailsGame.contentRatings?.length ? {t("library.rating", { rating: detailsGame.contentRatings.slice(0, 2).join(", ") })} : null} +
+
+ + +
+
+
+ )} +
+ + ) : null} +
+ ); + } + + return ( +
+
+
+ +

{t("library.title")}

+
+ +
+ + onSearchChange(e.target.value)} + placeholder={t("library.searchPlaceholder")} + className="library-search-input" + /> +
+ + {libraryFilterGroups.length > 0 && ( +
+ + + + {t("library.filters")} + + {selectedLibraryFilterIds.length > 0 && {selectedLibraryFilterIds.length}} + + +
+ {libraryFilterGroups.map((group) => ( +
+
{group.label}
+
+ {group.options.map((option) => { + const active = selectedLibraryFilterIds.includes(option.id); + return ( + + ); + })} +
+
+ ))} +
+
+ )} + + {sortOptions.length > 0 && ( +
+ + ({ value: option.id, label: option.label }))} + onChange={onSortChange} + ariaLabel={t("library.sortAriaLabel")} + /> +
+ )} + + {libraryCountLabel} +
+ + + {activeLibraryFilterOptions.length > 0 && ( + + {t("library.activeFilters")} + {activeLibraryFilterOptions.map((option) => ( + + ))} + + + )} + + +
+ {isLoading ? ( +
+ +

{t("library.empty.loadingLibrary")}

+
+ ) : libraryCount === 0 ? ( +
+ +

{t("library.empty.libraryEmpty")}

+

{t("library.empty.ownedGamesAppearHere")}

+
+ ) : visibleLibraryGames.length === 0 ? ( +
+ +

{hasActiveLibraryFilters && !librarySearchHasQuery ? t("library.empty.noFilteredGames") : t("library.empty.noGamesFound")}

+

+ {librarySearchHasQuery + ? t("library.empty.noGamesMatch", { query: searchQuery }) + : hasActiveLibraryFilters + ? t("library.empty.tryAdjustingFilters") + : t("library.empty.noGamesMatch", { query: searchQuery })} +

+
+ ) : ( +
+ {libraryGridItems} +
+ )} +
+
+ ); +}); From 8d41360325b1160f27b91e7b8fc83412c14051a9 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Thu, 9 Jul 2026 14:33:27 -0500 Subject: [PATCH 3/4] controller: controllerMode wiring + CSS pointer-events fix --- opennow-stable/src/renderer/src/App.tsx | 25 ++-- .../renderer/src/components/SettingsPage.tsx | 55 +++++++- .../renderer/src/components/StreamLoading.tsx | 53 +++++++- opennow-stable/src/renderer/src/styles.css | 118 +++++++++++++++++- .../renderer/src/utils/controllerGamepad.ts | 28 +++-- 5 files changed, 246 insertions(+), 33 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 77a78403b..ac093ce6d 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -4606,6 +4606,7 @@ export function App(): JSX.Element { gameTitle={streamingGame?.title ?? t("app.labels.game")} gameCover={streamingGame?.imageUrl} platformStore={streamingStore ?? undefined} + controllerMode={effectiveControllerMode} status={loadingStatus} queuePosition={queuePosition} adState={effectiveAdState} @@ -4750,17 +4751,19 @@ export function App(): JSX.Element { onExitComplete={handleSettingsExitComplete} > {settingsMounted && ( - + )} {logoutConfirmModal} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index e4c1e8082..8ecdd4faa 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -42,6 +42,7 @@ import { formatShortcutForDisplay, normalizeShortcut, shortcutFromKeyboardEvent import { getCodecDecodeBadgeState, shouldShowLinuxHardwareCodecHint, type CodecTestResult } from "../lib/codecDiagnostics"; import { getAccentColorOption, getAccentColorOptions } from "../lib/uiCustomization"; import { useTranslation } from "../i18n"; +import { controllerButton, readControllerGamepadButtons } from "../utils/controllerGamepad"; import { clearStoredRegionPingResults, loadStoredRegionPingResults, @@ -57,6 +58,8 @@ interface SettingsPageProps { onRunCodecTest: () => Promise; onSettingChange: (key: K, value: Settings[K]) => void; onClose: () => void; + returnPageLabel: string; + controllerMode?: boolean; focusSection?: SettingsSectionId; /** Called when the user clicks "What's new" in the About section */ onOpenWhatsNew?: () => void; @@ -716,7 +719,19 @@ function saveCachedEntitledResolutions(cache: EntitledResolutionsCache): void { /* ── Component ────────────────────────────────────────────────────── */ -export function SettingsPage({ settings, regions, onSettingChange, codecResults, codecTesting, onRunCodecTest, onClose, focusSection, onOpenWhatsNew }: SettingsPageProps): JSX.Element { +export function SettingsPage({ + settings, + regions, + onSettingChange, + codecResults, + codecTesting, + onRunCodecTest, + onClose, + returnPageLabel, + controllerMode = false, + focusSection, + onOpenWhatsNew, +}: SettingsPageProps): JSX.Element { const { locale, availableLocales, setLocale, t } = useTranslation(); const [savedIndicator, setSavedIndicator] = useState(false); const [activeSection, setActiveSection] = useState("stream"); @@ -2412,6 +2427,33 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, }; }, [nativeStreamerEnablePromptVisible, onClose, zortosCommunityProxyPromptVisible]); + useEffect(() => { + if (!controllerMode) return; + + let frameId: number | null = null; + const readButtons = (): number => { + const pad = navigator.getGamepads?.().find((gamepad): gamepad is Gamepad => Boolean(gamepad)); + return readControllerGamepadButtons(pad); + }; + + const handleFrame = () => { + if (!nativeStreamerEnablePromptVisible && !zortosCommunityProxyPromptVisible) { + const buttons = readButtons(); + if (buttons & controllerButton.west) { + onClose(); + } + } + frameId = window.requestAnimationFrame(handleFrame); + }; + + frameId = window.requestAnimationFrame(handleFrame); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + }; + }, [controllerMode, nativeStreamerEnablePromptVisible, onClose, zortosCommunityProxyPromptVisible]); + return ( <>
@@ -2470,6 +2512,14 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, ))} +
+ {t("settings.backTo")} + + {returnPageLabel} +
{/* ── Content ───────────────────────────────────────── */} @@ -4680,7 +4730,8 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, )} - + + {nativeStreamerEnablePromptVisible && (
{ + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onCancel]); + + useEffect(() => { + if (!controllerMode) return; + + let frameId: number | null = null; + let previousButtons = 0; + + const readButtons = (): number => { + const pad = navigator.getGamepads?.().find((gamepad): gamepad is Gamepad => Boolean(gamepad)); + return readControllerGamepadButtons(pad); + }; + + const handleFrame = () => { + const buttons = readButtons(); + const pressed = buttons & ~previousButtons; + if (pressed & controllerButton.west) { + onCancel(); + } + previousButtons = buttons; + frameId = window.requestAnimationFrame(handleFrame); + }; + + frameId = window.requestAnimationFrame(handleFrame); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + }; + }, [controllerMode, onCancel]); + return (
@@ -238,7 +285,11 @@ export function StreamLoading({ )}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index f8b27e059..c3627f5fd 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2069,8 +2069,7 @@ body, .controller-store-hero, .controller-store-sections, -.controller-store-empty, -.controller-store-page .controller-bottom-hints { +.controller-store-empty { position: relative; z-index: 1; } @@ -2131,6 +2130,7 @@ body, flex-direction: column; gap: clamp(24px, 3.1vh, 46px); margin-top: clamp(28px, 3.4vh, 58px); + margin-bottom: clamp(48px, 9vh, 114px); } .controller-store-section { @@ -3573,9 +3573,8 @@ body, display: flex; align-items: center; gap: clamp(18px, 2.4vw, 74px); - pointer-events: none; + pointer-events: auto; } - .controller-hint { display: inline-flex; align-items: center; @@ -3584,6 +3583,20 @@ body, font-size: clamp(0.76rem, 0.68vw, 1.05rem); font-weight: 850; white-space: nowrap; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease; +} +.controller-hint:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.24); +} +.controller-hint:active { + transform: scale(0.97); + background: rgba(255, 255, 255, 0.18); } .controller-hint--more { @@ -3621,7 +3634,7 @@ body, .controller-search-overlay { position: fixed; inset: 0; - z-index: 2190; + z-index: 1010; display: flex; align-items: center; justify-content: center; @@ -3683,6 +3696,16 @@ body, box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.2); } +.controller-search-actions { + display: flex; + justify-content: flex-end; + margin-top: 18px; +} + +.controller-search-actions .controller-secondary-action { + min-width: 132px; +} + .controller-store-filter-options { display: flex; flex-direction: column; @@ -4558,6 +4581,30 @@ button.game-card-store-chip.owned.active:hover { gap: 14px; } +.settings-sidebar-footer { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: auto; + padding-top: 14px; + border-top: 1px solid var(--panel-border); +} + +.settings-sidebar-footer .settings-footer-kicker { + color: var(--ink-muted); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-footer-destination { + color: var(--ink-soft); + font-size: 0.78rem; + font-weight: 650; + line-height: 1.3; +} + .settings-nav-group { display: flex; flex-direction: column; @@ -6513,7 +6560,66 @@ button.game-card-store-chip.owned.active:hover { /* Footer */ .settings-footer { display: flex; - justify-content: flex-end; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 22px 20px; + border-top: 1px solid var(--panel-border); + flex-shrink: 0; +} + +.settings-footer-context { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.settings-footer-kicker { + color: var(--ink-muted); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-footer-context strong { + color: var(--ink); + font-size: 0.95rem; + font-weight: 700; +} + +.settings-back-btn { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + min-height: 38px; + padding: 0 14px; + background: color-mix(in srgb, var(--accent) 8%, var(--bg-c)); + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--panel-border-solid)); + border-radius: var(--r-sm); + color: var(--ink); + font-size: 0.92rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; + transition: transform var(--t-normal), background var(--t-normal), border-color var(--t-normal), color var(--t-normal); +} + +.settings-back-btn svg { + flex-shrink: 0; +} + +.settings-back-btn:hover { + transform: translateY(-1px); + background: color-mix(in srgb, var(--accent) 12%, var(--card-hover)); + border-color: color-mix(in srgb, var(--accent) 24%, var(--panel-border-solid)); +} + +.settings-back-btn:active { + transform: translateY(0); } .settings-save-btn { diff --git a/opennow-stable/src/renderer/src/utils/controllerGamepad.ts b/opennow-stable/src/renderer/src/utils/controllerGamepad.ts index f7ac376b6..44c91cb59 100644 --- a/opennow-stable/src/renderer/src/utils/controllerGamepad.ts +++ b/opennow-stable/src/renderer/src/utils/controllerGamepad.ts @@ -14,17 +14,19 @@ export const controllerButton = { export function readControllerGamepadButtons(pad: Gamepad | undefined): number { if (!pad) return 0; - let buttons = 0; - if (pad.buttons[0]?.pressed) buttons |= controllerButton.south; - if (pad.buttons[1]?.pressed) buttons |= controllerButton.east; - if (pad.buttons[2]?.pressed) buttons |= controllerButton.west; - if (pad.buttons[3]?.pressed) buttons |= controllerButton.north; - if (pad.buttons[4]?.pressed) buttons |= controllerButton.leftShoulder; - if (pad.buttons[5]?.pressed) buttons |= controllerButton.rightShoulder; - if (pad.buttons[12]?.pressed || (pad.axes[1] ?? 0) < -0.65) buttons |= controllerButton.up; - if (pad.buttons[13]?.pressed || (pad.axes[1] ?? 0) > 0.65) buttons |= controllerButton.down; - if (pad.buttons[14]?.pressed || (pad.axes[0] ?? 0) < -0.65) buttons |= controllerButton.left; - if (pad.buttons[15]?.pressed || (pad.axes[0] ?? 0) > 0.65) buttons |= controllerButton.right; - if (pad.buttons[9]?.pressed || pad.buttons[16]?.pressed) buttons |= controllerButton.menu; - return buttons; + const buttons = pad.buttons ?? []; + const axes = pad.axes ?? []; + let result = 0; + if (buttons[0]?.pressed) result |= controllerButton.south; + if (buttons[1]?.pressed) result |= controllerButton.east; + if (buttons[2]?.pressed) result |= controllerButton.west; + if (buttons[3]?.pressed) result |= controllerButton.north; + if (buttons[4]?.pressed) result |= controllerButton.leftShoulder; + if (buttons[5]?.pressed) result |= controllerButton.rightShoulder; + if (buttons[12]?.pressed || (axes[1] ?? 0) < -0.65) result |= controllerButton.up; + if (buttons[13]?.pressed || (axes[1] ?? 0) > 0.65) result |= controllerButton.down; + if (buttons[14]?.pressed || (axes[0] ?? 0) < -0.65) result |= controllerButton.left; + if (buttons[15]?.pressed || (axes[0] ?? 0) > 0.65) result |= controllerButton.right; + if (buttons[9]?.pressed || buttons[16]?.pressed) result |= controllerButton.menu; + return result; } From e034cf852a2f427055e837c85062975d4fc1bbd1 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Thu, 9 Jul 2026 20:47:19 -0500 Subject: [PATCH 4/4] fix: address PR 621 review issues --- .../src/renderer/src/components/HomePage.tsx | 45 ++++++++++--------- .../renderer/src/components/SettingsPage.tsx | 11 ++++- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/HomePage.tsx b/opennow-stable/src/renderer/src/components/HomePage.tsx index 1853ad0e1..a31ab3786 100644 --- a/opennow-stable/src/renderer/src/components/HomePage.tsx +++ b/opennow-stable/src/renderer/src/components/HomePage.tsx @@ -461,7 +461,8 @@ export const HomePage = memo(function HomePage({ if (pressed & controllerButton.west) setControllerSearchOpen(true); if (pressed & controllerButton.leftShoulder) onPreviousControllerPage?.(); if (pressed & controllerButton.rightShoulder) onNextControllerPage?.(); - if (pressed & controllerButton.menu) onNextControllerPage?.(); + if (pressed & controllerButton.menu) onNextControllerPage?.(); + if (pressed & controllerButton.north) cycleControllerVariant(); if (pressed & controllerButton.up) focusControllerTile(rowIndex - 1, columnIndex); if (pressed & controllerButton.down) focusControllerTile(rowIndex + 1, columnIndex); if (pressed & controllerButton.left) focusControllerTile(rowIndex, columnIndex - 1); @@ -650,7 +651,7 @@ export const HomePage = memo(function HomePage({ ))}
-