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/HomePage.tsx b/opennow-stable/src/renderer/src/components/HomePage.tsx
index f52ed9871..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) cycleControllerVariant();
+ 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,11 +651,27 @@ export const HomePage = memo(function HomePage({
))}
-
-
A{t("app.actions.select")}
-
B{t("app.actions.back")}
-
X{t("app.actions.search")}
-
{t("library.moreOptions")}
+
+
+
+
+
+
@@ -685,7 +702,11 @@ export const HomePage = memo(function HomePage({
placeholder={t("home.searchPlaceholder")}
className="controller-search-input"
/>
- {t("app.actions.back")}
+
+
+
)}
diff --git a/opennow-stable/src/renderer/src/components/LibraryPage.tsx b/opennow-stable/src/renderer/src/components/LibraryPage.tsx
index a7954271e..67cbd6469 100644
--- a/opennow-stable/src/renderer/src/components/LibraryPage.tsx
+++ b/opennow-stable/src/renderer/src/components/LibraryPage.tsx
@@ -1,1219 +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}
-
-
- );
-}
-
-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}
- )}
-
-
- {heroStoreLabel && {heroStoreLabel}}
-
-
-
-
-
-
-
- {Array.from({ length: dotCount }).map((_, index) => (
-
- ))}
-
-
-
-
-
{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]}
- />
-
- ))}
-
- )}
-
-
-
-
A{t("app.actions.select")}
-
B{t("app.actions.back")}
-
Y{t("library.filter")}
-
X{t("app.actions.search")}
-
{t("library.moreOptions")}
-
-
-
- {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}
+
+
+ );
+}
+
+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}
+ )}
+
+
+ {heroStoreLabel && {heroStoreLabel}}
+
+
+
+
+
+
+
+ {Array.from({ length: dotCount }).map((_, index) => (
+
+ ))}
+
+
+
+
+
{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}
+
+ )}
+
+
+ );
+});
diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx
index e4c1e8082..98397648d 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,36 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
};
}, [nativeStreamerEnablePromptVisible, onClose, zortosCommunityProxyPromptVisible]);
+ 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 = () => {
+ if (!nativeStreamerEnablePromptVisible && !zortosCommunityProxyPromptVisible) {
+ const buttons = readButtons();
+ const pressed = buttons & ~previousButtons;
+ if (pressed & controllerButton.west) {
+ onClose();
+ }
+ previousButtons = buttons;
+ }
+ frameId = window.requestAnimationFrame(handleFrame);
+ };
+
+ frameId = window.requestAnimationFrame(handleFrame);
+ return () => {
+ if (frameId !== null) {
+ window.cancelAnimationFrame(frameId);
+ }
+ };
+ }, [controllerMode, nativeStreamerEnablePromptVisible, onClose, zortosCommunityProxyPromptVisible]);
+
return (
<>
@@ -2470,6 +2515,18 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
))}
+ {
+ 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;
}