diff --git a/.gitignore b/.gitignore
index a556d6db0..8dab8388b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,23 @@
-.vscode/
-node_modules/
-__pycache__
-dist
-out
-.DS_Store
-*.log*
-.env
-.vite
-ludusavi/**
-!ludusavi/config.yaml
-hydra-python-rpc/
-/hydra-native/
-native/hydra-native/target/
-.python-version
-
-# Sentry Config File
-.env.sentry-build-plugin
-
-*storybook.log
-Future-updates.md
-TO-DO.md
+.vscode/
+node_modules/
+__pycache__
+dist
+out
+.DS_Store
+*.log*
+*.tsbuildinfo
+.env
+.vite
+ludusavi/**
+!ludusavi/config.yaml
+hydra-python-rpc/
+/hydra-native/
+native/hydra-native/target/
+.python-version
+
+# Sentry Config File
+.env.sentry-build-plugin
+
+*storybook.log
+Future-updates.md
+TO-DO.md
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index c7833bf1c..1677bb6c7 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -1,12 +1,13 @@
-import { resolve } from "path";
+import react from "@vitejs/plugin-react";
import {
defineConfig,
+ externalizeDepsPlugin,
loadEnv,
swcPlugin,
- externalizeDepsPlugin,
} from "electron-vite";
-import react from "@vitejs/plugin-react";
+import { resolve } from "path";
import svgr from "vite-plugin-svgr";
+import { scopeBigPictureCss } from "./src/big-picture/vite-scope-big-picture-css";
export default defineConfig(({ mode }) => {
loadEnv(mode);
@@ -29,11 +30,35 @@ export default defineConfig(({ mode }) => {
preload: {
plugins: [externalizeDepsPlugin()],
},
+ bigPicture: {
+ root: "src/big-picture",
+ build: {
+ outDir: "out/big-picture",
+ rollupOptions: {
+ input: resolve("src/big-picture/index.html"),
+ },
+ },
+ css: {
+ postcss: {
+ plugins: [scopeBigPictureCss()],
+ },
+ },
+ resolve: {
+ alias: {
+ "@locales": resolve("src/locales"),
+ "@shared": resolve("src/shared"),
+ },
+ },
+ plugins: [react()],
+ },
renderer: {
build: {
sourcemap: true,
},
css: {
+ postcss: {
+ plugins: [scopeBigPictureCss()],
+ },
preprocessorOptions: {
scss: {
api: "modern",
diff --git a/package.json b/package.json
index c6331f730..8a173407d 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"build:win": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --win",
"build:mac": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --mac",
"build:linux": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --linux",
+ "dev:big-picture": "vite dev src/big-picture",
"prepare": "husky",
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
},
@@ -37,10 +38,13 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/noto-sans": "^5.2.10",
+ "@fontsource/space-grotesk": "^5.2.10",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.6.0",
+ "@phosphor-icons/react": "^2.1.10",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^10.33.0",
"@tiptap/extension-bold": "^3.6.2",
@@ -102,7 +106,8 @@
"workwonders-sdk": "0.4.2",
"ws": "^8.18.1",
"yaml": "^2.8.3",
- "yup": "^1.5.0"
+ "yup": "^1.5.0",
+ "zustand": "^5.0.12"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",
diff --git a/src/big-picture/index.html b/src/big-picture/index.html
new file mode 100644
index 000000000..1e7c8254f
--- /dev/null
+++ b/src/big-picture/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Hydra Big Picture
+
+
+
+
+
+
+
diff --git a/src/big-picture/src/app.tsx b/src/big-picture/src/app.tsx
new file mode 100644
index 000000000..f65a6daf4
--- /dev/null
+++ b/src/big-picture/src/app.tsx
@@ -0,0 +1,83 @@
+import { Fragment, useEffect } from "react";
+import { Outlet, useLocation } from "react-router-dom";
+import {
+ BIG_PICTURE_APP_LAYER_ID,
+ BIG_PICTURE_CONTENT_REGION_ID,
+ BIG_PICTURE_SHELL_REGION_ID,
+ getBigPictureSidebarItemIdFromPathname,
+ Header,
+ Sidebar,
+} from "./layout";
+import { IS_DESKTOP } from "./constants";
+import {
+ HorizontalFocusGroup,
+ NavigationLayer,
+ NavigationAutoScrollBridge,
+ NavigationInputProvider,
+ NavigationStateBridge,
+ NavigationDiagnostics,
+ VerticalFocusGroup,
+} from "./components";
+import type { FocusOverrides } from "./services";
+
+import "./styles/globals.scss";
+
+export default function App() {
+ const { pathname } = useLocation();
+ const showNavigationDiagnostics = import.meta.env.DEV;
+ const activeSidebarItemId = getBigPictureSidebarItemIdFromPathname(pathname);
+ const contentNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: activeSidebarItemId,
+ },
+ };
+
+ useEffect(() => {
+ if (!IS_DESKTOP) {
+ document.documentElement.style.colorScheme = "dark";
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showNavigationDiagnostics &&
}
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/accordion/index.tsx b/src/big-picture/src/components/common/accordion/index.tsx
new file mode 100644
index 000000000..4b5301aac
--- /dev/null
+++ b/src/big-picture/src/components/common/accordion/index.tsx
@@ -0,0 +1,118 @@
+import "./styles.scss";
+
+import { useState, useEffect, type ReactNode } from "react";
+import { Typography } from "../typography";
+import { CaretUpIcon } from "@phosphor-icons/react";
+import { AnimatePresence, motion } from "framer-motion";
+import cn from "classnames";
+
+export interface AccordionProps {
+ title: string;
+ hint?: string;
+ open?: boolean;
+ icon?: ReactNode;
+ children: ReactNode;
+ onOpenChange?: (isOpen: boolean) => void;
+}
+
+interface AccordionHeaderProps {
+ title: string;
+ hint?: string;
+ icon?: ReactNode;
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ onOpenChange?: (isOpen: boolean) => void;
+}
+
+interface AccordionContentProps {
+ children: ReactNode;
+}
+
+function AccordionHeader({
+ title,
+ hint,
+ icon,
+ isOpen,
+ setIsOpen,
+ onOpenChange,
+}: Readonly) {
+ return (
+
+ );
+}
+
+function AccordionContent({ children }: Readonly) {
+ return {children}
;
+}
+
+export function Accordion({
+ title,
+ hint,
+ icon,
+ open = false,
+ children,
+ onOpenChange,
+}: Readonly) {
+ const [isOpen, setIsOpen] = useState(open);
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {children}
+
+ )}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/accordion/styles.scss b/src/big-picture/src/components/common/accordion/styles.scss
new file mode 100644
index 000000000..c11c0075e
--- /dev/null
+++ b/src/big-picture/src/components/common/accordion/styles.scss
@@ -0,0 +1,64 @@
+.accordion {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-unit);
+}
+.accordion__header {
+ display: flex;
+ flex-direction: row;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+ background-color: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+ transition: border-radius 0.05s ease-in-out;
+ border-radius: calc(var(--spacing-unit) * 2);
+
+ &.accordion__header--open {
+ border-radius: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 2)
+ var(--spacing-unit) var(--spacing-unit);
+ }
+}
+
+.accordion__header__label,
+.accordion__header__indicators {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ text-transform: capitalize;
+
+ > svg {
+ color: currentColor;
+ }
+}
+
+.accordion__header__indicators {
+ color: var(--text-secondary);
+}
+
+.accordion__header__indicators__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: currentColor;
+
+ > svg {
+ align-items: center;
+ justify-content: center;
+ color: currentColor;
+ }
+}
+
+.accordion__content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ background-color: var(--surface);
+ padding: calc(var(--spacing-unit) * 4);
+ border-radius: var(--spacing-unit) var(--spacing-unit)
+ calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 2);
+}
diff --git a/src/big-picture/src/components/common/animated-hero-image/index.tsx b/src/big-picture/src/components/common/animated-hero-image/index.tsx
new file mode 100644
index 000000000..65ad10d11
--- /dev/null
+++ b/src/big-picture/src/components/common/animated-hero-image/index.tsx
@@ -0,0 +1,406 @@
+import "./styles.scss";
+
+import { motion, type HTMLMotionProps } from "framer-motion";
+import { useCallback, useEffect, useRef } from "react";
+
+export interface AnimatedHeroImageProps
+ extends Omit, "src"> {
+ imageUrl: string;
+}
+
+const FALLBACK_BACKGROUND_COLOR = "rgba(8, 8, 8, 1)";
+
+const RGBA_WITH_ALPHA =
+ /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*[\d.]+\s*\)$/;
+
+function getBackgroundColor(element: HTMLElement) {
+ const elementStyles = globalThis.getComputedStyle(element);
+ const rootStyles = globalThis.getComputedStyle(
+ globalThis.document.documentElement
+ );
+
+ return (
+ elementStyles.getPropertyValue("--background").trim() ||
+ rootStyles.getPropertyValue("--background").trim() ||
+ elementStyles.backgroundColor ||
+ FALLBACK_BACKGROUND_COLOR
+ );
+}
+
+function withAlpha(color: string, alpha: number) {
+ const normalizedAlpha = Math.max(0, Math.min(1, alpha));
+
+ if (color.startsWith("rgb(")) {
+ return color.replace("rgb(", "rgba(").replace(")", `, ${normalizedAlpha})`);
+ }
+
+ const rgbaMatch = RGBA_WITH_ALPHA.exec(color);
+
+ if (rgbaMatch) {
+ return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${normalizedAlpha})`;
+ }
+
+ return FALLBACK_BACKGROUND_COLOR;
+}
+
+function getCoverCrop(
+ imageWidth: number,
+ imageHeight: number,
+ containerWidth: number,
+ containerHeight: number
+) {
+ const imageAspectRatio = imageWidth / imageHeight;
+ const containerAspectRatio = containerWidth / containerHeight;
+
+ if (imageAspectRatio > containerAspectRatio) {
+ const cropWidth = imageHeight * containerAspectRatio;
+
+ return {
+ sourceX: (imageWidth - cropWidth) / 2,
+ sourceY: 0,
+ sourceWidth: cropWidth,
+ sourceHeight: imageHeight,
+ };
+ }
+
+ const cropHeight = imageWidth / containerAspectRatio;
+
+ return {
+ sourceX: 0,
+ sourceY: (imageHeight - cropHeight) / 2,
+ sourceWidth: imageWidth,
+ sourceHeight: cropHeight,
+ };
+}
+
+function createLayer(width: number, height: number, devicePixelRatio: number) {
+ const canvas = globalThis.document.createElement("canvas");
+ canvas.width = Math.round(width * devicePixelRatio);
+ canvas.height = Math.round(height * devicePixelRatio);
+
+ const context = canvas.getContext("2d");
+
+ if (!context) return null;
+
+ context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+
+ return { canvas, context };
+}
+
+export function AnimatedHeroImage({
+ imageUrl,
+ alt = "",
+ className = "",
+ onLoad,
+ onError,
+ ...props
+}: Readonly) {
+ const containerRef = useRef(null);
+ const imageRef = useRef(null);
+ const blendCanvasRef = useRef(null);
+ const frameRef = useRef(null);
+
+ const clearBlendCanvas = useCallback(() => {
+ const canvas = blendCanvasRef.current;
+
+ if (!canvas) return;
+
+ const context = canvas.getContext("2d");
+
+ if (!context) return;
+
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ }, []);
+
+ const renderBlend = useCallback(() => {
+ const container = containerRef.current;
+ const image = imageRef.current;
+ const canvas = blendCanvasRef.current;
+
+ if (
+ !container ||
+ !image ||
+ !canvas ||
+ !image.complete ||
+ !image.naturalWidth
+ ) {
+ clearBlendCanvas();
+ return;
+ }
+
+ const bounds = container.getBoundingClientRect();
+ const width = bounds.width;
+ const height = bounds.height;
+
+ if (width <= 0 || height <= 0) {
+ clearBlendCanvas();
+ return;
+ }
+
+ const devicePixelRatio = Math.min(globalThis.devicePixelRatio || 1, 2);
+ const expectedWidth = Math.round(width * devicePixelRatio);
+ const expectedHeight = Math.round(height * devicePixelRatio);
+
+ if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
+ canvas.width = expectedWidth;
+ canvas.height = expectedHeight;
+ }
+
+ const context = canvas.getContext("2d");
+
+ if (!context) return;
+
+ context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+ context.clearRect(0, 0, width, height);
+
+ const backgroundColor = getBackgroundColor(container);
+ const { sourceX, sourceY, sourceWidth, sourceHeight } = getCoverCrop(
+ image.naturalWidth,
+ image.naturalHeight,
+ width,
+ height
+ );
+
+ const bottomLayer = createLayer(width, height, devicePixelRatio);
+ const leftLayer = createLayer(width, height, devicePixelRatio);
+ const rightLayer = createLayer(width, height, devicePixelRatio);
+ if (!bottomLayer || !leftLayer || !rightLayer) return;
+
+ const bottomSourceHeight = sourceHeight * 0.34;
+ const sideSourceWidth = sourceWidth * 0.075;
+
+ bottomLayer.context.save();
+ bottomLayer.context.filter = `blur(${Math.max(32, height * 0.08)}px) saturate(1.08)`;
+ bottomLayer.context.globalAlpha = 0.56;
+ bottomLayer.context.drawImage(
+ image,
+ sourceX,
+ sourceY + sourceHeight - bottomSourceHeight,
+ sourceWidth,
+ bottomSourceHeight,
+ -width * 0.03,
+ height * 0.28,
+ width * 1.06,
+ height * 0.9
+ );
+ bottomLayer.context.filter = `blur(${Math.max(64, height * 0.13)}px)`;
+ bottomLayer.context.globalAlpha = 0.28;
+ bottomLayer.context.drawImage(
+ image,
+ sourceX,
+ sourceY + sourceHeight - bottomSourceHeight * 0.8,
+ sourceWidth,
+ bottomSourceHeight * 0.8,
+ -width * 0.06,
+ height * 0.36,
+ width * 1.12,
+ height * 0.84
+ );
+ bottomLayer.context.restore();
+
+ bottomLayer.context.globalCompositeOperation = "destination-in";
+ const bottomMask = bottomLayer.context.createLinearGradient(
+ 0,
+ 0,
+ 0,
+ height
+ );
+ bottomMask.addColorStop(0, "rgba(0, 0, 0, 0)");
+ bottomMask.addColorStop(0.22, "rgba(0, 0, 0, 0)");
+ bottomMask.addColorStop(0.42, "rgba(0, 0, 0, 0.03)");
+ bottomMask.addColorStop(0.62, "rgba(0, 0, 0, 0.16)");
+ bottomMask.addColorStop(0.78, "rgba(0, 0, 0, 0.42)");
+ bottomMask.addColorStop(0.92, "rgba(0, 0, 0, 0.72)");
+ bottomMask.addColorStop(1, "rgba(0, 0, 0, 1)");
+ bottomLayer.context.fillStyle = bottomMask;
+ bottomLayer.context.fillRect(0, 0, width, height);
+ bottomLayer.context.globalCompositeOperation = "destination-out";
+
+ const seamSoftener = bottomLayer.context.createLinearGradient(
+ 0,
+ height * 0.2,
+ 0,
+ height * 0.58
+ );
+ seamSoftener.addColorStop(0, "rgba(0, 0, 0, 1)");
+ seamSoftener.addColorStop(0.38, "rgba(0, 0, 0, 0.4)");
+ seamSoftener.addColorStop(1, "rgba(0, 0, 0, 0)");
+ bottomLayer.context.fillStyle = seamSoftener;
+ bottomLayer.context.fillRect(0, height * 0.2, width, height * 0.38);
+ bottomLayer.context.globalCompositeOperation = "source-over";
+
+ leftLayer.context.save();
+ leftLayer.context.filter = `blur(${Math.max(28, width * 0.035)}px) saturate(1.02)`;
+ leftLayer.context.globalAlpha = 0.78;
+ leftLayer.context.drawImage(
+ image,
+ sourceX,
+ sourceY,
+ sideSourceWidth,
+ sourceHeight,
+ 0,
+ -height * 0.02,
+ width * 0.16,
+ height * 1.04
+ );
+ leftLayer.context.restore();
+
+ leftLayer.context.globalCompositeOperation = "destination-in";
+ const leftMask = leftLayer.context.createLinearGradient(
+ 0,
+ 0,
+ width * 0.2,
+ 0
+ );
+ leftMask.addColorStop(0, "rgba(0, 0, 0, 1)");
+ leftMask.addColorStop(0.52, "rgba(0, 0, 0, 0.9)");
+ leftMask.addColorStop(1, "rgba(0, 0, 0, 0)");
+ leftLayer.context.fillStyle = leftMask;
+ leftLayer.context.fillRect(0, 0, width * 0.2, height);
+ leftLayer.context.globalCompositeOperation = "source-over";
+
+ rightLayer.context.save();
+ rightLayer.context.filter = `blur(${Math.max(28, width * 0.035)}px) saturate(1.02)`;
+ rightLayer.context.globalAlpha = 0.78;
+ rightLayer.context.drawImage(
+ image,
+ sourceX + sourceWidth - sideSourceWidth,
+ sourceY,
+ sideSourceWidth,
+ sourceHeight,
+ width * 0.84,
+ -height * 0.02,
+ width * 0.16,
+ height * 1.04
+ );
+ rightLayer.context.restore();
+
+ rightLayer.context.globalCompositeOperation = "destination-in";
+ const rightMask = rightLayer.context.createLinearGradient(
+ width,
+ 0,
+ width * 0.8,
+ 0
+ );
+ rightMask.addColorStop(0, "rgba(0, 0, 0, 1)");
+ rightMask.addColorStop(0.52, "rgba(0, 0, 0, 0.9)");
+ rightMask.addColorStop(1, "rgba(0, 0, 0, 0)");
+ rightLayer.context.fillStyle = rightMask;
+ rightLayer.context.fillRect(width * 0.8, 0, width * 0.2, height);
+ rightLayer.context.globalCompositeOperation = "source-over";
+
+ context.drawImage(bottomLayer.canvas, 0, 0, width, height);
+ context.drawImage(leftLayer.canvas, 0, 0, width, height);
+ context.drawImage(rightLayer.canvas, 0, 0, width, height);
+
+ const verticalFade = context.createLinearGradient(
+ 0,
+ height * 0.32,
+ 0,
+ height
+ );
+ verticalFade.addColorStop(0, withAlpha(backgroundColor, 0));
+ verticalFade.addColorStop(0.14, withAlpha(backgroundColor, 0.02));
+ verticalFade.addColorStop(0.32, withAlpha(backgroundColor, 0.08));
+ verticalFade.addColorStop(0.54, withAlpha(backgroundColor, 0.18));
+ verticalFade.addColorStop(0.72, withAlpha(backgroundColor, 0.38));
+ verticalFade.addColorStop(0.86, withAlpha(backgroundColor, 0.68));
+ verticalFade.addColorStop(0.94, withAlpha(backgroundColor, 0.9));
+ verticalFade.addColorStop(1, backgroundColor);
+ context.fillStyle = verticalFade;
+ context.fillRect(0, height * 0.32, width, height * 0.68);
+
+ const leftFade = context.createLinearGradient(0, 0, width * 0.12, 0);
+ leftFade.addColorStop(0, backgroundColor);
+ leftFade.addColorStop(0.26, withAlpha(backgroundColor, 0.58));
+ leftFade.addColorStop(1, withAlpha(backgroundColor, 0));
+ context.fillStyle = leftFade;
+ context.fillRect(0, 0, width * 0.12, height);
+
+ const rightFade = context.createLinearGradient(width, 0, width * 0.88, 0);
+ rightFade.addColorStop(0, backgroundColor);
+ rightFade.addColorStop(0.26, withAlpha(backgroundColor, 0.58));
+ rightFade.addColorStop(1, withAlpha(backgroundColor, 0));
+ context.fillStyle = rightFade;
+ context.fillRect(width * 0.88, 0, width * 0.12, height);
+ }, [clearBlendCanvas]);
+
+ const scheduleRender = useCallback(() => {
+ if (frameRef.current != null) {
+ globalThis.cancelAnimationFrame(frameRef.current);
+ }
+
+ frameRef.current = globalThis.requestAnimationFrame(() => {
+ renderBlend();
+ frameRef.current = null;
+ });
+ }, [renderBlend]);
+
+ useEffect(() => {
+ if (!imageUrl) {
+ clearBlendCanvas();
+ return;
+ }
+
+ const container = containerRef.current;
+
+ if (!container) return;
+
+ const resizeObserver = new ResizeObserver(() => {
+ scheduleRender();
+ });
+
+ resizeObserver.observe(container);
+ scheduleRender();
+
+ return () => {
+ resizeObserver.disconnect();
+
+ if (frameRef.current != null) {
+ globalThis.cancelAnimationFrame(frameRef.current);
+ frameRef.current = null;
+ }
+ };
+ }, [clearBlendCanvas, imageUrl, scheduleRender]);
+
+ return (
+
+ {imageUrl ? (
+
{
+ scheduleRender();
+ onLoad?.(event);
+ }}
+ onError={(event) => {
+ clearBlendCanvas();
+ onError?.(event);
+ }}
+ {...props}
+ />
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/animated-hero-image/styles.scss b/src/big-picture/src/components/common/animated-hero-image/styles.scss
new file mode 100644
index 000000000..731d561d7
--- /dev/null
+++ b/src/big-picture/src/components/common/animated-hero-image/styles.scss
@@ -0,0 +1,34 @@
+.animated-hero-image {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: block;
+ overflow: hidden;
+ isolation: isolate;
+}
+
+.animated-hero-image__main {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+ object-position: center;
+ will-change: transform;
+}
+
+.animated-hero-image__blend-wrap {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.animated-hero-image__blend {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/big-picture/src/components/common/backdrop/index.tsx b/src/big-picture/src/components/common/backdrop/index.tsx
new file mode 100644
index 000000000..8820a3b1d
--- /dev/null
+++ b/src/big-picture/src/components/common/backdrop/index.tsx
@@ -0,0 +1,22 @@
+import "./styles.scss";
+
+import { motion } from "framer-motion";
+import type { ReactNode } from "react";
+
+export interface BackdropProps {
+ children: ReactNode;
+}
+
+export function Backdrop({ children }: Readonly) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/big-picture/src/components/common/backdrop/styles.scss b/src/big-picture/src/components/common/backdrop/styles.scss
new file mode 100644
index 000000000..115d028c1
--- /dev/null
+++ b/src/big-picture/src/components/common/backdrop/styles.scss
@@ -0,0 +1,11 @@
+.backdrop {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10;
+ top: 0;
+ padding: calc(var(--spacing-unit) * 3);
+}
diff --git a/src/big-picture/src/components/common/box/index.tsx b/src/big-picture/src/components/common/box/index.tsx
new file mode 100644
index 000000000..01a9b4fe5
--- /dev/null
+++ b/src/big-picture/src/components/common/box/index.tsx
@@ -0,0 +1,52 @@
+import { Typography } from "..";
+
+interface BoxProps extends React.HTMLAttributes {
+ children?: React.ReactNode;
+ title: string;
+ value: string;
+}
+
+function Box({
+ children,
+ ...props
+}: Readonly>) {
+ const { style, ...rest } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+function TitleBox({ title }: Readonly>) {
+ return (
+
+
+ {title}
+
+
+ );
+}
+
+function SingleLineBox({ title, value }: Readonly) {
+ return (
+
+
+ {title}
+
+ {value}
+
+ );
+}
+
+export { Box, TitleBox, SingleLineBox };
diff --git a/src/big-picture/src/components/common/button/index.tsx b/src/big-picture/src/components/common/button/index.tsx
new file mode 100644
index 000000000..79e8e9424
--- /dev/null
+++ b/src/big-picture/src/components/common/button/index.tsx
@@ -0,0 +1,182 @@
+import "./styles.scss";
+
+import { SpinnerIcon } from "@phosphor-icons/react";
+import cn from "classnames";
+import { Link } from "react-router-dom";
+import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
+import { getContrastTextColor } from "../../../helpers";
+import { FocusItem } from "..";
+import type { FocusOverrides } from "../../../services";
+
+const variants = {
+ primary: "button--primary",
+ secondary: "button--secondary",
+ tertiary: "button--tertiary",
+ rounded: "button--rounded",
+ danger: "button--danger",
+ link: "button--link",
+};
+
+const sizes = {
+ icon: "button--icon",
+ small: "button--small",
+ medium: "button--medium",
+ large: "button--large",
+};
+
+export interface ButtonProps
+ extends Omit, "children"> {
+ loading?: boolean;
+ variant?: keyof typeof variants;
+ size?: keyof typeof sizes;
+ children: ReactNode;
+ icon?: ReactNode;
+ href?: string;
+ iconPosition?: "left" | "right";
+ target?: "_blank" | "_self" | "_parent" | "_top";
+ className?: string;
+ color?: string;
+ focusId?: string;
+ focusNavigationOverrides?: FocusOverrides;
+}
+
+function isExternalHref(href: string) {
+ return (
+ /^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(href) || href.startsWith("mailto:")
+ );
+}
+
+export function Button({
+ loading = false,
+ disabled = false,
+ size = "medium",
+ variant = "primary",
+ iconPosition = "left",
+ href,
+ icon,
+ onClick,
+ children,
+ target,
+ className,
+ color,
+ style,
+ focusId,
+ focusNavigationOverrides,
+ "aria-label": ariaLabel,
+ ...props
+}: Readonly) {
+ const buttonClassName = cn(
+ "button",
+ variants[variant],
+ sizes[size],
+ className,
+ {
+ "button--disabled": disabled || loading,
+ }
+ );
+
+ const buttonStyle = {
+ ...style,
+ ...(color
+ ? {
+ "--button-custom-color": color,
+ "--button-custom-hover-color": `color-mix(in srgb, ${color} 80%, white)`,
+ "--button-custom-text-color": getContrastTextColor(color),
+ }
+ : {}),
+ } as CSSProperties;
+
+ if (!href) {
+ return (
+
+
+
+ );
+ }
+
+ const linkContent = (
+ <>
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {children && {children}
}
+ >
+ );
+
+ if (target === "_blank" || isExternalHref(href)) {
+ return (
+
+
+ {linkContent}
+
+
+ );
+ }
+
+ return (
+
+
+ {linkContent}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/button/styles.scss b/src/big-picture/src/components/common/button/styles.scss
new file mode 100644
index 000000000..451468c97
--- /dev/null
+++ b/src/big-picture/src/components/common/button/styles.scss
@@ -0,0 +1,211 @@
+.button {
+ --button-custom-color: var(--primary);
+ --button-custom-hover-color: var(--primary-hover);
+ --button-custom-text-color: var(--background);
+ appearance: none;
+ cursor: pointer;
+ height: 44px;
+ padding: 0px calc(var(--spacing-unit) * 4);
+ border-radius: var(--spacing-unit);
+ border: none;
+ font-family: var(--font-space-grotesk);
+ font-weight: 400;
+ font-size: 14px;
+ line-height: 16.59px;
+ letter-spacing: 0px;
+ text-align: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: calc(var(--spacing-unit) * 8);
+ position: relative;
+ transition:
+ background-color 0.25s ease-in-out,
+ color 0.25s ease-in-out;
+ flex-shrink: 0;
+
+ &--primary {
+ color: var(--button-custom-text-color);
+ background-color: var(--button-custom-color);
+
+ &:hover {
+ background-color: var(--button-custom-hover-color);
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ outline: 1px solid var(--primary);
+ outline-offset: 2px;
+ }
+ }
+
+ &--secondary {
+ color: var(--text);
+ background-color: var(--secondary);
+ border: 1px solid var(--secondary-border);
+
+ &:hover {
+ background-color: var(--secondary-hover);
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ outline: 1px solid var(--primary);
+ outline-offset: 2px;
+ }
+ }
+
+ &--tertiary {
+ color: var(--text);
+ background-color: var(--tertiary);
+ border: 1px solid var(--tertiary-border);
+
+ &:hover {
+ background-color: var(--tertiary-hover);
+ }
+
+ &:focus-visible {
+ outline: 1px solid var(--tertiary-border);
+ outline-offset: 2px;
+ }
+ }
+
+ &--rounded {
+ color: var(--text);
+ background-color: transparent;
+ border-radius: calc(var(--spacing-unit) * 2);
+ border: 1px solid var(--secondary-border);
+
+ &:hover {
+ background-color: var(--secondary);
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ outline: 1px solid var(--primary);
+ outline-offset: 2px;
+ }
+ }
+
+ &--danger {
+ color: var(--error);
+ background-color: var(--error-background);
+ border: 1px solid var(--error-border);
+
+ &:hover {
+ background-color: var(--error-hover);
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ outline: 1px solid var(--error);
+ outline-offset: 2px;
+ }
+ }
+
+ &--link {
+ color: var(--text);
+ background-color: transparent;
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-decoration-color: var(--text);
+ opacity: 0.8;
+ text-align: left;
+ line-height: normal;
+ text-decoration-style: solid;
+ text-decoration-skip-ink: auto;
+ text-decoration-thickness: auto;
+ text-underline-position: from-font;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ text-underline-offset: 2px;
+ opacity: 1;
+ outline: none;
+ }
+ }
+
+ &--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &--icon {
+ padding: 0;
+ width: 44px;
+ }
+
+ &--small {
+ height: 40px;
+ }
+
+ &--large {
+ height: 48px;
+ }
+
+ &__loading-icon {
+ animation: spin 1s linear infinite;
+ }
+
+ &__text {
+ padding: calc(var(--spacing-unit) * 2) 0px;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ &__icon-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: calc(var(--spacing-unit) * 2) 0px;
+
+ &--left::after {
+ content: "";
+ position: absolute;
+ right: calc(var(--spacing-unit) * -4);
+ top: 0;
+ transform: none;
+ height: 100%;
+ width: 1px;
+ background-color: currentColor;
+ opacity: 0.2;
+ }
+
+ &--right::before {
+ content: "";
+ position: absolute;
+ left: calc(var(--spacing-unit) * -4);
+ top: 0;
+ transform: none;
+ height: 100%;
+ width: 1px;
+ background-color: currentColor;
+ opacity: 0.2;
+ }
+
+ &--right {
+ order: 1;
+ }
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/big-picture/src/components/common/checkbox/index.tsx b/src/big-picture/src/components/common/checkbox/index.tsx
new file mode 100644
index 000000000..7059f500b
--- /dev/null
+++ b/src/big-picture/src/components/common/checkbox/index.tsx
@@ -0,0 +1,65 @@
+import "./styles.scss";
+
+import { CheckIcon } from "@phosphor-icons/react";
+import { useId, type MouseEvent } from "react";
+import cn from "classnames";
+
+export interface CheckboxProps {
+ id?: string;
+ label?: string;
+ checked?: boolean;
+ block?: boolean;
+ disabled?: boolean;
+ onChange?: (checked: boolean) => void;
+}
+
+export const Checkbox = ({ label, ...props }: Readonly) => {
+ const generatedId = useId();
+ const id = props.id ?? generatedId;
+
+ const isChecked = props.checked ?? false;
+
+ const handleChange = (checked: boolean) => {
+ props.onChange?.(checked);
+ };
+
+ const handleBlockClick = (e: MouseEvent) => {
+ if (!props.block) return;
+
+ e.preventDefault();
+ handleChange(!isChecked);
+ };
+
+ return (
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+
+
+
+ {label && (
+
+ )}
+
+ );
+};
diff --git a/src/big-picture/src/components/common/checkbox/styles.scss b/src/big-picture/src/components/common/checkbox/styles.scss
new file mode 100644
index 000000000..768be2968
--- /dev/null
+++ b/src/big-picture/src/components/common/checkbox/styles.scss
@@ -0,0 +1,65 @@
+.checkbox {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: calc(var(--spacing-unit) * 2);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ padding: calc(var(--spacing-unit) * 2);
+ user-select: none;
+ cursor: pointer;
+
+ &--block {
+ width: 100%;
+ border-radius: calc(var(--spacing-unit) * 2);
+ transition: background-color 0.2s ease-in-out;
+
+ &:hover {
+ background-color: var(--secondary);
+ }
+
+ &--active {
+ background-color: var(--secondary-hover);
+
+ &:hover {
+ background-color: var(--secondary-hover);
+ }
+ }
+ }
+
+ &--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &__native-input {
+ appearance: none;
+ }
+
+ &__input {
+ width: 16px;
+ height: 16px;
+ border-radius: 2px;
+ border: 1px solid var(--primary);
+ background-color: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease-in-out;
+ cursor: pointer;
+
+ &--checked {
+ background-color: var(--primary);
+ }
+
+ &__icon {
+ color: var(--background);
+ }
+ }
+
+ &__label {
+ cursor: pointer;
+ }
+}
diff --git a/src/big-picture/src/components/common/chip/index.tsx b/src/big-picture/src/components/common/chip/index.tsx
new file mode 100644
index 000000000..2abef28c6
--- /dev/null
+++ b/src/big-picture/src/components/common/chip/index.tsx
@@ -0,0 +1,55 @@
+import "./styles.scss";
+
+import cn from "classnames";
+import { Typography } from "../typography";
+import { XIcon } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+
+const variants = {
+ solid: "chips--solid",
+ ghost: "chips--ghost",
+};
+
+export interface ChipProps {
+ label: string;
+ color: string;
+ icon?: ReactNode;
+ variant?: keyof typeof variants;
+ onRemove?: () => void;
+}
+
+export interface ColorDotProps {
+ color: string;
+}
+
+export function ColorDot({ color }: Readonly) {
+ return (
+
+ );
+}
+
+export function Chip({
+ label,
+ color,
+ icon,
+ variant = "solid",
+ onRemove,
+}: Readonly) {
+ return (
+
+
+ {icon &&
{icon}
}
+
+ {color &&
}
+
+
+ {label}
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/chip/styles.scss b/src/big-picture/src/components/common/chip/styles.scss
new file mode 100644
index 000000000..d20b7dcad
--- /dev/null
+++ b/src/big-picture/src/components/common/chip/styles.scss
@@ -0,0 +1,58 @@
+.chips {
+ display: flex;
+ flex-direction: row;
+ padding: calc(var(--spacing-unit) * 1) calc(var(--spacing-unit) * 2);
+ justify-content: center;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 1);
+ border-radius: calc(var(--spacing-unit) * 2);
+
+ &--solid {
+ background-color: var(--surface);
+ }
+
+ &--ghost {
+ background-color: transparent;
+ }
+}
+
+.chips__content {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ padding: var(--spacing-unit);
+
+ &__label {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ margin-top: calc(var(--spacing-unit) * 0.5);
+ }
+
+ &__color {
+ width: 11px;
+ height: 11px;
+ border-radius: 50%;
+ }
+}
+
+.chips__close-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: calc(var(--spacing-unit) * 0.5);
+ padding: var(--spacing-unit);
+ border-radius: var(--spacing-unit);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition:
+ background-color 0.2s ease-in-out,
+ color 0.2s ease-in-out;
+
+ &:hover {
+ background-color: var(--secondary);
+ color: var(--primary);
+ }
+}
diff --git a/src/big-picture/src/components/common/diagnostics/index.tsx b/src/big-picture/src/components/common/diagnostics/index.tsx
new file mode 100644
index 000000000..dfb8b1dfe
--- /dev/null
+++ b/src/big-picture/src/components/common/diagnostics/index.tsx
@@ -0,0 +1,1342 @@
+import { useGamepad } from "../../../hooks/use-gamepad.hook";
+import { useNavigationSnapshot } from "../../../stores/navigation.store";
+import { useGamepadStore } from "../../../stores";
+import {
+ GamepadAxisDirection,
+ GamepadAxisType,
+ GamepadButtonType,
+ GamepadInputStatus,
+} from "../../../types";
+import { type ReactNode, useEffect, useMemo, useState } from "react";
+
+interface LocalInputDebug {
+ label: string;
+ source: "gamepad-button" | "left-stick";
+ startedAt: number;
+}
+
+interface GamepadEventDebug {
+ gamepadIndex: number;
+ label: string;
+ source: "gamepad-button" | "left-stick";
+ status: GamepadInputStatus;
+ accepted: boolean;
+ activeGamepadIndex: number | null;
+ echoOfGamepadIndex?: number | null;
+ echoSuppressionMs?: number | null;
+ startedAt: number;
+}
+
+interface RawButtonDebug {
+ index: number;
+ pressed: boolean;
+ value: number;
+}
+
+interface RawAxisDebug {
+ index: number;
+ value: number;
+}
+
+interface RawGamepadDebug {
+ index: number;
+ id: string;
+ mapping: string;
+ connected: boolean;
+ vendorId: string | null;
+ productId: string | null;
+ buttonsLength: number;
+ axesLength: number;
+ buttons: RawButtonDebug[];
+ axes: RawAxisDebug[];
+ pressedButtons: RawButtonDebug[];
+ activeAxes: RawAxisDebug[];
+}
+
+const DEBUG_BUTTONS = [
+ ["A", GamepadButtonType.BUTTON_A],
+ ["B", GamepadButtonType.BUTTON_B],
+ ["X", GamepadButtonType.BUTTON_X],
+ ["Y", GamepadButtonType.BUTTON_Y],
+ ["Start", GamepadButtonType.START],
+ ["Select", GamepadButtonType.BACK],
+ ["Dpad Up", GamepadButtonType.DPAD_UP],
+ ["Dpad Down", GamepadButtonType.DPAD_DOWN],
+ ["Dpad Left", GamepadButtonType.DPAD_LEFT],
+ ["Dpad Right", GamepadButtonType.DPAD_RIGHT],
+] as const;
+
+function stickDeflection01(x: number, y: number) {
+ const cx = Math.max(-1, Math.min(1, x));
+ const cy = Math.max(-1, Math.min(1, y));
+
+ return Math.min(1, Math.hypot(cx, cy));
+}
+
+function getStickDirection(x: number, y: number) {
+ const threshold = 0.5;
+
+ if (Math.abs(x) < threshold && Math.abs(y) < threshold) {
+ return "none";
+ }
+
+ if (Math.abs(x) > Math.abs(y)) {
+ return x > 0 ? GamepadAxisDirection.RIGHT : GamepadAxisDirection.LEFT;
+ }
+
+ return y > 0 ? GamepadAxisDirection.DOWN : GamepadAxisDirection.UP;
+}
+
+function getFocusedElementDataset(currentFocusId: string | null) {
+ if (!currentFocusId || typeof document === "undefined") {
+ return null;
+ }
+
+ const element = document.getElementById(currentFocusId);
+
+ if (!element) {
+ return null;
+ }
+
+ return {
+ navigationState: element.dataset.navigationState ?? "unknown",
+ hasPrimary: element.dataset.hasPrimary === "true",
+ hasSecondary: element.dataset.hasSecondary === "true",
+ hasPressX: element.dataset.hasPressX === "true",
+ hasPressY: element.dataset.hasPressY === "true",
+ hasHoldA: element.dataset.hasHoldA === "true",
+ hasHoldB: element.dataset.hasHoldB === "true",
+ hasHoldX: element.dataset.hasHoldX === "true",
+ };
+}
+
+function formatMs(value: number) {
+ return `${Math.max(0, Math.round(value))}ms`;
+}
+
+function getRuntimePlatform() {
+ if (typeof navigator === "undefined") {
+ return "unknown";
+ }
+
+ const userAgentDataPlatform =
+ "userAgentData" in navigator
+ ? (navigator as Navigator & { userAgentData?: { platform?: string } })
+ .userAgentData?.platform
+ : undefined;
+ const platformText =
+ `${userAgentDataPlatform ?? ""} ${navigator.userAgent}`.toLowerCase();
+
+ if (platformText.includes("linux")) return "linux";
+ if (platformText.includes("mac")) return "mac";
+ if (platformText.includes("win")) return "windows";
+
+ return "unknown";
+}
+
+function getVendorProduct(id: string) {
+ const match = /Vendor:\s*([0-9a-f]{4})\s+Product:\s*([0-9a-f]{4})/i.exec(id);
+
+ return {
+ vendorId: match?.[1]?.toLowerCase() ?? null,
+ productId: match?.[2]?.toLowerCase() ?? null,
+ };
+}
+
+function getRawGamepadsDebug(): RawGamepadDebug[] {
+ if (typeof navigator === "undefined" || !navigator.getGamepads) {
+ return [];
+ }
+
+ return Array.from(navigator.getGamepads())
+ .filter((gamepad): gamepad is Gamepad => Boolean(gamepad))
+ .map((gamepad) => {
+ const { vendorId, productId } = getVendorProduct(gamepad.id);
+ const buttons = gamepad.buttons.map((button, index) => ({
+ index,
+ pressed: button.pressed,
+ value: button.value,
+ }));
+ const axes = gamepad.axes.map((value, index) => ({
+ index,
+ value,
+ }));
+
+ return {
+ index: gamepad.index,
+ id: gamepad.id,
+ mapping: gamepad.mapping || "none",
+ connected: gamepad.connected,
+ vendorId,
+ productId,
+ buttonsLength: gamepad.buttons.length,
+ axesLength: gamepad.axes.length,
+ buttons,
+ axes,
+ pressedButtons: buttons.filter(
+ (button) => button.pressed || button.value > 0.01
+ ),
+ activeAxes: axes.filter((axis) => Math.abs(axis.value) > 0.01),
+ };
+ });
+}
+
+function formatRawButtons(buttons: RawButtonDebug[]) {
+ if (buttons.length === 0) return "None";
+
+ return buttons
+ .map((button) => `b${button.index}:${button.value.toFixed(2)}`)
+ .join(", ");
+}
+
+function formatRawAxes(axes: RawAxisDebug[]) {
+ if (axes.length === 0) return "None";
+
+ return axes
+ .map((axis) => `a${axis.index}:${axis.value.toFixed(2)}`)
+ .join(", ");
+}
+
+function getInputRepeatLabel(
+ activeInput: LocalInputDebug | null,
+ lastInput: LocalInputDebug | null,
+ now: number
+) {
+ if (activeInput && now - activeInput.startedAt >= 400) {
+ return "held";
+ }
+
+ if (lastInput) {
+ return "single";
+ }
+
+ return "None";
+}
+
+function getGamepadLabel(
+ gamepad: { index: number; name: string } | null | undefined
+) {
+ if (!gamepad) return "None";
+
+ return `#${gamepad.index} ${gamepad.name}`;
+}
+
+function getVendorProductLabel(gamepad: RawGamepadDebug | null) {
+ if (!gamepad?.vendorId || !gamepad.productId) return "None";
+
+ return `${gamepad.vendorId}:${gamepad.productId}`;
+}
+
+function getRawCountsLabel(gamepad: RawGamepadDebug | null) {
+ if (!gamepad) return "None";
+
+ return `${gamepad.buttonsLength} buttons / ${gamepad.axesLength} axes`;
+}
+
+function getActiveRawButtonsLabel(gamepad: RawGamepadDebug | null) {
+ if (!gamepad) return "None";
+
+ return formatRawButtons(gamepad.pressedButtons);
+}
+
+function getActiveRawAxesLabel(gamepad: RawGamepadDebug | null) {
+ if (!gamepad) return "None";
+
+ return formatRawAxes(gamepad.activeAxes);
+}
+
+function getLastEventLabel(event: GamepadEventDebug | null) {
+ if (!event) return "None";
+
+ return `#${event.gamepadIndex} ${event.source}.${event.label}`;
+}
+
+function getEventAgeLabel(event: GamepadEventDebug | null, now: number) {
+ if (!event) return "None";
+
+ return formatMs(now - event.startedAt);
+}
+
+function getEventStatusLabel(event: GamepadEventDebug | null) {
+ if (!event) return "None";
+
+ return `${event.status} -> active #${event.activeGamepadIndex ?? "none"}`;
+}
+
+function getEventEchoLabel(event: GamepadEventDebug | null) {
+ if (event?.echoOfGamepadIndex === undefined) return "None";
+ if (event.echoOfGamepadIndex === null) return "None";
+
+ return `of #${event.echoOfGamepadIndex} (${formatMs(
+ event.echoSuppressionMs ?? 0
+ )})`;
+}
+
+function getFocusedActionsLabel(
+ focusedDataset: ReturnType
+) {
+ if (!focusedDataset) return "None";
+
+ const actions = [
+ focusedDataset.hasPrimary && "primary",
+ focusedDataset.hasSecondary && "secondary",
+ focusedDataset.hasPressX && "press.x",
+ focusedDataset.hasPressY && "press.y",
+ focusedDataset.hasHoldA && "hold.a",
+ focusedDataset.hasHoldB && "hold.b",
+ focusedDataset.hasHoldX && "hold.x",
+ ].filter(Boolean);
+
+ return actions.join(", ") || "None";
+}
+
+function getRegionPath(
+ currentRegionId: string | null | undefined,
+ regions: Array<{ id: string; parentRegionId: string | null }>
+) {
+ if (currentRegionId) {
+ const path: string[] = [];
+ let regionId: string | null = currentRegionId;
+
+ while (regionId) {
+ const region = regions.find((candidate) => candidate.id === regionId);
+
+ if (!region) break;
+
+ path.unshift(region.id);
+ regionId = region.parentRegionId;
+ }
+
+ return path;
+ }
+
+ return [];
+}
+
+function getActiveInputLabel(
+ pressedButtons: string[],
+ leftStickDirection: GamepadAxisDirection | "none"
+) {
+ return (
+ pressedButtons[0] ??
+ (leftStickDirection === "none" ? null : `left-stick.${leftStickDirection}`)
+ );
+}
+
+function getActiveInputSource(
+ pressedButtons: string[]
+): LocalInputDebug["source"] {
+ return pressedButtons[0] ? "gamepad-button" : "left-stick";
+}
+
+function getHoldProgressLabel(
+ activeInput: LocalInputDebug | null,
+ now: number
+) {
+ if (!activeInput) return "None";
+
+ return `${activeInput.label} ${formatMs(now - activeInput.startedAt)}`;
+}
+
+function getLastInputLabel(lastInput: LocalInputDebug | null) {
+ if (!lastInput) return "None";
+
+ return `${lastInput.source}.${lastInput.label}`;
+}
+
+function getItemStateLabel(
+ currentNode: { navigationState: string } | null,
+ focusedDataset: ReturnType
+) {
+ return (
+ currentNode?.navigationState ?? focusedDataset?.navigationState ?? "None"
+ );
+}
+
+function getRememberedFocusLabel(
+ currentRegionId: string | null | undefined,
+ rememberedByRegionId: Record
+) {
+ if (!currentRegionId) return "None";
+
+ return rememberedByRegionId[currentRegionId] ?? "None";
+}
+
+function getConnectedGamepadsLabel(
+ connectedGamepads: Array<{ index: number; layout: string }>,
+ rawGamepads: RawGamepadDebug[]
+) {
+ if (connectedGamepads.length === 0) return "None";
+
+ return connectedGamepads
+ .map((gamepad) => {
+ const rawGamepad = rawGamepads.find((raw) => raw.index === gamepad.index);
+ const vendorProduct =
+ rawGamepad?.vendorId && rawGamepad.productId
+ ? ` ${rawGamepad.vendorId}:${rawGamepad.productId}`
+ : "";
+ const browserMapping = rawGamepad ? ` ${rawGamepad.mapping}` : "";
+
+ return `#${gamepad.index}: ${gamepad.layout}${browserMapping}${vendorProduct}`;
+ })
+ .join(" · ");
+}
+
+function Section({
+ title,
+ children,
+}: {
+ readonly title: string;
+ readonly children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function Row({
+ label,
+ value,
+}: {
+ readonly label: string;
+ readonly value: ReactNode;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+function AxisValue({
+ label,
+ value,
+}: {
+ readonly label: string;
+ readonly value: number;
+}) {
+ const normalized = Math.max(0, Math.min(1, (value + 1) / 2));
+ const height = `${Math.abs(value) * 50}%`;
+ const top = value < 0 ? `${normalized * 100}%` : "50%";
+
+ return (
+
+
+
+
+
+ {label}
+
+
+ {value.toFixed(5)}
+
+
+
+ );
+}
+
+function StickCircle({ x, y }: { readonly x: number; readonly y: number }) {
+ const clampedX = Math.max(-1, Math.min(1, x));
+ const clampedY = Math.max(-1, Math.min(1, y));
+ const dotX = 56 + clampedX * 48;
+ const dotY = 56 + clampedY * 48;
+
+ return (
+
+ );
+}
+
+function StickPanel({
+ title,
+ x,
+ y,
+ xAxisLabel,
+ yAxisLabel,
+}: {
+ readonly title: string;
+ readonly x: number;
+ readonly y: number;
+ readonly xAxisLabel: string;
+ readonly yAxisLabel: string;
+}) {
+ return (
+
+ );
+}
+
+function TriggerMeter({
+ label,
+ value,
+}: {
+ readonly label: string;
+ readonly value: number;
+}) {
+ const normalized = Math.max(0, Math.min(1, value));
+
+ return (
+
+
+ {label}
+ {value.toFixed(5)}
+
+
+
+ );
+}
+
+function GamepadVisualizer({
+ isButtonPressed,
+ leftStickX,
+ leftStickY,
+ rightStickX,
+ rightStickY,
+ leftTriggerValue,
+ rightTriggerValue,
+ isInfiniteVibrationEnabled,
+ onTestVibration,
+ onToggleInfiniteVibration,
+}: {
+ readonly isButtonPressed: (button: GamepadButtonType) => boolean;
+ readonly leftStickX: number;
+ readonly leftStickY: number;
+ readonly rightStickX: number;
+ readonly rightStickY: number;
+ readonly leftTriggerValue: number;
+ readonly rightTriggerValue: number;
+ readonly isInfiniteVibrationEnabled: boolean;
+ readonly onTestVibration: () => void;
+ readonly onToggleInfiniteVibration: () => void;
+}) {
+ const activeFill = "#ffffff";
+ const bodyFill = "var(--secondary)";
+ const line = "var(--text-secondary)";
+ const softLine = "var(--secondary-border)";
+ const activeText = "var(--background)";
+ const inactiveFill = "var(--surface)";
+ const leftStickDotX = 113 + Math.max(-1, Math.min(1, leftStickX)) * 16;
+ const leftStickDotY = 160 + Math.max(-1, Math.min(1, leftStickY)) * 16;
+ const rightStickDotX = 278 + Math.max(-1, Math.min(1, rightStickX)) * 16;
+ const rightStickDotY = 238 + Math.max(-1, Math.min(1, rightStickY)) * 16;
+ const leftStickDeflection = stickDeflection01(leftStickX, leftStickY);
+ const rightStickDeflection = stickDeflection01(rightStickX, rightStickY);
+ const leftTrigger01 = Math.max(0, Math.min(1, leftTriggerValue));
+ const rightTrigger01 = Math.max(0, Math.min(1, rightTriggerValue));
+
+ const buttonFill = (button: GamepadButtonType) =>
+ isButtonPressed(button) ? activeFill : inactiveFill;
+ const buttonText = (button: GamepadButtonType) =>
+ isButtonPressed(button) ? activeText : line;
+ const dpadFill = (button: GamepadButtonType) =>
+ isButtonPressed(button) ? activeFill : "transparent";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function NavigationDiagnosticsPanel() {
+ const [now, setNow] = useState(Date.now());
+ const {
+ isButtonPressed,
+ getButtonValue,
+ getAxisValue,
+ onButtonPressed,
+ onStickMove,
+ vibrate,
+ activeGamepadIndex,
+ connectedGamepads,
+ hasGamepadConnected,
+ } = useGamepad();
+ const getActiveGamepad = useGamepadStore((state) => state.getActiveGamepad);
+ const { currentFocusId, nodes, regions, layers, debugSnapshot } =
+ useNavigationSnapshot();
+ const [lastInput, setLastInput] = useState(null);
+ const [activeInput, setActiveInput] = useState(null);
+ const [lastGamepadEvent, setLastGamepadEvent] =
+ useState(null);
+ const [isInfiniteVibrationEnabled, setIsInfiniteVibrationEnabled] =
+ useState(false);
+ const activeGamepad = getActiveGamepad();
+ const leftStickX = getAxisValue(GamepadAxisType.LEFT_STICK_X);
+ const leftStickY = getAxisValue(GamepadAxisType.LEFT_STICK_Y);
+ const rightStickX = getAxisValue(GamepadAxisType.RIGHT_STICK_X);
+ const rightStickY = getAxisValue(GamepadAxisType.RIGHT_STICK_Y);
+ const leftTriggerValue = getButtonValue(GamepadButtonType.LEFT_TRIGGER);
+ const rightTriggerValue = getButtonValue(GamepadButtonType.RIGHT_TRIGGER);
+ const leftStickDirection = getStickDirection(leftStickX, leftStickY);
+ const runtimePlatform = getRuntimePlatform();
+ const rawGamepads = getRawGamepadsDebug();
+ const activeRawGamepad =
+ rawGamepads.find((gamepad) => gamepad.index === activeGamepadIndex) ?? null;
+ const pressedButtons = DEBUG_BUTTONS.filter(([, button]) =>
+ isButtonPressed(button)
+ ).map(([label]) => label);
+ const currentNode = nodes.find((node) => node.id === currentFocusId) ?? null;
+ const currentRegion = currentNode
+ ? (regions.find((region) => region.id === currentNode.regionId) ?? null)
+ : null;
+ const [focusedDataset, setFocusedDataset] =
+ useState>(null);
+ const activeInputLabel = getActiveInputLabel(
+ pressedButtons,
+ leftStickDirection
+ );
+ const activeInputSource = getActiveInputSource(pressedButtons);
+
+ const regionPath = useMemo(() => {
+ return getRegionPath(currentRegion?.id, regions);
+ }, [currentRegion, regions]);
+
+ useEffect(() => {
+ if (!activeInput) {
+ return;
+ }
+
+ const intervalId = globalThis.setInterval(() => {
+ setNow(Date.now());
+ }, 100);
+
+ return () => {
+ globalThis.clearInterval(intervalId);
+ };
+ }, [activeInput]);
+
+ useEffect(() => {
+ if (!activeInputLabel) {
+ setActiveInput(null);
+ return;
+ }
+
+ setActiveInput((previous) => {
+ if (previous?.label === activeInputLabel) {
+ return previous;
+ }
+
+ const nextInput = {
+ label: activeInputLabel,
+ source: activeInputSource,
+ startedAt: Date.now(),
+ };
+
+ setLastInput(nextInput);
+ return nextInput;
+ });
+ }, [activeInputLabel, activeInputSource]);
+
+ useEffect(() => {
+ const unsubscribers = [
+ ...DEBUG_BUTTONS.map(([label, button]) =>
+ onButtonPressed(button, (event) => {
+ const startedAt = Date.now();
+
+ setLastGamepadEvent({
+ gamepadIndex: event.gamepadIndex,
+ label,
+ source: "gamepad-button",
+ status: event.status,
+ accepted: event.accepted,
+ activeGamepadIndex: event.activeGamepadIndex,
+ echoOfGamepadIndex: event.echoOfGamepadIndex,
+ echoSuppressionMs: event.echoSuppressionMs,
+ startedAt,
+ });
+ setNow(startedAt);
+ })
+ ),
+ ...[
+ GamepadAxisDirection.UP,
+ GamepadAxisDirection.DOWN,
+ GamepadAxisDirection.LEFT,
+ GamepadAxisDirection.RIGHT,
+ ].map((direction) =>
+ onStickMove("left", direction, (event) => {
+ const startedAt = Date.now();
+
+ setLastGamepadEvent({
+ gamepadIndex: event.gamepadIndex,
+ label: `${event.side}-stick.${event.direction}`,
+ source: "left-stick",
+ status: event.status,
+ accepted: event.accepted,
+ activeGamepadIndex: event.activeGamepadIndex,
+ echoOfGamepadIndex: event.echoOfGamepadIndex,
+ echoSuppressionMs: event.echoSuppressionMs,
+ startedAt,
+ });
+ setNow(startedAt);
+ })
+ ),
+ ];
+
+ return () => {
+ unsubscribers.forEach((unsubscribe) => unsubscribe());
+ };
+ }, [onButtonPressed, onStickMove]);
+
+ useEffect(() => {
+ setFocusedDataset(getFocusedElementDataset(currentFocusId));
+ }, [currentFocusId, currentNode?.navigationState]);
+
+ useEffect(() => {
+ if (!isInfiniteVibrationEnabled) {
+ return;
+ }
+
+ vibrate({
+ duration: 700,
+ weakMagnitude: 0.45,
+ strongMagnitude: 0.85,
+ });
+
+ const intervalId = globalThis.setInterval(() => {
+ vibrate({
+ duration: 700,
+ weakMagnitude: 0.45,
+ strongMagnitude: 0.85,
+ });
+ }, 650);
+
+ return () => {
+ globalThis.clearInterval(intervalId);
+ vibrate({
+ duration: 1,
+ weakMagnitude: 0,
+ strongMagnitude: 0,
+ });
+ };
+ }, [isInfiniteVibrationEnabled, vibrate]);
+
+ const handleTestVibration = () => {
+ vibrate({
+ duration: 1000,
+ weakMagnitude: 0.45,
+ strongMagnitude: 0.85,
+ });
+ };
+
+ const handleLogSnapshot = () => {
+ const snapshot = {
+ gamepad: {
+ hasGamepadConnected,
+ connectedGamepads,
+ activeGamepad,
+ activeGamepadIndex,
+ runtimePlatform,
+ rawGamepads,
+ pressedButtons,
+ leftStick: {
+ x: leftStickX,
+ y: leftStickY,
+ direction: leftStickDirection,
+ },
+ },
+ input: {
+ lastInput,
+ activeInput,
+ lastGamepadEvent,
+ },
+ navigation: {
+ currentFocusId,
+ currentNode,
+ currentRegion,
+ regionPath,
+ layers,
+ debugSnapshot,
+ },
+ };
+
+ console.group("[navigation-diagnostics]");
+ console.log("gamepad", snapshot.gamepad);
+ console.log("input", snapshot.input);
+ console.log("navigation", snapshot.navigation);
+ console.groupEnd();
+
+ let text: string;
+ try {
+ text = JSON.stringify(snapshot, null, 2);
+ } catch (error) {
+ console.warn("[navigation-diagnostics] snapshot JSON failed", error);
+ text = `[navigation-diagnostics] JSON.stringify failed: ${String(error)}`;
+ }
+
+ navigator.clipboard.writeText(text).catch((err) => {
+ console.warn("[navigation-diagnostics] clipboard copy failed", err);
+ });
+ };
+
+ return (
+
+
+ Diagnostics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 ? regionPath.join(" > ") : "None"}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setIsInfiniteVibrationEnabled((prev) => !prev);
+ }}
+ />
+
+
+ );
+}
+
+export function NavigationDiagnostics() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ {isOpen && }
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/divider/index.tsx b/src/big-picture/src/components/common/divider/index.tsx
new file mode 100644
index 000000000..41beef780
--- /dev/null
+++ b/src/big-picture/src/components/common/divider/index.tsx
@@ -0,0 +1,28 @@
+import "./styles.scss";
+
+import cn from "classnames";
+
+export interface DividerProps {
+ orientation?: "horizontal" | "vertical";
+ color?: string;
+}
+
+export function Divider({
+ orientation = "horizontal",
+ color,
+}: Readonly) {
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/components/common/divider/styles.scss b/src/big-picture/src/components/common/divider/styles.scss
new file mode 100644
index 000000000..458261278
--- /dev/null
+++ b/src/big-picture/src/components/common/divider/styles.scss
@@ -0,0 +1,32 @@
+.divider {
+ background-color: rgba(255, 255, 255, 0.1);
+ display: inline-block;
+ min-width: 1px;
+ min-height: 1px;
+
+ &--horizontal {
+ width: 100%;
+ height: 1px;
+ }
+
+ &--vertical {
+ width: 1px;
+ height: 100%;
+ }
+}
+
+.divider-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &--horizontal {
+ width: 100%;
+ }
+
+ &--vertical {
+ width: 1px;
+ height: 100%;
+ flex: 0 0 1px;
+ }
+}
diff --git a/src/big-picture/src/components/common/focus-item/index.tsx b/src/big-picture/src/components/common/focus-item/index.tsx
new file mode 100644
index 000000000..12847e274
--- /dev/null
+++ b/src/big-picture/src/components/common/focus-item/index.tsx
@@ -0,0 +1,129 @@
+import { Slot } from "@radix-ui/react-slot";
+import {
+ FocusItemActionsMetaContext,
+ useFocusLayerId,
+ useFocusRegionId,
+} from "../../context";
+import {
+ getFocusItemActionsMeta,
+ resolveFocusItemActions,
+ type FocusItemActions,
+} from "../../../types";
+import {
+ type FocusOverrides,
+ NavigationItemActionsService,
+ NavigationService,
+ type NavigationNodeState,
+} from "../../../services";
+import { useNavigationIsFocused } from "../../../stores";
+import { type ReactNode, useEffect, useId, useMemo, useRef } from "react";
+
+interface FocusItemProps {
+ id?: string;
+ actions?: FocusItemActions;
+ navigationState?: NavigationNodeState;
+ navigationOverrides?: FocusOverrides;
+ asChild?: boolean;
+ children: ReactNode;
+}
+
+export function FocusItem({
+ id,
+ actions,
+ navigationState = "active",
+ navigationOverrides,
+ asChild = false,
+ children,
+}: Readonly) {
+ const generatedId = useId();
+ const regionId = useFocusRegionId();
+ const layerId = useFocusLayerId();
+ const navigation = NavigationService.getInstance();
+ const navigationItemActions = NavigationItemActionsService.getInstance();
+ const ref = useRef(null);
+ const initialNavigationStateRef = useRef(navigationState);
+ const initialNavigationOverridesRef = useRef(navigationOverrides);
+ const resolvedId = id ?? `focus-item-${generatedId.replaceAll(":", "")}`;
+ const isFocused = useNavigationIsFocused(resolvedId);
+
+ const resolvedActions = useMemo(
+ () => resolveFocusItemActions(actions),
+ [actions]
+ );
+
+ const actionsMeta = useMemo(
+ () => getFocusItemActionsMeta(resolvedActions),
+ [resolvedActions]
+ );
+
+ if (!regionId) {
+ throw new Error("FocusItem must be rendered inside a focus group.");
+ }
+
+ useEffect(() => {
+ return navigation.registerNavigationNode({
+ id: resolvedId,
+ regionId,
+ layerId,
+ navigationState: initialNavigationStateRef.current,
+ navigationOverrides: initialNavigationOverridesRef.current,
+ getElement: () => ref.current,
+ });
+ }, [layerId, navigation, regionId, resolvedId]);
+
+ useEffect(() => {
+ navigation.updateNavigationNode(resolvedId, {
+ navigationState,
+ navigationOverrides,
+ });
+ }, [navigation, navigationOverrides, navigationState, resolvedId]);
+
+ useEffect(() => {
+ return navigationItemActions.registerItemActions({
+ itemId: resolvedId,
+ actions: resolvedActions,
+ getElement: () => ref.current,
+ });
+ }, [navigationItemActions, resolvedActions, resolvedId]);
+
+ useEffect(() => {
+ if (!isFocused) return;
+
+ const element = ref.current;
+
+ if (!element) return;
+
+ try {
+ element.focus({ preventScroll: true });
+ } catch {
+ element.focus();
+ }
+ }, [isFocused]);
+
+ const Component = asChild ? Slot : "div";
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/game-card/index.tsx b/src/big-picture/src/components/common/game-card/index.tsx
new file mode 100644
index 000000000..207e4dd6a
--- /dev/null
+++ b/src/big-picture/src/components/common/game-card/index.tsx
@@ -0,0 +1,23 @@
+import "./styles.scss";
+
+interface GameCardProps {
+ coverImageUrl?: string | null;
+ gameTitle: string;
+}
+
+function GameCard({ coverImageUrl, gameTitle }: Readonly) {
+ return (
+
+
+ {coverImageUrl ? (
+

+ ) : (
+
+ )}
+
+
{gameTitle}
+
+ );
+}
+
+export { GameCard };
diff --git a/src/big-picture/src/components/common/game-card/styles.scss b/src/big-picture/src/components/common/game-card/styles.scss
new file mode 100644
index 000000000..0fbe2ab9a
--- /dev/null
+++ b/src/big-picture/src/components/common/game-card/styles.scss
@@ -0,0 +1,43 @@
+.big-picture__game-card {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ width: 100%;
+ min-width: 0;
+ cursor: pointer;
+
+ &__cover {
+ width: 100%;
+ aspect-ratio: 3 / 4;
+ border-radius: calc(var(--spacing-unit) * 2);
+ overflow: hidden;
+ background-color: var(--surface);
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.2s ease;
+ }
+
+ &--placeholder {
+ width: 100%;
+ height: 100%;
+ background-color: var(--secondary);
+ }
+ }
+
+ &:hover &__cover img {
+ transform: scale(1.04);
+ }
+
+ &__title {
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
diff --git a/src/big-picture/src/components/common/grid-focus-group/index.tsx b/src/big-picture/src/components/common/grid-focus-group/index.tsx
new file mode 100644
index 000000000..b1ac2ac3e
--- /dev/null
+++ b/src/big-picture/src/components/common/grid-focus-group/index.tsx
@@ -0,0 +1,95 @@
+import {
+ FocusRegionContext,
+ useFocusLayerId,
+ useFocusRegionId,
+} from "../../context";
+import {
+ type FocusAutoScrollMode,
+ type FocusOverrides,
+ NavigationService,
+} from "../../../services";
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useId,
+ useRef,
+} from "react";
+
+interface GridFocusGroupProps {
+ regionId?: string;
+ navigationOverrides?: FocusOverrides;
+ autoScrollMode?: FocusAutoScrollMode;
+ getScrollAnchor?: () => HTMLElement | null;
+ className?: string;
+ style?: CSSProperties;
+ children: ReactNode;
+}
+
+export function GridFocusGroup({
+ regionId,
+ navigationOverrides,
+ autoScrollMode = "row",
+ getScrollAnchor,
+ className,
+ style,
+ children,
+}: Readonly) {
+ const generatedId = useId();
+ const parentRegionId = useFocusRegionId();
+ const layerId = useFocusLayerId();
+ const navigation = NavigationService.getInstance();
+ const initialNavigationOverridesRef = useRef(navigationOverrides);
+ const initialGetScrollAnchorRef = useRef(getScrollAnchor);
+ const ref = useRef(null);
+ const resolvedRegionId =
+ regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
+
+ useEffect(() => {
+ return navigation.registerRegion({
+ id: resolvedRegionId,
+ parentRegionId,
+ orientation: "grid",
+ layerId,
+ navigationOverrides: initialNavigationOverridesRef.current,
+ autoScrollMode,
+ isPersistent: Boolean(regionId),
+ getElement: () => ref.current,
+ getScrollAnchor: initialGetScrollAnchorRef.current,
+ });
+ }, [
+ autoScrollMode,
+ layerId,
+ navigation,
+ parentRegionId,
+ regionId,
+ resolvedRegionId,
+ ]);
+
+ useEffect(() => {
+ navigation.updateRegion(resolvedRegionId, {
+ autoScrollMode,
+ getScrollAnchor,
+ navigationOverrides,
+ });
+ }, [
+ autoScrollMode,
+ getScrollAnchor,
+ navigation,
+ navigationOverrides,
+ resolvedRegionId,
+ ]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/horizontal-card/index.tsx b/src/big-picture/src/components/common/horizontal-card/index.tsx
new file mode 100644
index 000000000..442c493e3
--- /dev/null
+++ b/src/big-picture/src/components/common/horizontal-card/index.tsx
@@ -0,0 +1,40 @@
+import "./styles.scss";
+
+import type { ReactNode } from "react";
+
+interface HorizontalCardProps {
+ image: string;
+ title: string;
+ description: string;
+ action: ReactNode;
+}
+
+export function HorizontalCard({
+ image,
+ title,
+ description,
+ action,
+}: Readonly) {
+ return (
+
+
+

+
+
+
+
{title}
+
+ {description}
+
+
+
{action}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/horizontal-card/styles.scss b/src/big-picture/src/components/common/horizontal-card/styles.scss
new file mode 100644
index 000000000..009f69589
--- /dev/null
+++ b/src/big-picture/src/components/common/horizontal-card/styles.scss
@@ -0,0 +1,73 @@
+.horizontal-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border-radius: var(--spacing-unit);
+ transition:
+ background-color 0.1s ease-in-out,
+ border 0.1s ease-in-out;
+ border: 1px solid transparent;
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--secondary);
+ border: 1px solid var(--secondary-border);
+
+ .horizontal-card__content__action {
+ opacity: 1;
+ }
+ }
+
+ &__image {
+ width: 268px;
+ height: 136px;
+ border-radius: var(--spacing-unit);
+ overflow: hidden;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--spacing-unit) * 2);
+ align-items: center;
+ justify-content: space-between;
+ padding: calc(var(--spacing-unit) * 2);
+ width: 100%;
+
+ &__info {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-unit);
+
+ &__title {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ color: var(--primary);
+ opacity: 0.8;
+ }
+
+ &__description {
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ color: var(--primary);
+ opacity: 0.5;
+ }
+ }
+
+ &__action {
+ opacity: 0;
+ transition: opacity 0.1s ease-in-out;
+ }
+ }
+}
diff --git a/src/big-picture/src/components/common/horizontal-focus-group/index.tsx b/src/big-picture/src/components/common/horizontal-focus-group/index.tsx
new file mode 100644
index 000000000..e8e32c802
--- /dev/null
+++ b/src/big-picture/src/components/common/horizontal-focus-group/index.tsx
@@ -0,0 +1,109 @@
+import { Slot } from "@radix-ui/react-slot";
+import {
+ FocusRegionContext,
+ useFocusLayerId,
+ useFocusRegionId,
+} from "../../context";
+import {
+ type FocusAutoScrollMode,
+ type FocusOverrides,
+ NavigationService,
+} from "../../../services";
+import {
+ type HTMLAttributes,
+ type ReactNode,
+ useEffect,
+ useId,
+ useRef,
+} from "react";
+
+interface HorizontalFocusGroupProps extends HTMLAttributes {
+ regionId?: string;
+ navigationOverrides?: FocusOverrides;
+ autoScrollMode?: FocusAutoScrollMode;
+ getScrollAnchor?: () => HTMLElement | null;
+ asChild?: boolean;
+ children: ReactNode;
+}
+
+export function HorizontalFocusGroup({
+ regionId,
+ navigationOverrides,
+ autoScrollMode = "region",
+ getScrollAnchor,
+ asChild = false,
+ className,
+ style,
+ children,
+ ...props
+}: Readonly) {
+ const generatedId = useId();
+ const parentRegionId = useFocusRegionId();
+ const layerId = useFocusLayerId();
+ const navigation = NavigationService.getInstance();
+ const initialNavigationOverridesRef = useRef(navigationOverrides);
+ const initialGetScrollAnchorRef = useRef(getScrollAnchor);
+ const ref = useRef(null);
+ const resolvedRegionId =
+ regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
+
+ useEffect(() => {
+ return navigation.registerRegion({
+ id: resolvedRegionId,
+ parentRegionId,
+ orientation: "horizontal",
+ layerId,
+ navigationOverrides: initialNavigationOverridesRef.current,
+ autoScrollMode,
+ isPersistent: Boolean(regionId),
+ getElement: () => ref.current,
+ getScrollAnchor: initialGetScrollAnchorRef.current,
+ });
+ }, [
+ autoScrollMode,
+ layerId,
+ navigation,
+ parentRegionId,
+ regionId,
+ resolvedRegionId,
+ ]);
+
+ useEffect(() => {
+ navigation.updateRegion(resolvedRegionId, {
+ autoScrollMode,
+ getScrollAnchor,
+ navigationOverrides,
+ });
+ }, [
+ autoScrollMode,
+ getScrollAnchor,
+ navigation,
+ navigationOverrides,
+ resolvedRegionId,
+ ]);
+
+ const Component = asChild ? Slot : "div";
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/image-lightbox/index.tsx b/src/big-picture/src/components/common/image-lightbox/index.tsx
new file mode 100644
index 000000000..c4fbd3378
--- /dev/null
+++ b/src/big-picture/src/components/common/image-lightbox/index.tsx
@@ -0,0 +1,16 @@
+import "./styles.scss";
+
+import { Backdrop } from "../backdrop";
+
+export interface ImageLightboxProps {
+ src: string;
+ alt: string;
+}
+
+export function ImageLightbox({ src, alt }: Readonly) {
+ return (
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/image-lightbox/styles.scss b/src/big-picture/src/components/common/image-lightbox/styles.scss
new file mode 100644
index 000000000..44765b16e
--- /dev/null
+++ b/src/big-picture/src/components/common/image-lightbox/styles.scss
@@ -0,0 +1,6 @@
+.image-lightbox {
+ object-fit: cover;
+ user-select: none;
+ width: 60%;
+ max-width: 1000px;
+}
diff --git a/src/big-picture/src/components/common/index.ts b/src/big-picture/src/components/common/index.ts
new file mode 100644
index 000000000..610e25fb6
--- /dev/null
+++ b/src/big-picture/src/components/common/index.ts
@@ -0,0 +1,36 @@
+export * from "./accordion";
+export * from "./animated-hero-image";
+export * from "./backdrop";
+export * from "./button";
+export * from "./checkbox";
+export * from "./chip";
+export * from "./divider";
+export * from "./game-card";
+export * from "./horizontal-card";
+export * from "./image-lightbox";
+export * from "./input";
+export * from "./list-card";
+export * from "./modal";
+export * from "./route-anchor";
+export * from "./scroll-area";
+export * from "./source-anchor";
+export * from "./tabs";
+export * from "./tooltip";
+export * from "./typography";
+export * from "./user-profile";
+export * from "./vertical-game-card";
+export * from "./vertical-store-game-card";
+export * from "./horizontal-focus-group";
+export * from "./vertical-focus-group";
+export * from "./grid-focus-group";
+export * from "./navigation-layer";
+export * from "./focus-item";
+export * from "./diagnostics";
+export * from "./scroll-area";
+export * from "./route-anchor";
+export * from "./button";
+export * from "./input";
+export * from "./typography";
+export * from "./divider";
+export * from "./user-profile";
+export * from "./box";
diff --git a/src/big-picture/src/components/common/input/index.tsx b/src/big-picture/src/components/common/input/index.tsx
new file mode 100644
index 000000000..79ed9d2b4
--- /dev/null
+++ b/src/big-picture/src/components/common/input/index.tsx
@@ -0,0 +1,98 @@
+import "./styles.scss";
+
+import cn from "classnames";
+import { Typography } from "../typography";
+import {
+ forwardRef,
+ type InputHTMLAttributes,
+ type ReactNode,
+ useRef,
+} from "react";
+import { FocusItem } from "..";
+import type { FocusOverrides, NavigationNodeState } from "../../../services";
+
+export interface InputProps extends InputHTMLAttributes {
+ label?: string;
+ hint?: string;
+ error?: boolean;
+ iconLeft?: ReactNode;
+ iconRight?: ReactNode;
+ focusId?: string;
+ focusNavigationOverrides?: FocusOverrides;
+ focusNavigationState?: NavigationNodeState;
+}
+
+export const Input = forwardRef(function Input(
+ {
+ type = "text",
+ placeholder = "Placeholder",
+ label,
+ hint,
+ error = false,
+ disabled = false,
+ iconLeft,
+ iconRight,
+ focusId,
+ focusNavigationOverrides,
+ focusNavigationState,
+ ...props
+ },
+ ref
+) {
+ const inputRef = useRef(null);
+ const resolvedFocusNavigationState =
+ focusNavigationState ?? (disabled ? "disabled" : "active");
+
+ const setInputRef = (element: HTMLInputElement | null) => {
+ inputRef.current = element;
+
+ if (typeof ref === "function") {
+ ref(element);
+ } else if (ref) {
+ ref.current = element;
+ }
+ };
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
inputRef.current?.focus() }}
+ navigationOverrides={focusNavigationOverrides}
+ navigationState={resolvedFocusNavigationState}
+ >
+
+
+ {iconLeft && (
+
{iconLeft}
+ )}
+ {iconRight && (
+
{iconRight}
+ )}
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+});
diff --git a/src/big-picture/src/components/common/input/styles.scss b/src/big-picture/src/components/common/input/styles.scss
new file mode 100644
index 000000000..583a39313
--- /dev/null
+++ b/src/big-picture/src/components/common/input/styles.scss
@@ -0,0 +1,121 @@
+.input {
+ width: 100%;
+ height: 44px;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
+ border-radius: calc(var(--spacing-unit) * 2);
+ font-family: var(--font-space-grotesk);
+ font-size: 14px;
+ line-height: 16.59px;
+ border: 1px solid var(--secondary-border);
+ background-color: var(--background);
+ color: var(--text);
+ transition: border-color 0.2s ease-in-out;
+
+ &[data-icon-left="true"] {
+ padding-left: 46px;
+ }
+
+ &[data-icon-right="true"] {
+ padding-right: 46px;
+ }
+
+ &:hover {
+ border-color: var(--secondary-hover);
+ }
+
+ &:focus-visible,
+ [data-focus-visible="true"] & {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.6);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &::placeholder {
+ color: var(--text-secondary);
+ }
+
+ &--error {
+ border-color: var(--error);
+
+ &:hover {
+ border-color: var(--error);
+ }
+
+ &:focus-visible {
+ border-color: var(--error);
+ }
+ }
+
+ &-container {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ width: 100%;
+ }
+
+ &-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ width: 100%;
+
+ > [data-focus-wrapper] {
+ width: 100%;
+ }
+
+ &:hover {
+ .input-icon--left {
+ color: var(--text-primary);
+ }
+ }
+
+ &--icon::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ z-index: 2;
+ }
+ }
+
+ &-icon {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ transition: color 0.2s ease-in-out;
+
+ &--left {
+ left: 10px;
+ }
+
+ &--right {
+ right: 10px;
+ }
+ }
+
+ &-label {
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: normal;
+ }
+
+ &-hint {
+ color: var(--text-secondary);
+ font-size: 10px;
+ line-height: normal;
+
+ &--error {
+ color: var(--error);
+ }
+ }
+}
diff --git a/src/big-picture/src/components/common/list-card/index.tsx b/src/big-picture/src/components/common/list-card/index.tsx
new file mode 100644
index 000000000..365e0bbba
--- /dev/null
+++ b/src/big-picture/src/components/common/list-card/index.tsx
@@ -0,0 +1,55 @@
+import "./styles.scss";
+
+import { SourceAnchor } from "../source-anchor";
+import type { AnchorHTMLAttributes, ReactNode } from "react";
+
+export interface ListCardProps extends AnchorHTMLAttributes {
+ title: string;
+ description: string;
+ image: string;
+ action?: ReactNode;
+ sources?: string[];
+ href: string;
+}
+
+export function ListCard({
+ title,
+ description,
+ image,
+ action,
+ sources,
+ href,
+ ...props
+}: Readonly) {
+ return (
+
+
+
+

+
+
+
+
{title}
+
+ {description}
+
+ {sources && (
+
+ {sources.map((source) => (
+
+ ))}
+
+ )}
+
+
{action}
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/list-card/styles.scss b/src/big-picture/src/components/common/list-card/styles.scss
new file mode 100644
index 000000000..1417538d1
--- /dev/null
+++ b/src/big-picture/src/components/common/list-card/styles.scss
@@ -0,0 +1,81 @@
+.list-card {
+ display: flex;
+ flex-direction: row;
+ height: 100px;
+ width: 100%;
+ border-radius: calc(var(--spacing-unit) * 2);
+ align-items: center;
+ overflow: hidden;
+ transition: background-color 0.1s ease-in-out;
+ &:hover {
+ background-color: var(--secondary);
+ }
+
+ &__image {
+ width: 200px;
+ height: 100px;
+ border-radius: var(--spacing-unit);
+ overflow: hidden;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: row;
+ gap: var(--spacing-unit);
+ padding: calc(var(--spacing-unit) * 4);
+ justify-content: space-between;
+ flex: 1;
+ height: 100px;
+
+ &:hover &__action {
+ opacity: 1;
+ }
+
+ &__info {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-unit);
+
+ &__title {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ color: var(--primary);
+ opacity: 0.8;
+ }
+
+ &__description {
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ color: var(--primary);
+ opacity: 0.5;
+ }
+
+ &__sources {
+ margin-top: var(--spacing-unit);
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+ }
+
+ &__action {
+ opacity: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 0.05s ease-in-out;
+ margin-right: calc(var(--spacing-unit) * 2);
+ }
+ }
+}
diff --git a/src/big-picture/src/components/common/modal/index.tsx b/src/big-picture/src/components/common/modal/index.tsx
new file mode 100644
index 000000000..ee365d027
--- /dev/null
+++ b/src/big-picture/src/components/common/modal/index.tsx
@@ -0,0 +1,103 @@
+import "./styles.scss";
+
+import { useCallback, useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import cn from "classnames";
+
+import { Backdrop } from "../backdrop";
+import { IS_BROWSER } from "../../../constants";
+
+export interface ModalProps {
+ visible: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ clickOutsideToClose?: boolean;
+ className?: string;
+}
+
+export function Modal({
+ visible,
+ onClose,
+ children,
+ clickOutsideToClose = true,
+ className,
+}: Readonly) {
+ const modalContentRef = useRef(null);
+
+ const isTopMostModal = () => {
+ if (
+ document.querySelector(
+ ".featurebase-widget-overlay.featurebase-display-block"
+ )
+ )
+ return false;
+
+ const openModals = document.querySelectorAll("[role=dialog]");
+ return (
+ openModals.length &&
+ openModals[openModals.length - 1] === modalContentRef.current
+ );
+ };
+
+ const handleCloseClick = useCallback(() => {
+ onClose();
+ }, [onClose]);
+
+ useEffect(() => {
+ if (!visible) return;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isTopMostModal()) {
+ handleCloseClick();
+ }
+ };
+
+ globalThis.window.addEventListener("keydown", onKeyDown);
+ return () => globalThis.window.removeEventListener("keydown", onKeyDown);
+ }, [visible, handleCloseClick]);
+
+ useEffect(() => {
+ if (!clickOutsideToClose || !visible) return;
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (!isTopMostModal()) return;
+ if (
+ modalContentRef.current &&
+ !modalContentRef.current.contains(e.target as Node)
+ ) {
+ handleCloseClick();
+ }
+ };
+
+ globalThis.window.addEventListener("mousedown", onMouseDown);
+ return () =>
+ globalThis.window.removeEventListener("mousedown", onMouseDown);
+ }, [clickOutsideToClose, visible, handleCloseClick]);
+
+ if (!IS_BROWSER) return null;
+
+ const portalTarget = document.getElementById("root") ?? document.body;
+
+ return createPortal(
+
+ {visible && (
+
+
+ {children}
+
+
+ )}
+ ,
+ portalTarget
+ );
+}
diff --git a/src/big-picture/src/components/common/modal/styles.scss b/src/big-picture/src/components/common/modal/styles.scss
new file mode 100644
index 000000000..446a5ff80
--- /dev/null
+++ b/src/big-picture/src/components/common/modal/styles.scss
@@ -0,0 +1,10 @@
+.modal {
+ border-radius: 8px;
+ min-width: 400px;
+ max-width: 600px;
+ max-height: 100%;
+ overflow: hidden;
+ background-color: #0e0e0e;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/src/big-picture/src/components/common/navigation-layer/index.tsx b/src/big-picture/src/components/common/navigation-layer/index.tsx
new file mode 100644
index 000000000..f87d73419
--- /dev/null
+++ b/src/big-picture/src/components/common/navigation-layer/index.tsx
@@ -0,0 +1,55 @@
+import { NavigationService } from "../../../services";
+import { FocusLayerContext } from "../../context";
+import { type ReactNode, useEffect, useId } from "react";
+
+interface NavigationLayerProps {
+ layerId?: string;
+ rootRegionId?: string;
+ initialFocusId?: string;
+ initialFocusRegionId?: string;
+ children: ReactNode;
+}
+
+export function NavigationLayer({
+ layerId,
+ rootRegionId,
+ initialFocusId,
+ initialFocusRegionId,
+ children,
+}: Readonly) {
+ const generatedId = useId();
+ const navigation = NavigationService.getInstance();
+ const resolvedLayerId =
+ layerId ?? `navigation-layer-${generatedId.replaceAll(":", "")}`;
+
+ useEffect(() => {
+ const unregisterLayer = navigation.registerLayer({
+ id: resolvedLayerId,
+ rootRegionId,
+ isPersistent: Boolean(layerId),
+ });
+
+ navigation.focusInitialInLayer({
+ layerId: resolvedLayerId,
+ initialFocusId,
+ initialFocusRegionId,
+ });
+
+ return () => {
+ unregisterLayer();
+ };
+ }, [
+ initialFocusId,
+ initialFocusRegionId,
+ layerId,
+ navigation,
+ resolvedLayerId,
+ rootRegionId,
+ ]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/big-picture/src/components/common/route-anchor/index.tsx b/src/big-picture/src/components/common/route-anchor/index.tsx
new file mode 100644
index 000000000..08e9a560c
--- /dev/null
+++ b/src/big-picture/src/components/common/route-anchor/index.tsx
@@ -0,0 +1,74 @@
+import "./styles.scss";
+
+import { HeartStraightIcon } from "@phosphor-icons/react";
+import type { AnchorHTMLAttributes, ReactNode } from "react";
+import { Link } from "react-router-dom";
+import { FocusItem } from "..";
+import type { FocusOverrides } from "../../../services";
+
+export interface RouteAnchorProps
+ extends Omit, "href"> {
+ label: string;
+ icon: ReactNode | string;
+ href: string;
+ active?: boolean;
+ disabled?: boolean;
+ isFavorite?: boolean;
+ focusId?: string;
+ focusNavigationOverrides?: FocusOverrides;
+}
+
+export const RouteAnchor = ({
+ href,
+ label,
+ icon,
+ active = false,
+ disabled = false,
+ isFavorite = false,
+ focusId,
+ focusNavigationOverrides,
+ ...props
+}: Readonly) => {
+ const isGameIcon = typeof icon === "string";
+
+ return (
+
+
+
+
+
+ {isGameIcon ? (
+

+ ) : (
+ icon
+ )}
+
+
{label}
+
+ {isFavorite && (
+
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/big-picture/src/components/common/route-anchor/styles.scss b/src/big-picture/src/components/common/route-anchor/styles.scss
new file mode 100644
index 000000000..e5d7487e7
--- /dev/null
+++ b/src/big-picture/src/components/common/route-anchor/styles.scss
@@ -0,0 +1,125 @@
+.route-anchor {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 3);
+ padding: calc(var(--spacing-unit) * 2);
+ border-radius: var(--spacing-unit);
+ transition: background-color 0.2s ease-in-out;
+ width: 100%;
+ color: var(--text);
+ user-select: none;
+
+ &--extra-padding {
+ padding: calc(var(--spacing-unit) * 3);
+ }
+
+ &--active {
+ color: var(--primary);
+ background-color: var(--secondary-hover);
+ }
+
+ &:not(&--active):hover {
+ background-color: var(--secondary);
+ }
+
+ [data-focus-visible="true"] & {
+ box-shadow: inset 0 0 0 1px var(--text-secondary);
+ }
+
+ [data-focus-visible="true"] &:not(&--active) {
+ background-color: var(--secondary);
+ }
+
+ &__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: calc(var(--spacing-unit) / 2);
+ flex-shrink: 0;
+ overflow: hidden;
+
+ &--small-size {
+ width: 24px;
+ height: 24px;
+ }
+
+ &--large-size {
+ width: 32px;
+ height: 32px;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+
+ &__label {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ line-clamp: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__favorite {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ }
+}
+
+.state-wrapper {
+ width: 100%;
+
+ &--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &--active {
+ pointer-events: none;
+ }
+}
+
+.tooltip-content {
+ background-color: var(--secondary);
+ color: var(--text);
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
+ border-radius: var(--spacing-unit);
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ animation-duration: 200ms;
+ animation-timing-function: ease-in-out;
+ will-change: transform, opacity;
+
+ &[data-state="delayed-open"] {
+ animation-name: fadeIn;
+ }
+
+ &__label {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateX(-2px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
diff --git a/src/big-picture/src/components/common/scroll-area/index.tsx b/src/big-picture/src/components/common/scroll-area/index.tsx
new file mode 100644
index 000000000..9a7f9f0ca
--- /dev/null
+++ b/src/big-picture/src/components/common/scroll-area/index.tsx
@@ -0,0 +1,52 @@
+import "./styles.scss";
+
+import { type ReactNode, useRef, useEffect } from "react";
+import cn from "classnames";
+
+export interface ScrollAreaProps {
+ children: ReactNode;
+ className?: string;
+ onScroll?: (info: {
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+ }) => void;
+ showScrollbar?: boolean;
+}
+
+export function ScrollArea({
+ children,
+ className,
+ onScroll,
+ showScrollbar = false,
+}: Readonly) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!onScroll) return;
+
+ const el = ref.current;
+ if (!el) return;
+
+ const handleScroll = () => {
+ const { scrollTop, scrollHeight, clientHeight } = el;
+ onScroll({ scrollTop, scrollHeight, clientHeight });
+ };
+
+ el.addEventListener("scroll", handleScroll);
+ return () => el.removeEventListener("scroll", handleScroll);
+ }, [onScroll]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/big-picture/src/components/common/scroll-area/styles.scss b/src/big-picture/src/components/common/scroll-area/styles.scss
new file mode 100644
index 000000000..af296fb93
--- /dev/null
+++ b/src/big-picture/src/components/common/scroll-area/styles.scss
@@ -0,0 +1,14 @@
+.scroll-area {
+ height: 100%;
+ width: 100%;
+ overflow-y: auto;
+
+ &--hide-scrollbar {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+}
diff --git a/src/big-picture/src/components/common/source-anchor/index.tsx b/src/big-picture/src/components/common/source-anchor/index.tsx
new file mode 100644
index 000000000..5c036d6fc
--- /dev/null
+++ b/src/big-picture/src/components/common/source-anchor/index.tsx
@@ -0,0 +1,31 @@
+import "./styles.scss";
+
+import type { AnchorHTMLAttributes } from "react";
+
+export interface SourceAnchorProps
+ extends AnchorHTMLAttributes {
+ title: string;
+ href?: string;
+}
+
+export function SourceAnchor({
+ title,
+ href,
+ ...props
+}: Readonly) {
+ return (
+ <>
+ {href ? (
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/src/big-picture/src/components/common/source-anchor/styles.scss b/src/big-picture/src/components/common/source-anchor/styles.scss
new file mode 100644
index 000000000..12e4121f4
--- /dev/null
+++ b/src/big-picture/src/components/common/source-anchor/styles.scss
@@ -0,0 +1,30 @@
+.source-anchor {
+ background-color: var(--surface);
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ border-radius: var(--spacing-unit);
+ transition: background-color 0.15s ease-in-out;
+
+ .source-anchor__title {
+ color: var(--primary-hover);
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ }
+}
+
+.source-anchor--link {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--secondary);
+ }
+
+ &:focus-visible {
+ outline: 1px solid var(--primary);
+ outline-offset: 2px;
+ border-radius: var(--spacing-unit);
+ }
+}
diff --git a/src/big-picture/src/components/common/tabs/index.tsx b/src/big-picture/src/components/common/tabs/index.tsx
new file mode 100644
index 000000000..65a91f875
--- /dev/null
+++ b/src/big-picture/src/components/common/tabs/index.tsx
@@ -0,0 +1,135 @@
+import "./styles.scss";
+
+import { motion } from "framer-motion";
+import cn from "classnames";
+import {
+ type CSSProperties,
+ type ReactNode,
+ useId,
+ useMemo,
+ useState,
+} from "react";
+import { FocusItem } from "../focus-item";
+import { HorizontalFocusGroup } from "../horizontal-focus-group";
+import type { FocusOverrides } from "../../../services";
+
+export interface TabsItem {
+ id?: string;
+ value: TValue;
+ label: ReactNode;
+ disabled?: boolean;
+ navigationOverrides?: FocusOverrides;
+}
+
+export interface TabsProps {
+ items: Array>;
+ value?: TValue;
+ defaultValue?: TValue;
+ onValueChange?: (value: TValue) => void;
+ trailingAction?: ReactNode;
+ regionId?: string;
+ navigationOverrides?: FocusOverrides;
+ ariaLabel?: string;
+ className?: string;
+}
+
+export function Tabs({
+ items,
+ value,
+ defaultValue,
+ onValueChange,
+ trailingAction,
+ regionId,
+ navigationOverrides,
+ ariaLabel = "Tabs",
+ className,
+}: Readonly>) {
+ const generatedId = useId();
+ const [internalValue, setInternalValue] = useState(
+ defaultValue ?? items[0]?.value
+ );
+ const selectedValue = value ?? internalValue;
+ const indicatorLayoutId = `tabs-indicator-${generatedId}`;
+
+ const selectedItem = useMemo(
+ () => items.find((item) => item.value === selectedValue),
+ [items, selectedValue]
+ );
+
+ const handleSelect = (nextValue: TValue) => {
+ setInternalValue(nextValue);
+ onValueChange?.(nextValue);
+ };
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {items.map((item) => {
+ const isSelected = selectedItem?.value === item.value;
+
+ return (
+
+
+
+ );
+ })}
+
+
+ {trailingAction && (
+
{trailingAction}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/tabs/styles.scss b/src/big-picture/src/components/common/tabs/styles.scss
new file mode 100644
index 000000000..3ce30444e
--- /dev/null
+++ b/src/big-picture/src/components/common/tabs/styles.scss
@@ -0,0 +1,98 @@
+.tabs {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+
+ &__content {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 12);
+ padding: 0 calc(var(--spacing-unit) * 4);
+ }
+
+ &__list {
+ min-width: 0;
+ flex: 1;
+ }
+
+ &__tab {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+ padding: 0 0 calc(var(--spacing-unit) * 3 + 3px);
+ border: 0;
+ background: transparent;
+ color: var(--primary);
+ opacity: 0.5;
+ font-family: var(--font-space-grotesk);
+ font-size: 14px;
+ font-weight: 400;
+ line-height: normal;
+ letter-spacing: 0;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: opacity 0.2s ease-in-out;
+
+ &:hover,
+ &:focus-visible,
+ &[data-focus-visible="true"],
+ &--active {
+ opacity: 0.8;
+ }
+
+ &:focus-visible,
+ &[data-focus-visible="true"] {
+ outline: none;
+ }
+
+ &--disabled {
+ cursor: not-allowed;
+ opacity: 0.3;
+ }
+ }
+
+ &__tab-label {
+ display: inline-block;
+ transform: scale(1);
+ transform-origin: center bottom;
+ transition: transform 0.2s cubic-bezier(0.33, 1, 0.68, 1);
+ }
+
+ &__tab:focus-visible &__tab-label,
+ &__tab[data-focus-visible="true"]:not(:hover) &__tab-label {
+ transform: scale(1.03);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ &__tab-label {
+ transition: none;
+ }
+ }
+
+ &__indicator {
+ position: absolute;
+ right: calc(var(--spacing-unit) * -1);
+ bottom: 0;
+ left: calc(var(--spacing-unit) * -1);
+ height: 3px;
+ background-color: var(--text);
+ box-shadow: 0 0 8px rgba(255, 255, 255, 0.15);
+ }
+
+ &__trailing-action {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ }
+
+ &__divider {
+ width: 100%;
+ height: 1px;
+ margin-top: -1px;
+ background-color: var(--secondary-border);
+ }
+}
diff --git a/src/big-picture/src/components/common/tooltip/index.tsx b/src/big-picture/src/components/common/tooltip/index.tsx
new file mode 100644
index 000000000..814157379
--- /dev/null
+++ b/src/big-picture/src/components/common/tooltip/index.tsx
@@ -0,0 +1,61 @@
+import "./styles.scss";
+
+import { useState, type CSSProperties, type ReactNode } from "react";
+
+export interface TooltipProps {
+ children: ReactNode;
+ content: string;
+ position?: "top" | "bottom" | "left" | "right";
+ showArrow?: boolean;
+ offset?: number;
+ active?: boolean;
+ className?: string;
+ id?: string;
+ style?: CSSProperties;
+}
+
+export function Tooltip({
+ children,
+ content,
+ position = "top",
+ offset = 8,
+ showArrow = true,
+ active = true,
+ className = "",
+ id,
+ style,
+}: Readonly) {
+ const [isHovering, setIsHovering] = useState(false);
+
+ if (!active) return children;
+
+ const tooltipStyle = {
+ "--tooltip-offset": `${offset}px`,
+ } as CSSProperties;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-static-element-interactions
+ setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ onFocus={() => setIsHovering(true)}
+ onBlur={() => setIsHovering(false)}
+ style={style}
+ >
+ {children}
+ {isHovering && (
+
+ {content}
+
+ )}
+
+ );
+}
diff --git a/src/big-picture/src/components/common/tooltip/styles.scss b/src/big-picture/src/components/common/tooltip/styles.scss
new file mode 100644
index 000000000..2827a7302
--- /dev/null
+++ b/src/big-picture/src/components/common/tooltip/styles.scss
@@ -0,0 +1,142 @@
+.tooltip {
+ position: relative;
+ display: inline-flex;
+ width: fit-content;
+ height: fit-content;
+}
+
+.tooltip__portal {
+ --tooltip-offset: 8px;
+
+ position: absolute;
+ background-color: var(--secondary);
+ color: #fff;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
+ border-radius: var(--spacing-unit);
+ white-space: nowrap;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ z-index: 10000;
+ pointer-events: none;
+
+ &.tooltip__content--top {
+ left: 50%;
+ bottom: calc(100% + var(--tooltip-offset));
+ transform: translateX(-50%);
+ animation: fadeInUp 0.3s ease;
+
+ &[data-show-arrow="true"]::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: var(--secondary) transparent transparent transparent;
+ }
+ }
+
+ &.tooltip__content--bottom {
+ left: 50%;
+ top: calc(100% + var(--tooltip-offset));
+ transform: translateX(-50%);
+ animation: fadeInDown 0.3s ease;
+
+ &[data-show-arrow="true"]::after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: transparent transparent var(--secondary) transparent;
+ }
+ }
+
+ &.tooltip__content--left {
+ right: calc(100% + var(--tooltip-offset));
+ top: 50%;
+ transform: translateY(-50%);
+ animation: fadeInLeft 0.3s ease;
+
+ &[data-show-arrow="true"]::after {
+ content: "";
+ position: absolute;
+ left: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: transparent transparent transparent var(--secondary);
+ }
+ }
+
+ &.tooltip__content--right {
+ left: calc(100% + var(--tooltip-offset));
+ top: 50%;
+ transform: translateY(-50%);
+ animation: fadeInRight 0.3s ease;
+
+ &[data-show-arrow="true"]::after {
+ content: "";
+ position: absolute;
+ right: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: transparent var(--secondary) transparent transparent;
+ }
+ }
+}
+
+@keyframes fadeInRight {
+ from {
+ opacity: 0;
+ transform: translateY(-50%) translateX(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(-50%) translateX(0);
+ }
+}
+
+@keyframes fadeInLeft {
+ from {
+ opacity: 0;
+ transform: translateY(-50%) translateX(2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(-50%) translateX(0);
+ }
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
diff --git a/src/big-picture/src/components/common/typography/index.tsx b/src/big-picture/src/components/common/typography/index.tsx
new file mode 100644
index 000000000..86d83b27e
--- /dev/null
+++ b/src/big-picture/src/components/common/typography/index.tsx
@@ -0,0 +1,64 @@
+import "./styles.scss";
+
+import cn from "classnames";
+import type { HTMLAttributes } from "react";
+
+export interface TypographyProps
+ extends HTMLAttributes<
+ HTMLHeadingElement | HTMLParagraphElement | HTMLLabelElement
+ > {
+ variant?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body" | "label";
+}
+
+export function Typography({
+ children,
+ variant = "body",
+ ...props
+}: Readonly) {
+ const { className, ...rest } = props;
+
+ switch (variant) {
+ case "h1":
+ return (
+
+ {children}
+
+ );
+ case "h2":
+ return (
+
+ {children}
+
+ );
+ case "h3":
+ return (
+
+ {children}
+
+ );
+ case "h4":
+ return (
+
+ {children}
+
+ );
+ case "h5":
+ return (
+
+ {children}
+
+ );
+ case "h6":
+ return (
+
+ {children}
+
+ );
+ default:
+ return (
+
+ {children}
+
+ );
+ }
+}
diff --git a/src/big-picture/src/components/common/typography/styles.scss b/src/big-picture/src/components/common/typography/styles.scss
new file mode 100644
index 000000000..5f897569a
--- /dev/null
+++ b/src/big-picture/src/components/common/typography/styles.scss
@@ -0,0 +1,40 @@
+.typography {
+ color: var(--text);
+
+ &--h1 {
+ font-size: 2.5rem;
+ font-weight: 700;
+ line-height: 3.5rem;
+ }
+
+ &--h2 {
+ font-size: 2rem;
+ font-weight: 700;
+ }
+
+ &--h3 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ }
+
+ &--h4 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ }
+
+ &--h5 {
+ font-size: 1rem;
+ font-weight: 700;
+ }
+
+ &--h6 {
+ font-size: 0.875rem;
+ font-weight: 700;
+ }
+
+ &--body,
+ &--label {
+ font-size: 0.875rem;
+ font-weight: 400;
+ }
+}
diff --git a/src/big-picture/src/components/common/user-profile/index.tsx b/src/big-picture/src/components/common/user-profile/index.tsx
new file mode 100644
index 000000000..e9bcecb08
--- /dev/null
+++ b/src/big-picture/src/components/common/user-profile/index.tsx
@@ -0,0 +1,122 @@
+import "./styles.scss";
+
+import {
+ BellIcon,
+ CheckIcon,
+ CopyIcon,
+ UsersIcon,
+} from "@phosphor-icons/react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+export interface UserProfileProps {
+ image: string;
+ name: string;
+ friendCode: string;
+}
+
+interface UserProfileContentProps {
+ image: string;
+ name: string;
+ friendCode: string;
+}
+
+interface UserProfileActionsProps {
+ friendsCount: number;
+}
+
+function UserProfileActions({
+ friendsCount,
+}: Readonly) {
+ const [isHovering, setIsHovering] = useState(false);
+
+ return (
+
+
+
+
+
+
+ {friendsCount}
+ {" "}
+
+ friends online
+
+
+
+
+
+
+ );
+}
+
+function UserProfileContent({
+ image,
+ name,
+ friendCode,
+}: Readonly) {
+ const [isCopied, setIsCopied] = useState(false);
+
+ const handleCopy = () => {
+ if (isCopied) return;
+ setIsCopied(true);
+ navigator.clipboard.writeText(friendCode).catch(() => {});
+
+ globalThis.window.setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ };
+
+ return (
+
+

+
+
+
{name}
+
+
+
+ );
+}
+
+export function UserProfile({
+ image,
+ name,
+ friendCode,
+}: Readonly) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/user-profile/styles.scss b/src/big-picture/src/components/common/user-profile/styles.scss
new file mode 100644
index 000000000..d3b2864e8
--- /dev/null
+++ b/src/big-picture/src/components/common/user-profile/styles.scss
@@ -0,0 +1,123 @@
+.user-profile-container {
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ margin-top: calc(var(--spacing-unit) * 2);
+ transition: background-color 0.2s ease-in-out;
+ background-color: var(--surface);
+ border-top: 1px solid var(--secondary-hover);
+ padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 3);
+ gap: calc(var(--spacing-unit) * 4);
+}
+
+.user-profile-content {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 3);
+ align-self: stretch;
+
+ &__image {
+ display: flex;
+ align-items: center;
+ width: 48px;
+ height: 48px;
+ border-radius: calc(var(--spacing-unit) * 1.5);
+ object-fit: cover;
+ object-position: center;
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+
+ &__info {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: var(--spacing-unit);
+
+ &__name {
+ color: var(--Beta-Text-Main, #cecece);
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ }
+
+ &__friend-code {
+ color: var(--Beta-Text-Secondary, #838383);
+ font-size: 13px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-unit);
+ cursor: pointer;
+
+ &:hover {
+ .user-profile-content__info__friend-code__icon {
+ opacity: 1;
+ }
+ }
+
+ &__icon {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ }
+ }
+ }
+}
+
+.user-profile__actions {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+
+ &__friends {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ opacity: 0.5;
+ transition: opacity 0.2s ease-in-out;
+ padding: calc(var(--spacing-unit) * 1) calc(var(--spacing-unit) * 1.5);
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &__count {
+ color: #fff;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ }
+
+ &__count__number {
+ font-weight: 600;
+ }
+ }
+
+ &__notification {
+ display: flex;
+ padding: var(--spacing-unit);
+ align-items: center;
+ opacity: 0.5;
+ transition: opacity 0.2s ease-in-out;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &__button {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-unit);
+ }
+}
diff --git a/src/big-picture/src/components/common/vertical-focus-group/index.tsx b/src/big-picture/src/components/common/vertical-focus-group/index.tsx
new file mode 100644
index 000000000..bd02727cc
--- /dev/null
+++ b/src/big-picture/src/components/common/vertical-focus-group/index.tsx
@@ -0,0 +1,109 @@
+import { Slot } from "@radix-ui/react-slot";
+import {
+ FocusRegionContext,
+ useFocusLayerId,
+ useFocusRegionId,
+} from "../../context";
+import {
+ type FocusAutoScrollMode,
+ type FocusOverrides,
+ NavigationService,
+} from "../../../services";
+import {
+ type HTMLAttributes,
+ type ReactNode,
+ useEffect,
+ useId,
+ useRef,
+} from "react";
+
+interface VerticalFocusGroupProps extends HTMLAttributes {
+ regionId?: string;
+ navigationOverrides?: FocusOverrides;
+ autoScrollMode?: FocusAutoScrollMode;
+ getScrollAnchor?: () => HTMLElement | null;
+ asChild?: boolean;
+ children: ReactNode;
+}
+
+export function VerticalFocusGroup({
+ regionId,
+ navigationOverrides,
+ autoScrollMode = "auto",
+ getScrollAnchor,
+ asChild = false,
+ className,
+ style,
+ children,
+ ...props
+}: Readonly) {
+ const generatedId = useId();
+ const parentRegionId = useFocusRegionId();
+ const layerId = useFocusLayerId();
+ const navigation = NavigationService.getInstance();
+ const initialNavigationOverridesRef = useRef(navigationOverrides);
+ const initialGetScrollAnchorRef = useRef(getScrollAnchor);
+ const ref = useRef(null);
+ const resolvedRegionId =
+ regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
+
+ useEffect(() => {
+ return navigation.registerRegion({
+ id: resolvedRegionId,
+ parentRegionId,
+ orientation: "vertical",
+ layerId,
+ navigationOverrides: initialNavigationOverridesRef.current,
+ autoScrollMode,
+ isPersistent: Boolean(regionId),
+ getElement: () => ref.current,
+ getScrollAnchor: initialGetScrollAnchorRef.current,
+ });
+ }, [
+ autoScrollMode,
+ layerId,
+ navigation,
+ parentRegionId,
+ regionId,
+ resolvedRegionId,
+ ]);
+
+ useEffect(() => {
+ navigation.updateRegion(resolvedRegionId, {
+ autoScrollMode,
+ getScrollAnchor,
+ navigationOverrides,
+ });
+ }, [
+ autoScrollMode,
+ getScrollAnchor,
+ navigation,
+ navigationOverrides,
+ resolvedRegionId,
+ ]);
+
+ const Component = asChild ? Slot : "div";
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/vertical-game-card/index.tsx b/src/big-picture/src/components/common/vertical-game-card/index.tsx
new file mode 100644
index 000000000..58358c6bd
--- /dev/null
+++ b/src/big-picture/src/components/common/vertical-game-card/index.tsx
@@ -0,0 +1,115 @@
+import "./styles.scss";
+
+import { SparkleIcon, TrophyIcon } from "@phosphor-icons/react";
+import cn from "classnames";
+import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
+
+export interface VerticalGameCardProps {
+ coverImageUrl?: string | null;
+ gameTitle: string;
+ subtitle: string;
+ progressLabel?: string;
+ progressValue?: number;
+ progressColor?: string;
+ action?: ReactNode;
+ forceHovered?: boolean;
+ className?: string;
+ onClick?: () => void;
+ onCoverImageError?: () => void;
+}
+
+const DEFAULT_PROGRESS_COLOR = "var(--alert)";
+
+function clampProgress(value: number) {
+ return Math.min(1, Math.max(0, value));
+}
+
+export function VerticalGameCard({
+ coverImageUrl,
+ gameTitle,
+ subtitle,
+ progressLabel,
+ progressValue,
+ progressColor = DEFAULT_PROGRESS_COLOR,
+ action,
+ forceHovered = false,
+ className,
+ onClick,
+ onCoverImageError,
+}: Readonly) {
+ const hasProgress =
+ progressLabel != null &&
+ progressValue != null &&
+ !Number.isNaN(progressValue);
+ const normalizedProgress = hasProgress ? clampProgress(progressValue) : 0;
+ const isCompleted = hasProgress && normalizedProgress === 1;
+
+ const customProperties = {
+ "--vertical-game-card-progress-color": progressColor,
+ "--vertical-game-card-progress-value": normalizedProgress,
+ } as CSSProperties;
+ const ProgressIcon = isCompleted ? SparkleIcon : TrophyIcon;
+
+ const handleCardKeyDown = (event: KeyboardEvent) => {
+ if (onClick == null) return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onClick();
+ }
+ };
+
+ return (
+
+
+ {coverImageUrl ? (
+

+ ) : (
+
+ )}
+
+
+
+
+
+
{gameTitle}
+
{subtitle}
+
+
+
+
+
+
{progressLabel ?? "0/0"}
+
+
+
+
+
+
+ {action &&
{action}
}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/vertical-game-card/styles.scss b/src/big-picture/src/components/common/vertical-game-card/styles.scss
new file mode 100644
index 000000000..b6438ad2c
--- /dev/null
+++ b/src/big-picture/src/components/common/vertical-game-card/styles.scss
@@ -0,0 +1,269 @@
+.vertical-game-card {
+ --vertical-game-card-progress-color: var(--alert);
+ --vertical-game-card-progress-value: 0;
+ width: min(350px, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ border: 1px solid transparent;
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: transparent;
+ outline: none;
+ outline-offset: 3px;
+ transition:
+ background-color 0.2s ease-in-out,
+ border-color 0.2s ease-in-out,
+ outline-color 0.2s ease-in-out;
+ overflow: hidden;
+
+ &__cover {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 268 / 400;
+ border-radius: var(--spacing-unit);
+ overflow: hidden;
+ background-color: var(--surface);
+
+ img,
+ &-placeholder {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ img {
+ object-fit: cover;
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 172%;
+ background: linear-gradient(
+ 35deg,
+ rgba(0, 0, 0, 0.1) 0%,
+ rgba(0, 0, 0, 0.07) 51.5%,
+ rgba(255, 255, 255, 0.15) 64%,
+ rgba(255, 255, 255, 0.1) 100%
+ );
+ transform: translateY(-36%);
+ opacity: 0.5;
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
+ pointer-events: none;
+ z-index: 1;
+ }
+ }
+
+ &__cover-placeholder {
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.08) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ ),
+ var(--secondary);
+ }
+
+ &__body {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 2);
+ padding: 0 0 calc(var(--spacing-unit) * 2);
+ transition: padding 0.2s ease-in-out;
+ }
+
+ &__info {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+
+ &__text {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-unit);
+ }
+
+ &__title,
+ &__subtitle,
+ &__progress-label span {
+ margin: 0;
+ font-size: 14px;
+ line-height: normal;
+ letter-spacing: 0;
+ }
+
+ &__title,
+ &__subtitle,
+ &__progress-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__title {
+ color: var(--text);
+ font-weight: 700;
+ }
+
+ &__subtitle {
+ color: var(--text-secondary);
+ font-weight: 400;
+ }
+
+ &__progress {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+
+ &--placeholder {
+ visibility: hidden;
+ }
+ }
+
+ &__progress-label {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-unit);
+ color: color-mix(
+ in srgb,
+ var(--vertical-game-card-progress-color) 70%,
+ white
+ );
+ font-weight: 400;
+ }
+
+ &__progress-track {
+ position: relative;
+ width: calc(var(--spacing-unit) * 25);
+ height: var(--spacing-unit);
+ border-radius: 999px;
+ background-color: color-mix(
+ in srgb,
+ var(--vertical-game-card-progress-color) 20%,
+ transparent
+ );
+ overflow: hidden;
+ flex-shrink: 0;
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: calc(100% * var(--vertical-game-card-progress-value));
+ border-radius: inherit;
+ background-color: var(--vertical-game-card-progress-color);
+ z-index: 1;
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ opacity: 0;
+ pointer-events: none;
+ z-index: 2;
+ }
+ }
+
+ &__action {
+ max-width: 0;
+ opacity: 0;
+ flex-shrink: 0;
+ overflow: hidden;
+ pointer-events: none;
+ transition:
+ max-width 0.2s ease-in-out,
+ opacity 0.2s ease-in-out;
+ }
+
+ &:hover,
+ &:focus-within,
+ &--force-hovered,
+ [data-focused="true"] > &,
+ [data-focus-visible="true"] > & {
+ background-color: var(--surface);
+ border-color: var(--secondary-border);
+
+ .vertical-game-card__cover::before {
+ opacity: 1;
+ transform: translateY(-20%);
+ }
+
+ .vertical-game-card__body {
+ padding-inline: calc(var(--spacing-unit) * 2);
+ }
+
+ .vertical-game-card__action {
+ max-width: calc(var(--spacing-unit) * 11);
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+
+ [data-focus-visible="true"] > & {
+ outline: 2px solid var(--primary);
+ }
+
+ &--completed {
+ .vertical-game-card__progress-track {
+ &::before {
+ opacity: 0;
+ inset: 0 auto 0 -62%;
+ width: 62%;
+ background: linear-gradient(
+ 100deg,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 0.03) 10%,
+ rgba(255, 255, 255, 0.08) 26%,
+ rgba(255, 255, 255, 0.16) 40%,
+ rgba(255, 255, 255, 0.25) 50%,
+ rgba(255, 255, 255, 0.16) 60%,
+ rgba(255, 255, 255, 0.08) 74%,
+ rgba(255, 255, 255, 0.03) 90%,
+ rgba(255, 255, 255, 0) 100%
+ );
+ animation: vertical-game-card-complete-shine 1.7s
+ cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ }
+ }
+ }
+}
+
+@keyframes vertical-game-card-complete-shine {
+ 0% {
+ opacity: 0;
+ transform: translateX(0);
+ }
+
+ 14% {
+ opacity: 0.5;
+ }
+
+ 82% {
+ opacity: 0.5;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateX(262%);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .vertical-game-card--completed .vertical-game-card__progress-track::before {
+ animation: none;
+ opacity: 0;
+ }
+}
diff --git a/src/big-picture/src/components/common/vertical-store-game-card/index.tsx b/src/big-picture/src/components/common/vertical-store-game-card/index.tsx
new file mode 100644
index 000000000..08aecd073
--- /dev/null
+++ b/src/big-picture/src/components/common/vertical-store-game-card/index.tsx
@@ -0,0 +1,74 @@
+import "./styles.scss";
+
+import cn from "classnames";
+import type { KeyboardEvent } from "react";
+
+export interface VerticalStoreGameCardProps {
+ coverImageUrl?: string | null;
+ gameTitle: string;
+ downloadSourceCount: number;
+ forceHovered?: boolean;
+ className?: string;
+ onClick?: () => void;
+ onCoverImageError?: () => void;
+}
+
+function getDownloadSourcesLabel(downloadSourceCount: number) {
+ const normalizedCount = Math.max(0, downloadSourceCount);
+ const suffix = normalizedCount === 1 ? "source" : "sources";
+
+ return `${normalizedCount} download ${suffix}`;
+}
+
+export function VerticalStoreGameCard({
+ coverImageUrl,
+ gameTitle,
+ downloadSourceCount,
+ forceHovered = false,
+ className,
+ onClick,
+ onCoverImageError,
+}: Readonly) {
+ const handleCardKeyDown = (event: KeyboardEvent) => {
+ if (onClick == null) return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onClick();
+ }
+ };
+
+ return (
+
+
+ {coverImageUrl ? (
+

+ ) : (
+
+ )}
+
+
+
+
{gameTitle}
+
+ {getDownloadSourcesLabel(downloadSourceCount)}
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/common/vertical-store-game-card/styles.scss b/src/big-picture/src/components/common/vertical-store-game-card/styles.scss
new file mode 100644
index 000000000..3ee78ebf1
--- /dev/null
+++ b/src/big-picture/src/components/common/vertical-store-game-card/styles.scss
@@ -0,0 +1,122 @@
+.vertical-store-game-card {
+ width: min(350px, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 5);
+ border: 1px solid transparent;
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: transparent;
+ outline: none;
+ outline-offset: 3px;
+ overflow: hidden;
+ transition:
+ background-color 0.2s ease-in-out,
+ border-color 0.2s ease-in-out,
+ outline-color 0.2s ease-in-out;
+
+ &__cover {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 268 / 400;
+ border-radius: var(--spacing-unit);
+ overflow: hidden;
+ background-color: var(--surface);
+
+ img,
+ &-placeholder {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ img {
+ object-fit: cover;
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 172%;
+ background: linear-gradient(
+ 35deg,
+ rgba(0, 0, 0, 0.1) 0%,
+ rgba(0, 0, 0, 0.07) 51.5%,
+ rgba(255, 255, 255, 0.15) 64%,
+ rgba(255, 255, 255, 0.1) 100%
+ );
+ transform: translateY(-36%);
+ opacity: 0.5;
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
+ pointer-events: none;
+ z-index: 1;
+ }
+ }
+
+ &__cover-placeholder {
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.08) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ ),
+ var(--secondary);
+ }
+
+ &__body {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-unit);
+ padding: 0 0 calc(var(--spacing-unit) * 2);
+ transition: padding 0.2s ease-in-out;
+ }
+
+ &__title,
+ &__subtitle {
+ margin: 0;
+ line-height: normal;
+ letter-spacing: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__title {
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ &__subtitle {
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ &:hover,
+ &:focus-within,
+ &--force-hovered,
+ [data-focused="true"] > &,
+ [data-focus-visible="true"] > & {
+ background-color: var(--surface);
+ border-color: var(--secondary-border);
+
+ .vertical-store-game-card__cover::before {
+ opacity: 1;
+ transform: translateY(-20%);
+ }
+
+ .vertical-store-game-card__body {
+ padding-inline: calc(var(--spacing-unit) * 2);
+ }
+ }
+
+ [data-focus-visible="true"] > & {
+ outline: 2px solid var(--primary);
+ }
+}
diff --git a/src/big-picture/src/components/context/focus-item-actions.context.tsx b/src/big-picture/src/components/context/focus-item-actions.context.tsx
new file mode 100644
index 000000000..de3e3f4ad
--- /dev/null
+++ b/src/big-picture/src/components/context/focus-item-actions.context.tsx
@@ -0,0 +1,20 @@
+import type { FocusItemActionsMeta } from "../../types";
+import { createContext, useContext } from "react";
+
+const defaultFocusItemActionsMeta: FocusItemActionsMeta = {
+ hasPrimary: false,
+ hasSecondary: false,
+ hasPressX: false,
+ hasPressY: false,
+ hasHoldA: false,
+ hasHoldB: false,
+ hasHoldX: false,
+};
+
+export const FocusItemActionsMetaContext = createContext(
+ defaultFocusItemActionsMeta
+);
+
+export function useFocusItemActionsMeta() {
+ return useContext(FocusItemActionsMetaContext);
+}
diff --git a/src/big-picture/src/components/context/focus-layer.context.tsx b/src/big-picture/src/components/context/focus-layer.context.tsx
new file mode 100644
index 000000000..9522cb51c
--- /dev/null
+++ b/src/big-picture/src/components/context/focus-layer.context.tsx
@@ -0,0 +1,10 @@
+import { ROOT_NAVIGATION_LAYER_ID } from "../../services";
+import { createContext, useContext } from "react";
+
+export const FocusLayerContext = createContext(
+ ROOT_NAVIGATION_LAYER_ID
+);
+
+export function useFocusLayerId() {
+ return useContext(FocusLayerContext);
+}
diff --git a/src/big-picture/src/components/context/focus-region.context.tsx b/src/big-picture/src/components/context/focus-region.context.tsx
new file mode 100644
index 000000000..b6760856e
--- /dev/null
+++ b/src/big-picture/src/components/context/focus-region.context.tsx
@@ -0,0 +1,7 @@
+import { createContext, useContext } from "react";
+
+export const FocusRegionContext = createContext(null);
+
+export function useFocusRegionId() {
+ return useContext(FocusRegionContext);
+}
diff --git a/src/big-picture/src/components/context/index.ts b/src/big-picture/src/components/context/index.ts
new file mode 100644
index 000000000..0703a00c6
--- /dev/null
+++ b/src/big-picture/src/components/context/index.ts
@@ -0,0 +1,3 @@
+export * from "./focus-item-actions.context";
+export * from "./focus-layer.context";
+export * from "./focus-region.context";
diff --git a/src/big-picture/src/components/index.ts b/src/big-picture/src/components/index.ts
new file mode 100644
index 000000000..10f39aa39
--- /dev/null
+++ b/src/big-picture/src/components/index.ts
@@ -0,0 +1,4 @@
+export * from "./common";
+export * from "./context";
+export * from "./providers";
+export * from "./pages";
diff --git a/src/big-picture/src/components/pages/game/achievements/index.tsx b/src/big-picture/src/components/pages/game/achievements/index.tsx
new file mode 100644
index 000000000..0629aa294
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/achievements/index.tsx
@@ -0,0 +1,117 @@
+import type { UserAchievement } from "@types";
+import { Link } from "react-router-dom";
+import { Box, FocusItem, Typography } from "../../../common";
+import cn from "classnames";
+import { EyeIcon } from "@phosphor-icons/react/dist/ssr";
+import {
+ GAME_ACHIEVEMENTS_TITLE_ID,
+ GAME_ACHIEVEMENTS_VIEW_ALL_ID,
+ GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+} from "../navigation";
+import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
+
+export interface AchievementsBoxProps {
+ achievements: UserAchievement[];
+}
+
+export function AchievementsBox({
+ achievements,
+}: Readonly) {
+ const achievementsNavigationOverrides: FocusOverrides = {
+ down: {
+ type: "item",
+ itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
+ },
+ right: {
+ type: "block",
+ },
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ },
+ };
+
+ const viewAllNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_ACHIEVEMENTS_TITLE_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ down: {
+ type: "item",
+ itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ },
+ };
+
+ return (
+
+
+
+ Achievements
+
+
+ {achievements.filter((achievement) => achievement.unlocked).length}{" "}
+ / {achievements.length}
+
+
+
+
+ {achievements.slice(0, 5).map((achievement) => (
+
+
+
+
+ {achievement.displayName}
+
+
+ {achievement.description}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ View All Achievements
+
+
+ Time to platinum!
+
+
+
{achievements.length}
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/game-reviews/index.tsx b/src/big-picture/src/components/pages/game/game-reviews/index.tsx
new file mode 100644
index 000000000..e44c885cd
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/game-reviews/index.tsx
@@ -0,0 +1,517 @@
+import {
+ ClockIcon,
+ StarIcon,
+ ThumbsDownIcon,
+ ThumbsUpIcon,
+} from "@phosphor-icons/react";
+import { sanitizeHtml } from "@shared";
+import type { GameReview, GameShop } from "@types";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { IS_DESKTOP } from "../../../../constants";
+import { useDate, useFormat } from "../../../../hooks";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
+import {
+ FocusOverrides,
+ NavigationService,
+} from "../../../../services/navigation.service";
+import {
+ Box,
+ Button,
+ FocusItem,
+ HorizontalFocusGroup,
+ Typography,
+} from "../../../common";
+import {
+ GAME_REVIEWS_LOAD_MORE_ID,
+ GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
+ GAME_REVIEWS_REGION_ID,
+ GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
+ GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
+ GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+ GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ getGameReviewVoteButtonDownvoteId,
+ getGameReviewVoteButtonUpvoteId,
+ getGameReviewVotesRegionId,
+} from "../navigation";
+
+type ReviewSortOption =
+ | "newest"
+ | "oldest"
+ | "score_high"
+ | "score_low"
+ | "most_voted";
+
+interface GameReviewsProps {
+ shop: GameShop;
+ objectId: string;
+}
+
+const REVIEWS_PER_PAGE = 5;
+
+export function GameReviews({ shop, objectId }: Readonly) {
+ const [reviews, setReviews] = useState([]);
+ const [reviewsLoading, setReviewsLoading] = useState(false);
+ const [totalReviewCount, setTotalReviewCount] = useState(0);
+ const [sortBy, setSortBy] = useState("newest");
+ const [page, setPage] = useState(0);
+ const [hasMore, setHasMore] = useState(true);
+ const [votingReviews, setVotingReviews] = useState>(new Set());
+
+ const { formatDistance } = useDate();
+ const { formatNumber, formatPlayTime } = useFormat();
+ const abortControllerRef = useRef(null);
+ const prevHasMoreRef = useRef(true);
+
+ const loadReviews = useCallback(
+ async (reset = false) => {
+ if (!IS_DESKTOP || !objectId || shop === "custom") return;
+
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ setReviewsLoading(true);
+ try {
+ const skip = reset ? 0 : page * REVIEWS_PER_PAGE;
+ const params = new URLSearchParams({
+ take: String(REVIEWS_PER_PAGE),
+ skip: String(skip),
+ sortBy,
+ });
+
+ const response = await globalThis.window.electron.hydraApi.get<{
+ reviews: GameReview[];
+ totalCount: number;
+ }>(`/games/${shop}/${objectId}/reviews?${params.toString()}`, {
+ needsAuth: false,
+ });
+
+ if (abortController.signal.aborted) return;
+
+ const reviewsData = response?.reviews ?? [];
+ const reviewCount = response?.totalCount ?? 0;
+
+ if (reset) {
+ setReviews(reviewsData);
+ setPage(0);
+ } else {
+ setReviews((prev) => [...prev, ...reviewsData]);
+ }
+
+ setTotalReviewCount(reviewCount);
+ setHasMore(reviewsData.length === REVIEWS_PER_PAGE);
+ } catch (error) {
+ if (!abortController.signal.aborted) {
+ console.error("Failed to load reviews:", error);
+ }
+ } finally {
+ if (!abortController.signal.aborted) {
+ setReviewsLoading(false);
+ }
+ }
+ },
+ [objectId, shop, page, sortBy]
+ );
+
+ const applyVote = (
+ review: GameReview,
+ voteType: "upvote" | "downvote"
+ ): GameReview => {
+ const updated = { ...review };
+
+ const isToggleOff =
+ (voteType === "upvote" && updated.hasUpvoted) ||
+ (voteType === "downvote" && updated.hasDownvoted);
+
+ if (isToggleOff) {
+ if (voteType === "upvote") {
+ updated.hasUpvoted = false;
+ updated.upvotes = Math.max(0, (updated.upvotes || 0) - 1);
+ } else {
+ updated.hasDownvoted = false;
+ updated.downvotes = Math.max(0, (updated.downvotes || 0) - 1);
+ }
+ return updated;
+ }
+
+ if (voteType === "upvote") {
+ updated.hasUpvoted = true;
+ updated.upvotes = (updated.upvotes || 0) + 1;
+ } else if (voteType === "downvote") {
+ updated.hasDownvoted = true;
+ updated.downvotes = (updated.downvotes || 0) + 1;
+ }
+
+ if (voteType === "upvote" && updated.hasDownvoted) {
+ updated.hasDownvoted = false;
+ updated.downvotes = Math.max(0, (updated.downvotes || 0) - 1);
+ } else if (voteType === "downvote" && updated.hasUpvoted) {
+ updated.hasUpvoted = false;
+ updated.upvotes = Math.max(0, (updated.upvotes || 0) - 1);
+ }
+
+ return updated;
+ };
+
+ const handleVote = async (
+ reviewId: string,
+ voteType: "upvote" | "downvote"
+ ) => {
+ if (!objectId || votingReviews.has(reviewId)) return;
+
+ setVotingReviews((prev) => new Set(prev).add(reviewId));
+
+ const reviewIndex = reviews.findIndex((r) => r.id === reviewId);
+ if (reviewIndex === -1) {
+ setVotingReviews((prev) => {
+ const next = new Set(prev);
+ next.delete(reviewId);
+ return next;
+ });
+ return;
+ }
+
+ const originalReview = { ...reviews[reviewIndex] };
+ const updatedReviews = [...reviews];
+ updatedReviews[reviewIndex] = applyVote(originalReview, voteType);
+ setReviews(updatedReviews);
+
+ try {
+ await globalThis.window.electron.hydraApi.put(
+ `/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
+ { data: {} }
+ );
+ } catch {
+ const rolledBack = [...reviews];
+ rolledBack[reviewIndex] = originalReview;
+ setReviews(rolledBack);
+ } finally {
+ setTimeout(() => {
+ setVotingReviews((prev) => {
+ const next = new Set(prev);
+ next.delete(reviewId);
+ return next;
+ });
+ }, 500);
+ }
+ };
+
+ const handleSortChange = (newSortBy: ReviewSortOption) => {
+ if (newSortBy !== sortBy) {
+ setSortBy(newSortBy);
+ setPage(0);
+ setHasMore(true);
+ }
+ };
+
+ const loadMore = () => {
+ if (!reviewsLoading && hasMore) {
+ setPage((prev) => prev + 1);
+ }
+ };
+
+ useEffect(() => {
+ loadReviews(true);
+ }, [sortBy, objectId, loadReviews]);
+
+ useEffect(() => {
+ if (page > 0) {
+ loadReviews(false);
+ }
+ }, [page, loadReviews]);
+
+ useEffect(() => {
+ if (prevHasMoreRef.current && !hasMore && reviews.length > 0) {
+ const lastReview = reviews[reviews.length - 1];
+ const targetId = getGameReviewVoteButtonUpvoteId(lastReview.id);
+ requestAnimationFrame(() => {
+ NavigationService.getInstance().setFocus(targetId);
+ });
+ }
+ prevHasMoreRef.current = hasMore;
+ }, [hasMore, reviews]);
+
+ useEffect(() => {
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, []);
+
+ if (reviewsLoading && reviews.length === 0) {
+ return null;
+ }
+
+ const primaryFilterNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+ },
+ left: {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
+ },
+ };
+
+ const secondaryFilterNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
+ },
+ };
+
+ const thirdFilterNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ },
+ };
+
+ const upvoteButtonNavigationOverrides = (
+ reviewId: string
+ ): FocusOverrides => ({
+ left: {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ right: {
+ type: "item",
+ itemId: getGameReviewVoteButtonDownvoteId(reviewId),
+ },
+ });
+
+ const downvoteButtonNavigationOverrides = (
+ reviewId: string
+ ): FocusOverrides => ({
+ left: {
+ type: "item",
+ itemId: getGameReviewVoteButtonUpvoteId(reviewId),
+ },
+ right: {
+ type: "block",
+ },
+ });
+
+ const loadMoreNavigationOverrides: FocusOverrides = {
+ right: {
+ type: "block",
+ },
+ down: {
+ type: "block",
+ },
+ };
+
+ return (
+
+
+
Reviews
+
+
+ {totalReviewCount}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {reviews.length === 0 ? (
+
+
+ No reviews yet
+
+
+ ) : (
+ reviews.map((review) => {
+ const isVoting = votingReviews.has(review.id);
+
+ const row = (
+
+
+
+ {review.user.profileImageUrl ? (
+

+ ) : (
+
+ )}
+
+
+
+ {review.user.displayName || "Anonymous"}
+
+
+
+
+
+ {review.score}/5
+
+
+ {Boolean(
+ review.playTimeInSeconds && review.playTimeInSeconds > 0
+ ) && (
+
+
+
+ {formatPlayTime(review.playTimeInSeconds ?? 0)}
+
+
+ )}
+
+
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ return row;
+ })
+ )}
+
+ {hasMore && reviews.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/hero/index.tsx b/src/big-picture/src/components/pages/game/hero/index.tsx
new file mode 100644
index 000000000..99202dedf
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/hero/index.tsx
@@ -0,0 +1,207 @@
+import {
+ HeartIcon,
+ PlayIcon,
+ PlusCircleIcon,
+ XCircleIcon,
+} from "@phosphor-icons/react";
+import type { LibraryGame, ShopDetailsWithAssets } from "@types";
+import { motion } from "framer-motion";
+import { DownloadIcon } from "lucide-react";
+import { useMemo } from "react";
+import {
+ FocusOverrides,
+ FocusOverrideTarget,
+} from "src/big-picture/src/services/navigation.service";
+import { useDominantColor } from "../../../../hooks";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
+import {
+ Button,
+ Divider,
+ HorizontalFocusGroup,
+ Tooltip,
+ Typography,
+} from "../../../common";
+import {
+ GAME_HERO_ACTIONS_REGION_ID,
+ GAME_HERO_PRIMARY_ACTION_ID,
+ GAME_HERO_TOGGLE_FAVORITE_ID,
+ GAME_STATS_REGION_ID,
+ GAME_STATS_TITLE_ID,
+} from "../navigation";
+
+export interface HeroProps {
+ shopDetails: ShopDetailsWithAssets;
+ game: LibraryGame | null;
+ isGameRunning: boolean;
+ isFavorite: boolean;
+ toggleFavorite: () => void;
+ onPlay: () => void;
+ onClose: () => void;
+}
+
+export function Hero({
+ shopDetails,
+ game,
+ isGameRunning,
+ isFavorite,
+ toggleFavorite,
+ onPlay,
+ onClose,
+}: Readonly) {
+ const dominantColor = useDominantColor(game?.libraryHeroImageUrl ?? null);
+ const heroDownNavigationTarget: FocusOverrideTarget = {
+ type: "region",
+ regionId: GAME_STATS_REGION_ID,
+ entryDirection: "down",
+ };
+
+ const toggleFavoriteNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: GAME_HERO_PRIMARY_ACTION_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_STATS_TITLE_ID,
+ },
+ down: heroDownNavigationTarget,
+ };
+
+ const renderActionButton = useMemo(() => {
+ const downloadNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_HERO_TOGGLE_FAVORITE_ID,
+ },
+ down: heroDownNavigationTarget,
+ };
+
+ if (isGameRunning) {
+ return (
+ }
+ onClick={onClose}
+ >
+ Close Game
+
+ );
+ }
+
+ if (game?.executablePath) {
+ return (
+ }
+ onClick={onPlay}
+ >
+ Launch Game
+
+ );
+ }
+
+ if (game) {
+ return (
+ }
+ onClick={() => console.log("Download")}
+ >
+ Download
+
+ );
+ }
+
+ return (
+ }
+ >
+ Add to Library
+
+ );
+ }, [isGameRunning, game, onClose, onPlay, heroDownNavigationTarget]);
+
+ return (
+
+
+
+
+

+
+
+
+
+ {renderActionButton}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/how-long-to-beat/index.tsx b/src/big-picture/src/components/pages/game/how-long-to-beat/index.tsx
new file mode 100644
index 000000000..c213090b5
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/how-long-to-beat/index.tsx
@@ -0,0 +1,76 @@
+import { ClockIcon } from "@phosphor-icons/react";
+import { Typography, Box, TitleBox, FocusItem } from "../../../common";
+import type { HowLongToBeatCategory } from "@types";
+import {
+ GAME_HOW_LONG_TO_BEAT_TITLE_ID,
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ GAME_STATS_TITLE_ID,
+} from "../navigation";
+import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
+
+export interface HowLongToBeatBoxProps {
+ howLongToBeat: HowLongToBeatCategory[];
+}
+
+export function HowLongToBeatBox({
+ howLongToBeat,
+}: Readonly) {
+ const howLongToBeatNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_STATS_TITLE_ID,
+ },
+ right: {
+ type: "block",
+ },
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ },
+ };
+
+ return (
+
+
+
+
+
+
+ {howLongToBeat?.map((item) => (
+ -
+
+
+
+ {item.duration.split(" ")[0]}
+
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/index.ts b/src/big-picture/src/components/pages/game/index.ts
new file mode 100644
index 000000000..eb4d2b839
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/index.ts
@@ -0,0 +1,8 @@
+export * from "./hero";
+export * from "./playtime-bar";
+export * from "./screenshot-carousel";
+export * from "./how-long-to-beat";
+export * from "./achievements";
+export * from "./requirements-to-play";
+export * from "./supported-languages";
+export * from "./game-reviews";
diff --git a/src/big-picture/src/components/pages/game/navigation.ts b/src/big-picture/src/components/pages/game/navigation.ts
new file mode 100644
index 000000000..dc0e9b334
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/navigation.ts
@@ -0,0 +1,51 @@
+export const GAME_HERO_ACTIONS_REGION_ID = "game-hero-actions";
+export const GAME_HERO_PRIMARY_ACTION_ID = "game-hero-primary-action";
+export const GAME_HERO_TOGGLE_FAVORITE_ID = "game-hero-toggle-favorite";
+export const GAME_SCREENSHOT_CAROUSEL_DOTS_REGION_ID =
+ "game-screenshot-carousel-dots";
+export const GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID =
+ "game-screenshot-carousel-prev-button";
+export const GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID =
+ "game-screenshot-carousel-next-button";
+export const GAME_STATS_REGION_ID = "game-stats";
+export const GAME_STATS_TITLE_ID = "game-stats-title";
+export const GAME_HOW_LONG_TO_BEAT_TITLE_ID = "game-how-long-to-beat-title";
+export const GAME_ACHIEVEMENTS_TITLE_ID = "game-achievements-title";
+export const GAME_ACHIEVEMENTS_VIEW_ALL_ID = "game-achievements-view-all";
+export const GAME_REQUIREMENTS_TO_PLAY_BUTTONS_REGION_ID =
+ "game-requirements-to-play-buttons";
+export const GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID =
+ "game-requirements-to-play-minimum-button";
+export const GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID =
+ "game-requirements-to-play-recommended-button";
+export const GAME_SUPPORTED_LANGUAGES_TITLE_ID =
+ "game-supported-languages-title";
+export const GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID =
+ "game-supported-languages-last-row";
+export const GAME_REVIEWS_REGION_ID = "game-reviews";
+export const GAME_REVIEWS_SORT_OPTIONS_REGION_ID = "game-reviews-sort-options";
+export const GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID =
+ "game-reviews-primary-filter-button";
+export const GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID =
+ "game-reviews-secondary-filter-button";
+export const GAME_REVIEWS_THIRD_FILTER_BUTTON_ID =
+ "game-reviews-third-filter-button";
+export const GAME_REVIEWS_LOAD_MORE_ID = "game-reviews-load-more";
+
+export function getGameReviewVotesRegionId(reviewId: string) {
+ return `game-review-votes-${reviewId}`;
+}
+
+export function getGameReviewFirstVoteButtonId(reviewId?: string | null) {
+ if (!reviewId) return null;
+
+ return getGameReviewVoteButtonUpvoteId(reviewId);
+}
+
+export function getGameReviewVoteButtonUpvoteId(reviewId: string) {
+ return `game-review-vote-button-upvote-${reviewId}`;
+}
+
+export function getGameReviewVoteButtonDownvoteId(reviewId: string) {
+ return `game-review-vote-button-downvote-${reviewId}`;
+}
diff --git a/src/big-picture/src/components/pages/game/playtime-bar/index.tsx b/src/big-picture/src/components/pages/game/playtime-bar/index.tsx
new file mode 100644
index 000000000..7905b16b5
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/playtime-bar/index.tsx
@@ -0,0 +1,37 @@
+import { Typography } from "../../../common";
+
+import type { LibraryGame } from "@types";
+import { useDate, useFormat } from "../../../../hooks";
+
+export interface PlaytimeBarProps {
+ game: LibraryGame | null;
+}
+
+export function PlaytimeBar({ game }: Readonly) {
+ const { formatDistance } = useDate();
+ const { formatPlayTime } = useFormat();
+
+ const playTimeInSeconds = (game?.playTimeInMilliseconds ?? 0) / 1000;
+
+ return (
+
+
+
+ Played for {formatPlayTime(playTimeInSeconds)}
+
+
+ {game?.lastTimePlayed ? (
+ <>
+ Last played{" "}
+ {formatDistance(game.lastTimePlayed, new Date(), {
+ addSuffix: true,
+ })}
+ >
+ ) : (
+ <>You haven't played {game?.title} yet>
+ )}
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/requirements-to-play/index.tsx b/src/big-picture/src/components/pages/game/requirements-to-play/index.tsx
new file mode 100644
index 000000000..04ab9ecd6
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/requirements-to-play/index.tsx
@@ -0,0 +1,103 @@
+import { ShopDetails } from "@types";
+import { useMemo, useState } from "react";
+import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
+import { normalizeRequirementsHtml } from "../../../../helpers";
+import { Box, Button, HorizontalFocusGroup, Typography } from "../../../common";
+import {
+ GAME_ACHIEVEMENTS_VIEW_ALL_ID,
+ GAME_REQUIREMENTS_TO_PLAY_BUTTONS_REGION_ID,
+ GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID,
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+} from "../navigation";
+
+export interface RequirementsToPlayProps {
+ shopDetails: ShopDetails;
+}
+
+export function RequirementsToPlay({
+ shopDetails,
+}: Readonly) {
+ const [activeRequirement, setActiveRequirement] = useState<
+ "minimum" | "recommended"
+ >("minimum");
+
+ const normalizedHtml = useMemo(() => {
+ const raw =
+ activeRequirement === "minimum"
+ ? shopDetails.pc_requirements.minimum
+ : shopDetails.pc_requirements.recommended;
+
+ return normalizeRequirementsHtml(raw);
+ }, [activeRequirement, shopDetails.pc_requirements]);
+
+ const minimumButtonNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID,
+ },
+ };
+
+ const recommendedButtonNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ };
+
+ return (
+
+
+
+ System Requirements
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/screenshot-carousel/index.tsx b/src/big-picture/src/components/pages/game/screenshot-carousel/index.tsx
new file mode 100644
index 000000000..38e12e7c2
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/screenshot-carousel/index.tsx
@@ -0,0 +1,321 @@
+import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
+import type { SteamMovie, SteamScreenshot } from "@types";
+import useEmblaCarousel from "embla-carousel-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
+import { useNavigationIsFocused } from "../../../../stores";
+import { FocusItem, HorizontalFocusGroup } from "../../../common";
+import {
+ GAME_HERO_ACTIONS_REGION_ID,
+ GAME_REVIEWS_REGION_ID,
+ GAME_SCREENSHOT_CAROUSEL_DOTS_REGION_ID,
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+ GAME_STATS_TITLE_ID,
+} from "../navigation";
+import { VideoPlayer } from "./video-player";
+interface ScreenshotCarouselProps {
+ screenshots: SteamScreenshot[];
+ videos: SteamMovie[];
+}
+
+type MediaItem = {
+ id: string;
+ type: "video" | "image";
+ src?: string;
+ poster?: string;
+ videoSrc?: string;
+ videoType?: string;
+};
+
+export function ScreenshotCarousel({
+ screenshots,
+ videos,
+}: Readonly) {
+ const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const carouselContainerRef = useRef(null);
+
+ const videoRefs = useRef>([]);
+
+ const isPrevFocused = useNavigationIsFocused(
+ GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID
+ );
+ const isNextFocused = useNavigationIsFocused(
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID
+ );
+
+ useEffect(() => {
+ if ((isPrevFocused || isNextFocused) && carouselContainerRef.current) {
+ carouselContainerRef.current.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ }, [isPrevFocused, isNextFocused]);
+
+ const scrollPrev = () => emblaApi?.scrollPrev();
+ const scrollNext = () => emblaApi?.scrollNext();
+
+ const onSelect = useCallback(() => {
+ if (!emblaApi) return;
+ const index = emblaApi.selectedScrollSnap();
+ setSelectedIndex(index);
+
+ videoRefs.current.forEach((video, idx) => {
+ if (!video) return;
+ if (idx === index) {
+ video.play().catch(() => {});
+ } else {
+ video.pause();
+ }
+ });
+ }, [emblaApi]);
+
+ useEffect(() => {
+ if (!emblaApi) return;
+ emblaApi.on("select", onSelect);
+ onSelect();
+ }, [emblaApi, onSelect]);
+
+ const mediaItems: MediaItem[] = useMemo(() => {
+ const items: MediaItem[] = [];
+
+ videos.forEach((video) => {
+ let videoSrc: string | undefined;
+ let videoType: string | undefined;
+
+ if (video.hls_h264) {
+ videoSrc = video.hls_h264;
+ videoType = "application/x-mpegURL";
+ } else if (video.dash_h264) {
+ videoSrc = video.dash_h264;
+ videoType = "application/dash+xml";
+ } else if (video.dash_av1) {
+ videoSrc = video.dash_av1;
+ videoType = "application/dash+xml";
+ } else if (video.mp4?.max) {
+ videoSrc = video.mp4.max;
+ videoType = "video/mp4";
+ } else if (video.webm?.max) {
+ videoSrc = video.webm.max;
+ videoType = "video/webm";
+ }
+
+ if (videoSrc) {
+ items.push({
+ id: `video-${video.id}`,
+ type: "video",
+ poster: video.thumbnail,
+ videoSrc: videoSrc.startsWith("http://")
+ ? videoSrc.replace("http://", "https://")
+ : videoSrc,
+ videoType,
+ });
+ }
+ });
+
+ screenshots.forEach((image) => {
+ items.push({
+ id: `screenshot-${image.id}`,
+ type: "image",
+ src: image.path_full,
+ });
+ });
+
+ return items;
+ }, [videos, screenshots]);
+
+ const isVisible = (index: number) => Math.abs(index - selectedIndex) <= 1;
+
+ const renderSlideContent = (item: MediaItem, idx: number) => {
+ if (!isVisible(idx)) {
+ return (
+
+ );
+ }
+
+ if (item.type === "video") {
+ return (
+ {
+ videoRefs.current[idx] = el;
+ }}
+ />
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ const prevButtonNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ up: {
+ type: "region",
+ regionId: GAME_HERO_ACTIONS_REGION_ID,
+ },
+ down: {
+ type: "region",
+ regionId: GAME_REVIEWS_REGION_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ },
+ };
+
+ const nextButtonNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
+ },
+ right: {
+ type: "item",
+ itemId: GAME_STATS_TITLE_ID,
+ },
+ up: {
+ type: "region",
+ regionId: GAME_HERO_ACTIONS_REGION_ID,
+ },
+ down: {
+ type: "region",
+ regionId: GAME_REVIEWS_REGION_ID,
+ },
+ };
+
+ return (
+
+
+
+ {mediaItems.map((item, idx) => (
+
+ {renderSlideContent(item, idx)}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {mediaItems.map((item, i) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/screenshot-carousel/video-player.tsx b/src/big-picture/src/components/pages/game/screenshot-carousel/video-player.tsx
new file mode 100644
index 000000000..021cc43a8
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/screenshot-carousel/video-player.tsx
@@ -0,0 +1,76 @@
+import { useHlsVideo } from "@shared";
+import { useRef } from "react";
+
+interface VideoPlayerProps {
+ videoSrc?: string;
+ videoType?: string;
+ poster?: string;
+ autoplay?: boolean;
+ muted?: boolean;
+ loop?: boolean;
+ controls?: boolean;
+ style?: React.CSSProperties;
+ videoRef?: (el: HTMLVideoElement | null) => void;
+}
+
+export function VideoPlayer({
+ videoSrc,
+ videoType,
+ poster,
+ autoplay = false,
+ muted = true,
+ loop = false,
+ controls = true,
+ style,
+ videoRef,
+}: Readonly) {
+ const internalRef = useRef(null);
+ const isHls = videoType === "application/x-mpegURL";
+
+ useHlsVideo(internalRef, {
+ videoSrc,
+ videoType,
+ autoplay,
+ muted,
+ loop,
+ });
+
+ const setRef = (el: HTMLVideoElement | null) => {
+ (internalRef as React.MutableRefObject).current =
+ el;
+ videoRef?.(el);
+ };
+
+ if (isHls) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/game/supported-languages/index.tsx b/src/big-picture/src/components/pages/game/supported-languages/index.tsx
new file mode 100644
index 000000000..e0e7ddcf5
--- /dev/null
+++ b/src/big-picture/src/components/pages/game/supported-languages/index.tsx
@@ -0,0 +1,144 @@
+import { CheckIcon, XIcon } from "@phosphor-icons/react";
+import { ShopDetails } from "@types";
+import { useMemo } from "react";
+import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
+import { Box, FocusItem, Typography } from "../../../common";
+import {
+ GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
+ GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
+ GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ GAME_SUPPORTED_LANGUAGES_TITLE_ID,
+} from "../navigation";
+
+export interface SupportedLanguagesProps {
+ shopDetails: ShopDetails;
+}
+
+export function SupportedLanguages({
+ shopDetails,
+}: Readonly) {
+ const languages = useMemo(() => {
+ const supportedLanguages = shopDetails.supported_languages;
+ if (!supportedLanguages) return [];
+
+ const languagesString = supportedLanguages.split("
")[0];
+ const languageArray = languagesString?.split(",") || [];
+
+ return languageArray.map((lang) => ({
+ language: lang.replace("*", "").trim(),
+ hasAudio: lang.includes("*"),
+ }));
+ }, [shopDetails.supported_languages]);
+
+ if (languages.length === 0) {
+ return null;
+ }
+
+ const supportedLanguagesNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
+ },
+ down: {
+ type: "item",
+ itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ };
+
+ const lastRowNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_SUPPORTED_LANGUAGES_TITLE_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ down: {
+ type: "item",
+ itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
+ },
+ };
+
+ const renderRow = (
+ lang: { language: string; hasAudio: boolean },
+ index: number
+ ) => {
+ const row = (
+
+
+ {lang.language}
+
+
+
+
+
+ {lang.hasAudio ? (
+
+ ) : (
+
+ )}
+
+
+ );
+
+ if (index === languages.length - 1) {
+ return (
+
+ {row}
+
+ );
+ }
+
+ return row;
+ };
+
+ return (
+
+
+
+
Languages
+
+
+
+ Caption
+
+
+ Audio
+
+
+
+
+
+
+
+ {languages.map((lang, index) => renderRow(lang, index))}
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/index.ts b/src/big-picture/src/components/pages/index.ts
new file mode 100644
index 000000000..cf98f43d2
--- /dev/null
+++ b/src/big-picture/src/components/pages/index.ts
@@ -0,0 +1 @@
+export * from "./library";
diff --git a/src/big-picture/src/components/pages/library/filters/filters.scss b/src/big-picture/src/components/pages/library/filters/filters.scss
new file mode 100644
index 000000000..5026a068c
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/filters/filters.scss
@@ -0,0 +1,29 @@
+.library-filters {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ padding: 0 calc(var(--spacing-unit) * 16);
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 4);
+ }
+
+ &__search {
+ flex: 1;
+ max-width: 600px;
+ }
+
+ &__view-actions {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+
+ &__tabs {
+ flex-shrink: 0;
+ padding: calc(var(--spacing-unit) * 6) 0 calc(var(--spacing-unit) * 4) 0;
+ }
+}
diff --git a/src/big-picture/src/components/pages/library/filters/filters.tsx b/src/big-picture/src/components/pages/library/filters/filters.tsx
new file mode 100644
index 000000000..d1e11964a
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/filters/filters.tsx
@@ -0,0 +1,226 @@
+import "./filters.scss";
+import { Button, HorizontalFocusGroup, Input, Tabs } from "../../../common";
+import {
+ ListDashesIcon,
+ MagnifyingGlassIcon,
+ SquaresFourIcon,
+} from "@phosphor-icons/react";
+import type { FocusOverrides } from "../../../../services";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
+import {
+ LIBRARY_FILTERS_ALL_TAB_ID,
+ LIBRARY_FILTERS_COMPLETED_TAB_ID,
+ LIBRARY_FILTERS_FAVORITES_TAB_ID,
+ LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID,
+ LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
+ LIBRARY_FILTERS_SEARCH_INPUT_ID,
+ LIBRARY_FILTERS_TABS_REGION_ID,
+ LIBRARY_FILTERS_TOOLBAR_REGION_ID,
+ LIBRARY_HERO_ACTIONS_REGION_ID,
+} from "../navigation";
+import type { LibraryFilterCounts, LibraryFilterTab } from "../library-data";
+import type { TabsItem } from "../../../common";
+
+export interface LibraryFiltersProps {
+ selectedTab: LibraryFilterTab;
+ onSelectedTabChange: (tab: LibraryFilterTab) => void;
+ search: string;
+ onSearchChange: (search: string) => void;
+ counts: LibraryFilterCounts;
+ firstGridItemId?: string | null;
+}
+
+export function LibraryFilters({
+ selectedTab,
+ onSelectedTabChange,
+ search,
+ onSearchChange,
+ counts,
+ firstGridItemId = null,
+}: Readonly) {
+ const tabDownOverride = firstGridItemId
+ ? ({
+ type: "item",
+ itemId: firstGridItemId,
+ } as const)
+ : ({
+ type: "block",
+ } as const);
+ const tabUpOverride = {
+ type: "region",
+ regionId: LIBRARY_FILTERS_TOOLBAR_REGION_ID,
+ entryDirection: "up",
+ } as const;
+ const sidebarLibraryOverride = {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.library,
+ } as const;
+ const tabs = [
+ {
+ id: LIBRARY_FILTERS_ALL_TAB_ID,
+ value: "all",
+ label: `All (${counts.all})`,
+ navigationOverrides: {
+ left: sidebarLibraryOverride,
+ right: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_FAVORITES_TAB_ID,
+ },
+ up: tabUpOverride,
+ down: tabDownOverride,
+ },
+ },
+ {
+ id: LIBRARY_FILTERS_FAVORITES_TAB_ID,
+ value: "favorites",
+ label: `Favorites (${counts.favorites})`,
+ navigationOverrides: {
+ left: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_ALL_TAB_ID,
+ },
+ right: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_COMPLETED_TAB_ID,
+ },
+ up: tabUpOverride,
+ down: tabDownOverride,
+ },
+ },
+ {
+ id: LIBRARY_FILTERS_COMPLETED_TAB_ID,
+ value: "completed",
+ label: `Completed (${counts.completed})`,
+ navigationOverrides: {
+ left: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_FAVORITES_TAB_ID,
+ },
+ right: { type: "block" },
+ up: tabUpOverride,
+ down: tabDownOverride,
+ },
+ },
+ ] satisfies Array>;
+ const toolbarNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "region",
+ regionId: LIBRARY_HERO_ACTIONS_REGION_ID,
+ entryDirection: "up",
+ },
+ down: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_ALL_TAB_ID,
+ },
+ };
+ const toolbarUpOverride = {
+ type: "region",
+ regionId: LIBRARY_HERO_ACTIONS_REGION_ID,
+ entryDirection: "up",
+ } as const;
+ const toolbarDownOverride = {
+ type: "item",
+ itemId: LIBRARY_FILTERS_ALL_TAB_ID,
+ } as const;
+ const searchNavigationOverrides: FocusOverrides = {
+ left: sidebarLibraryOverride,
+ right: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
+ },
+ up: toolbarUpOverride,
+ down: toolbarDownOverride,
+ };
+ const listViewNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID,
+ },
+ right: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID,
+ },
+ up: toolbarUpOverride,
+ down: toolbarDownOverride,
+ };
+ const gridViewNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ up: toolbarUpOverride,
+ down: toolbarDownOverride,
+ };
+ const tabsNavigationOverrides: FocusOverrides = {
+ up: tabUpOverride,
+ down: tabDownOverride,
+ };
+
+ return (
+
+
+
Your Library
+
+
+
+
+ }
+ value={search}
+ onChange={(e) => onSearchChange(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/game-card.tsx b/src/big-picture/src/components/pages/library/game-card.tsx
new file mode 100644
index 000000000..d309da1fe
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/game-card.tsx
@@ -0,0 +1,84 @@
+import type { LibraryGame } from "@types";
+import { DotsThreeVerticalIcon } from "@phosphor-icons/react";
+import { FocusItem, VerticalGameCard } from "../../common";
+import {
+ formatPlayedTime,
+ getBigPictureGameDetailsPath,
+ getGameAchievementProgress,
+ getGameImageSources,
+} from "../../../helpers";
+import type { FocusOverrides } from "../../../services";
+import { useDominantColor } from "../../../hooks";
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { getLibraryFocusGridItemId } from "./navigation";
+
+export interface VerticalLibraryGameCardProps {
+ game: LibraryGame;
+ navigationOverrides?: FocusOverrides;
+}
+
+export function VerticalLibraryGameCard({
+ game,
+ navigationOverrides,
+}: Readonly) {
+ const navigate = useNavigate();
+ const imageSources = useMemo(() => getGameImageSources(game), [game]);
+ const [imageSourceIndex, setImageSourceIndex] = useState(0);
+ const [imageExhausted, setImageExhausted] = useState(false);
+
+ useEffect(() => {
+ setImageSourceIndex(0);
+ setImageExhausted(false);
+ }, [game.id, imageSources]);
+
+ const activeImageSource = imageExhausted
+ ? null
+ : (imageSources[imageSourceIndex] ?? null);
+
+ const dominantColor = useDominantColor(activeImageSource);
+ const achievementProgress = getGameAchievementProgress(game);
+ const gameDetailsPath = getBigPictureGameDetailsPath(game);
+
+ const handleCoverImageError = () => {
+ if (imageSourceIndex < imageSources.length - 1) {
+ setImageSourceIndex((currentIndex) => currentIndex + 1);
+ return;
+ }
+
+ setImageExhausted(true);
+ };
+
+ return (
+ navigate(gameDetailsPath),
+ secondary: "off",
+ }}
+ navigationOverrides={navigationOverrides}
+ >
+ navigate(gameDetailsPath)}
+ action={
+
+
+
+ }
+ onCoverImageError={handleCoverImageError}
+ />
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/grid-navigation.ts b/src/big-picture/src/components/pages/library/grid-navigation.ts
new file mode 100644
index 000000000..ec29f1ca0
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/grid-navigation.ts
@@ -0,0 +1,211 @@
+import type { LibraryGame } from "@types";
+import type { FocusOverrides } from "../../../services";
+import { useEffect, useMemo, useState } from "react";
+import {
+ getLibraryFocusGridItemId,
+ LIBRARY_FILTERS_TABS_REGION_ID,
+ LIBRARY_FOCUS_GRID_REGION_ID,
+} from "./navigation";
+
+interface GridItemPosition {
+ id: string;
+ top: number;
+ left: number;
+ centerX: number;
+}
+
+const ROW_TOLERANCE_PX = 24;
+
+function groupItemsIntoRows(items: GridItemPosition[]) {
+ const sortedItems = [...items].sort((a, b) => {
+ if (Math.abs(a.top - b.top) > ROW_TOLERANCE_PX) {
+ return a.top - b.top;
+ }
+
+ return a.left - b.left;
+ });
+
+ return sortedItems.reduce((rows, item) => {
+ const lastRow = rows.at(-1);
+
+ if (!lastRow || Math.abs(lastRow[0].top - item.top) > ROW_TOLERANCE_PX) {
+ rows.push([item]);
+ return rows;
+ }
+
+ lastRow.push(item);
+ lastRow.sort((leftItem, rightItem) => leftItem.left - rightItem.left);
+ return rows;
+ }, []);
+}
+
+function getClosestItemByCenterX(
+ items: GridItemPosition[] | undefined,
+ centerX: number
+) {
+ if (!items?.length) return null;
+
+ return [...items].sort((leftItem, rightItem) => {
+ return (
+ Math.abs(leftItem.centerX - centerX) -
+ Math.abs(rightItem.centerX - centerX)
+ );
+ })[0];
+}
+
+function buildFocusOverridesForGridItem(
+ item: GridItemPosition,
+ row: GridItemPosition[],
+ itemIndex: number,
+ rowIndex: number,
+ rows: GridItemPosition[][]
+): FocusOverrides {
+ const leftItem = row[itemIndex - 1];
+ const rightItem = row[itemIndex + 1];
+ const upItem = getClosestItemByCenterX(rows[rowIndex - 1], item.centerX);
+ const downItem = getClosestItemByCenterX(rows[rowIndex + 1], item.centerX);
+
+ return {
+ ...(leftItem
+ ? {
+ left: {
+ type: "item",
+ itemId: leftItem.id,
+ },
+ }
+ : {}),
+ ...(rightItem
+ ? {
+ right: {
+ type: "item",
+ itemId: rightItem.id,
+ },
+ }
+ : {
+ right: {
+ type: "block",
+ },
+ }),
+ ...(upItem
+ ? {
+ up: {
+ type: "item",
+ itemId: upItem.id,
+ },
+ }
+ : {
+ up: {
+ type: "region",
+ regionId: LIBRARY_FILTERS_TABS_REGION_ID,
+ entryDirection: "down",
+ },
+ }),
+ ...(downItem
+ ? {
+ down: {
+ type: "item",
+ itemId: downItem.id,
+ },
+ }
+ : {
+ down: {
+ type: "block",
+ },
+ }),
+ };
+}
+
+function buildOverridesMapFromRows(rows: GridItemPosition[][]) {
+ const nextOverridesByItemId: Record = {};
+
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
+ const row = rows[rowIndex];
+
+ for (let itemIndex = 0; itemIndex < row.length; itemIndex++) {
+ const item = row[itemIndex];
+ nextOverridesByItemId[item.id] = buildFocusOverridesForGridItem(
+ item,
+ row,
+ itemIndex,
+ rowIndex,
+ rows
+ );
+ }
+ }
+
+ return nextOverridesByItemId;
+}
+
+export function useLibraryGridNavigation(games: LibraryGame[]) {
+ const [overridesByItemId, setOverridesByItemId] = useState<
+ Record
+ >({});
+
+ const itemIds = useMemo(() => {
+ return games.map((game) => getLibraryFocusGridItemId(game.id));
+ }, [games]);
+
+ useEffect(() => {
+ if (itemIds.length === 0) {
+ setOverridesByItemId({});
+ return;
+ }
+
+ let animationFrameId = 0;
+ let resizeObserver: ResizeObserver | null = null;
+
+ const computeOverrides = () => {
+ const items = itemIds
+ .map((id) => {
+ const element = globalThis.document.getElementById(id);
+ const rect = element?.getBoundingClientRect();
+
+ if (!element || !rect) return null;
+
+ return {
+ id,
+ top: rect.top,
+ left: rect.left,
+ centerX: rect.left + rect.width / 2,
+ };
+ })
+ .filter((item): item is GridItemPosition => item !== null);
+
+ const rows = groupItemsIntoRows(items);
+ setOverridesByItemId(buildOverridesMapFromRows(rows));
+ };
+
+ const scheduleCompute = () => {
+ globalThis.cancelAnimationFrame(animationFrameId);
+ animationFrameId = globalThis.requestAnimationFrame(computeOverrides);
+ };
+
+ scheduleCompute();
+ globalThis.addEventListener("resize", scheduleCompute);
+
+ const gridElement = globalThis.document.querySelector(
+ `[data-focus-region-id="${LIBRARY_FOCUS_GRID_REGION_ID}"]`
+ );
+
+ if (gridElement instanceof HTMLElement) {
+ resizeObserver = new ResizeObserver(scheduleCompute);
+ resizeObserver.observe(gridElement);
+
+ itemIds.forEach((id) => {
+ const itemElement = globalThis.document.getElementById(id);
+
+ if (!itemElement) return;
+
+ resizeObserver?.observe(itemElement);
+ });
+ }
+
+ return () => {
+ globalThis.cancelAnimationFrame(animationFrameId);
+ globalThis.removeEventListener("resize", scheduleCompute);
+ resizeObserver?.disconnect();
+ };
+ }, [itemIds]);
+
+ return overridesByItemId;
+}
diff --git a/src/big-picture/src/components/pages/library/grid/focus-grid.scss b/src/big-picture/src/components/pages/library/grid/focus-grid.scss
new file mode 100644
index 000000000..edd3907bb
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/grid/focus-grid.scss
@@ -0,0 +1,37 @@
+.library-focus-grid {
+ width: 100%;
+ padding: 0 calc(var(--spacing-unit) * 16);
+}
+
+.library-focus-grid__grid {
+ display: grid;
+ grid-template-columns: repeat(
+ var(--library-focus-grid-column-count, 1),
+ var(--library-focus-grid-card-width, 350px)
+ );
+ column-gap: var(
+ --library-focus-grid-column-gap,
+ calc(var(--spacing-unit) * 8)
+ );
+ row-gap: calc(var(--spacing-unit) * 16);
+ justify-content: start;
+}
+
+.vertical-library-game-card__action-button {
+ pointer-events: none;
+}
+
+@media (max-width: 720px) {
+ .library-focus-grid {
+ padding: 0 calc(var(--spacing-unit) * 6);
+ }
+
+ .library-focus-grid__grid {
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(var(--library-focus-grid-card-width, 350px), 1fr)
+ );
+ column-gap: calc(var(--spacing-unit) * 4);
+ justify-content: initial;
+ }
+}
diff --git a/src/big-picture/src/components/pages/library/grid/focus-grid.tsx b/src/big-picture/src/components/pages/library/grid/focus-grid.tsx
new file mode 100644
index 000000000..4c40e322f
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/grid/focus-grid.tsx
@@ -0,0 +1,42 @@
+import type { LibraryGame } from "@types";
+import { GridFocusGroup } from "../../../common";
+import {
+ getLibraryFocusGridItemId,
+ LIBRARY_FOCUS_GRID_REGION_ID,
+} from "../navigation";
+import { useLibraryGridNavigation } from "../grid-navigation";
+import { useLibraryGridLayout } from "./use-library-grid-layout";
+import { VerticalLibraryGameCard } from "../game-card";
+
+import "./focus-grid.scss";
+
+export interface LibraryFocusGridProps {
+ games: LibraryGame[];
+}
+
+export function LibraryFocusGrid({ games }: Readonly) {
+ const navigationOverridesByItemId = useLibraryGridNavigation(games);
+ const style = useLibraryGridLayout(games.length);
+
+ if (games.length === 0) return null;
+
+ return (
+
+
+ {games.map((game) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/grid/use-library-grid-layout.ts b/src/big-picture/src/components/pages/library/grid/use-library-grid-layout.ts
new file mode 100644
index 000000000..a7b2c99a8
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/grid/use-library-grid-layout.ts
@@ -0,0 +1,82 @@
+import { type CSSProperties, useEffect, useMemo, useState } from "react";
+import { LIBRARY_FOCUS_GRID_REGION_ID } from "../navigation";
+
+const GRID_CARD_WIDTH = 350;
+const GRID_MIN_COLUMN_GAP = 32;
+const GRID_MOBILE_BREAKPOINT = 720;
+
+interface LibraryGridLayout {
+ columnCount: number;
+ columnGap: number;
+}
+
+function getLayoutForWidth(width: number): LibraryGridLayout {
+ if (width <= 0) {
+ return {
+ columnCount: 1,
+ columnGap: GRID_MIN_COLUMN_GAP,
+ };
+ }
+
+ const columnCount = Math.max(
+ 1,
+ Math.floor(
+ (width + GRID_MIN_COLUMN_GAP) / (GRID_CARD_WIDTH + GRID_MIN_COLUMN_GAP)
+ )
+ );
+
+ return {
+ columnCount,
+ columnGap:
+ columnCount > 1
+ ? Math.max(
+ GRID_MIN_COLUMN_GAP,
+ (width - columnCount * GRID_CARD_WIDTH) / (columnCount - 1)
+ )
+ : GRID_MIN_COLUMN_GAP,
+ };
+}
+
+export function useLibraryGridLayout(itemCount: number) {
+ const [layout, setLayout] = useState(() => getLayoutForWidth(0));
+
+ useEffect(() => {
+ if (itemCount === 0) return;
+
+ const gridElement = globalThis.document.querySelector(
+ `[data-focus-region-id="${LIBRARY_FOCUS_GRID_REGION_ID}"]`
+ );
+
+ if (!(gridElement instanceof HTMLElement)) return;
+
+ const updateLayout = () => {
+ if (globalThis.innerWidth <= GRID_MOBILE_BREAKPOINT) {
+ setLayout(getLayoutForWidth(0));
+ return;
+ }
+
+ setLayout(getLayoutForWidth(gridElement.getBoundingClientRect().width));
+ };
+
+ updateLayout();
+
+ const resizeObserver = new ResizeObserver(updateLayout);
+ resizeObserver.observe(gridElement);
+ globalThis.addEventListener("resize", updateLayout);
+
+ return () => {
+ resizeObserver.disconnect();
+ globalThis.removeEventListener("resize", updateLayout);
+ };
+ }, [itemCount]);
+
+ return useMemo(
+ () =>
+ ({
+ "--library-focus-grid-card-width": `${GRID_CARD_WIDTH}px`,
+ "--library-focus-grid-column-count": `${layout.columnCount}`,
+ "--library-focus-grid-column-gap": `${layout.columnGap}px`,
+ }) as CSSProperties,
+ [layout]
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/hero/hero.scss b/src/big-picture/src/components/pages/library/hero/hero.scss
new file mode 100644
index 000000000..0f28a9fe6
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/hero/hero.scss
@@ -0,0 +1,246 @@
+.hero {
+ position: relative;
+ width: 100%;
+ height: 600px;
+ overflow: hidden;
+ border: none;
+ background: var(--background);
+ box-shadow: 0px 0px 0px 0px transparent;
+}
+
+.hero__bg-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ transition: opacity 0.3s cubic-bezier(0.33, 1, 0.68, 1);
+ pointer-events: none;
+}
+
+.hero__bg-layer--base {
+ opacity: 1;
+}
+
+.hero__bg-layer--incoming {
+ z-index: 1;
+ opacity: 0;
+}
+
+.hero__bg-layer--visible {
+ opacity: 1;
+}
+
+.hero__bg {
+ width: 100%;
+ height: 100%;
+}
+
+.hero__overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ background:
+ linear-gradient(
+ 90deg,
+ var(--background) 0%,
+ transparent 25% 75%,
+ var(--background) 99.53%
+ ),
+ linear-gradient(transparent 0%, var(--background) 97%);
+}
+
+.hero__content {
+ position: absolute;
+ inset: auto 0 0;
+ z-index: 3;
+ display: flex;
+ align-items: flex-end;
+ flex-direction: row;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 12);
+ padding: 0 calc(var(--spacing-unit) * 16) calc(var(--spacing-unit) * 16);
+}
+
+.hero__content__left {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 6);
+ max-width: 337px;
+ min-width: 0;
+}
+
+.hero__logo {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ width: 337px;
+ height: 210px;
+}
+
+.hero__logo__image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ object-position: left center;
+}
+
+.hero__logo__fallback {
+ max-width: 100%;
+ font-size: 48px;
+ font-weight: 700;
+ line-height: 1;
+ color: var(--primary);
+}
+
+.hero__copy {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ color: var(--primary);
+ gap: calc(var(--spacing-unit) * 1);
+}
+
+.hero__eyebrow {
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1;
+ color: var(--text);
+}
+
+.hero__description {
+ margin-top: var(--spacing-unit);
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--text-secondary);
+}
+
+.hero__actions {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 4);
+}
+
+.hero__action__divider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 1px;
+ width: 1px;
+ height: 28px;
+ pointer-events: none;
+}
+
+.hero__action__divider .divider-container--vertical {
+ width: 1px;
+ height: 28px;
+ flex-basis: 1px;
+}
+
+.hero__action__divider .divider--vertical {
+ width: 1px;
+ height: 28px;
+ min-height: 28px;
+ background-color: var(--text-secondary);
+ opacity: 0.7;
+}
+
+.hero__stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--spacing-unit);
+ width: min(100%, 336px);
+ flex-shrink: 0;
+}
+
+.hero__stat {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: var(--spacing-unit);
+}
+
+.hero__stat__value {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-unit);
+ height: 91px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.hero__stat__label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 37px;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
+ background: var(--surface);
+ color: var(--text-secondary);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 1;
+}
+
+.hero__stat--achievements .hero__stat__value {
+ border-radius: calc(var(--spacing-unit) * 2) 0 0 0;
+}
+
+.hero__stat--achievements .hero__stat__label {
+ border-radius: 0 0 0 calc(var(--spacing-unit) * 2);
+}
+
+.hero__stat--playtime .hero__stat__value {
+ border-radius: 0 calc(var(--spacing-unit) * 2) 0 0;
+}
+
+.hero__stat--playtime .hero__stat__label {
+ border-radius: 0 0 calc(var(--spacing-unit) * 2) 0;
+}
+
+@media (max-width: 1100px) {
+ .hero {
+ height: 820px;
+ }
+
+ .hero__content {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(var(--spacing-unit) * 8);
+ }
+
+ .hero__stats {
+ width: 100%;
+ max-width: 336px;
+ }
+}
+
+@media (max-width: 720px) {
+ .hero {
+ height: 900px;
+ }
+
+ .hero__content {
+ padding: 0 calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 6);
+ }
+
+ .hero__content__left {
+ max-width: 100%;
+ width: 100%;
+ }
+
+ .hero__logo {
+ width: min(100%, 337px);
+ height: auto;
+ aspect-ratio: 337 / 210;
+ }
+
+ .hero__actions {
+ flex-wrap: wrap;
+ }
+}
diff --git a/src/big-picture/src/components/pages/library/hero/hero.tsx b/src/big-picture/src/components/pages/library/hero/hero.tsx
new file mode 100644
index 000000000..a788e732c
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/hero/hero.tsx
@@ -0,0 +1,278 @@
+import type { LibraryGame } from "@types";
+import {
+ ClockIcon,
+ DownloadSimpleIcon,
+ GearIcon,
+ HeartIcon,
+ PlayIcon,
+ TrophyIcon,
+} from "@phosphor-icons/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import cn from "classnames";
+import {
+ Button,
+ AnimatedHeroImage,
+ Divider,
+ HorizontalFocusGroup,
+} from "../../../common";
+import { formatRelativeDate } from "../../../../helpers";
+import { useDominantColor } from "../../../../hooks";
+import { type FocusOverrides } from "../../../../services";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
+import {
+ LIBRARY_FILTERS_SEARCH_INPUT_ID,
+ LIBRARY_HERO_ACTIONS_REGION_ID,
+ LIBRARY_HERO_FAVORITE_BUTTON_ID,
+ LIBRARY_HERO_LAUNCH_BUTTON_ID,
+ LIBRARY_HERO_OPTIONS_BUTTON_ID,
+} from "../navigation";
+import { getHeroPlaytimeLabel } from "../library-data";
+import { useLibraryLaunchGame } from "../use-library-launch-game";
+import { useHeroBackgroundLayers } from "./use-hero-background-layers";
+
+import "./hero.scss";
+
+interface LibraryHeroProps {
+ lastPlayedGames: LibraryGame[];
+ onOpenGameSettings?: (game: LibraryGame) => void;
+ onToggleFavorite?: (game: LibraryGame) => Promise | void;
+ favoriteLoadingGameId?: string | null;
+}
+
+const FEATURED_GAME_INTERVAL = 60000;
+
+function getLastPlayedLabel(lastTimePlayed: Date | string | null | undefined) {
+ const relativeDate = formatRelativeDate(lastTimePlayed, {
+ fallback: "recently",
+ });
+
+ return `Last played ${relativeDate}`;
+}
+
+export function LibraryHero({
+ lastPlayedGames,
+ onOpenGameSettings,
+ onToggleFavorite,
+ favoriteLoadingGameId = null,
+}: Readonly) {
+ const [featuredGameIndex, setFeaturedGameIndex] = useState(0);
+ const heroRef = useRef(null);
+ const featuredGame = lastPlayedGames[featuredGameIndex] ?? null;
+ const launchGame = useLibraryLaunchGame(
+ useCallback(() => {
+ console.log("library-hero download");
+ }, [])
+ );
+ const getHeroScrollAnchor = useCallback(() => heroRef.current, []);
+ const dominantColor = useDominantColor(
+ featuredGame?.libraryHeroImageUrl ?? null
+ );
+ const { backgroundLayers, getLayerEventHandlers } = useHeroBackgroundLayers(
+ featuredGame?.libraryHeroImageUrl
+ );
+
+ useEffect(() => {
+ if (lastPlayedGames.length <= 1) {
+ setFeaturedGameIndex(0);
+ return;
+ }
+
+ const intervalId = globalThis.setInterval(() => {
+ setFeaturedGameIndex((currentIndex) => {
+ return (currentIndex + 1) % lastPlayedGames.length;
+ });
+ }, FEATURED_GAME_INTERVAL);
+
+ return () => {
+ globalThis.clearInterval(intervalId);
+ };
+ }, [lastPlayedGames]);
+
+ const achievementCount =
+ featuredGame?.unlockedAchievementCount ??
+ featuredGame?.achievementCount ??
+ 0;
+ const playtime = getHeroPlaytimeLabel(featuredGame?.playTimeInMilliseconds);
+ const lastPlayedLabel = getLastPlayedLabel(featuredGame?.lastTimePlayed);
+ const isFavoriteLoading =
+ Boolean(featuredGame) && favoriteLoadingGameId === featuredGame?.id;
+ const hasExecutable = Boolean(featuredGame?.executablePath);
+
+ const handlePlayOrDownloadClick = () => {
+ if (!featuredGame) return;
+ void launchGame(featuredGame);
+ };
+
+ const heroActionsNavigationOverrides: FocusOverrides = {
+ down: {
+ type: "item",
+ itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID,
+ },
+ };
+ const sidebarLibraryOverride = {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.library,
+ } as const;
+ const launchNavigationOverrides: FocusOverrides = {
+ left: sidebarLibraryOverride,
+ right: { type: "item", itemId: LIBRARY_HERO_OPTIONS_BUTTON_ID },
+ down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
+ };
+ const optionsNavigationOverrides: FocusOverrides = {
+ left: { type: "item", itemId: LIBRARY_HERO_LAUNCH_BUTTON_ID },
+ right: { type: "item", itemId: LIBRARY_HERO_FAVORITE_BUTTON_ID },
+ down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
+ };
+ const favoriteNavigationOverrides: FocusOverrides = {
+ left: { type: "item", itemId: LIBRARY_HERO_OPTIONS_BUTTON_ID },
+ right: { type: "block" },
+ down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
+ };
+
+ return (
+
+ {backgroundLayers.map((layer) => {
+ const layerHandlers = getLayerEventHandlers(layer);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {featuredGame?.logoImageUrl ? (
+

+ ) : (
+
+ {featuredGame?.title ?? ""}
+
+ )}
+
+
+
+
Continue playing:
+
{lastPlayedLabel}
+
+
+
+
+ {hasExecutable ? (
+ }
+ color={dominantColor ?? undefined}
+ focusId={LIBRARY_HERO_LAUNCH_BUTTON_ID}
+ focusNavigationOverrides={launchNavigationOverrides}
+ disabled={!featuredGame}
+ onClick={handlePlayOrDownloadClick}
+ >
+ Launch Game
+
+ ) : (
+ }
+ color={dominantColor ?? undefined}
+ focusId={LIBRARY_HERO_LAUNCH_BUTTON_ID}
+ focusNavigationOverrides={launchNavigationOverrides}
+ disabled={!featuredGame}
+ onClick={handlePlayOrDownloadClick}
+ >
+ Launch Game
+
+ )}
+
+
+
+ }
+ focusId={LIBRARY_HERO_OPTIONS_BUTTON_ID}
+ focusNavigationOverrides={optionsNavigationOverrides}
+ onClick={() => {
+ if (featuredGame) {
+ onOpenGameSettings?.(featuredGame);
+ }
+ }}
+ >
+ Options
+
+
+
+
+
+
+
+
+
+
+
+ {achievementCount}
+
+
+
Achievements
+
+
+
+
+
+ {playtime}
+
+
Hours Played
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/hero/use-hero-background-layers.ts b/src/big-picture/src/components/pages/library/hero/use-hero-background-layers.ts
new file mode 100644
index 000000000..5af8c56c9
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/hero/use-hero-background-layers.ts
@@ -0,0 +1,118 @@
+import { useCallback, useEffect, useState } from "react";
+
+export interface HeroBackgroundLayer {
+ key: number;
+ imageUrl: string;
+ role: "base" | "incoming";
+ isVisible: boolean;
+}
+
+function getNextLayerKey() {
+ return Date.now();
+}
+
+export function useHeroBackgroundLayers(imageUrl: string | null | undefined) {
+ const [backgroundLayers, setBackgroundLayers] = useState<
+ HeroBackgroundLayer[]
+ >([]);
+
+ useEffect(() => {
+ const nextImageUrl = imageUrl ?? "";
+
+ if (!nextImageUrl) {
+ setBackgroundLayers([]);
+ return;
+ }
+
+ setBackgroundLayers((currentLayers) => {
+ const baseLayer =
+ currentLayers.find((layer) => layer.role === "base") ?? null;
+ const incomingLayer =
+ currentLayers.find((layer) => layer.role === "incoming") ?? null;
+
+ if (
+ baseLayer?.imageUrl === nextImageUrl ||
+ incomingLayer?.imageUrl === nextImageUrl
+ ) {
+ return currentLayers;
+ }
+
+ const nextLayer = {
+ key: getNextLayerKey(),
+ imageUrl: nextImageUrl,
+ role: "base" as const,
+ isVisible: true,
+ };
+
+ if (!baseLayer) return [nextLayer];
+
+ return [
+ baseLayer,
+ {
+ ...nextLayer,
+ key: nextLayer.key + 1,
+ role: "incoming" as const,
+ isVisible: false,
+ },
+ ];
+ });
+ }, [imageUrl]);
+
+ const showIncomingLayer = useCallback((layerKey: number) => {
+ setBackgroundLayers((currentLayers) => {
+ return currentLayers.map((layer) => {
+ if (layer.key !== layerKey || layer.role !== "incoming") return layer;
+
+ return {
+ ...layer,
+ isVisible: true,
+ };
+ });
+ });
+ }, []);
+
+ const removeIncomingLayer = useCallback((layerKey: number) => {
+ setBackgroundLayers((currentLayers) => {
+ return currentLayers.filter((layer) => layer.key !== layerKey);
+ });
+ }, []);
+
+ const promoteIncomingLayer = useCallback((layerKey: number) => {
+ setBackgroundLayers((currentLayers) => {
+ const incomingLayer = currentLayers.find(
+ (layer) => layer.key === layerKey && layer.role === "incoming"
+ );
+
+ if (!incomingLayer?.isVisible) return currentLayers;
+
+ return currentLayers
+ .filter((layer) => layer.key === layerKey)
+ .map((layer) => ({
+ ...layer,
+ role: "base" as const,
+ isVisible: true,
+ }));
+ });
+ }, []);
+
+ const getLayerEventHandlers = useCallback(
+ (layer: HeroBackgroundLayer) => {
+ const runForIncomingLayer = (run: (layerKey: number) => void) => () => {
+ if (layer.role !== "incoming") return;
+ run(layer.key);
+ };
+
+ return {
+ onLoad: runForIncomingLayer(showIncomingLayer),
+ onError: runForIncomingLayer(removeIncomingLayer),
+ onTransitionEnd: runForIncomingLayer(promoteIncomingLayer),
+ };
+ },
+ [promoteIncomingLayer, removeIncomingLayer, showIncomingLayer]
+ );
+
+ return {
+ backgroundLayers,
+ getLayerEventHandlers,
+ };
+}
diff --git a/src/big-picture/src/components/pages/library/index.ts b/src/big-picture/src/components/pages/library/index.ts
new file mode 100644
index 000000000..fb661b831
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/index.ts
@@ -0,0 +1,9 @@
+export * from "./hero/hero";
+export * from "./grid/focus-grid";
+export * from "./game-card";
+export * from "./settings-modal";
+export * from "./filters/filters";
+export * from "./library-data";
+export * from "./use-library-favorite";
+export * from "./use-library-launch-game";
+export * from "./use-library-page-data";
diff --git a/src/big-picture/src/components/pages/library/library-data.ts b/src/big-picture/src/components/pages/library/library-data.ts
new file mode 100644
index 000000000..30209d493
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/library-data.ts
@@ -0,0 +1,99 @@
+import type { LibraryGame } from "@types";
+
+export type LibraryFilterTab = "all" | "favorites" | "completed";
+
+export interface LibraryFilterCounts {
+ all: number;
+ favorites: number;
+ completed: number;
+}
+
+export const LAST_PLAYED_GAMES_COUNT = 3;
+
+export function sortByLastPlayed(a: LibraryGame, b: LibraryGame) {
+ const aLastPlayed = a.lastTimePlayed
+ ? new Date(a.lastTimePlayed as Date).getTime()
+ : null;
+ const bLastPlayed = b.lastTimePlayed
+ ? new Date(b.lastTimePlayed as Date).getTime()
+ : null;
+
+ if (aLastPlayed !== null && bLastPlayed !== null) {
+ const lastPlayedDifference = bLastPlayed - aLastPlayed;
+ if (lastPlayedDifference !== 0) return lastPlayedDifference;
+ }
+
+ if (aLastPlayed !== null) return -1;
+ if (bLastPlayed !== null) return 1;
+
+ return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
+}
+
+export function isCompletedGame(game: LibraryGame) {
+ const achievementCount = game.achievementCount ?? 0;
+
+ return (
+ achievementCount > 0 &&
+ (game.unlockedAchievementCount ?? 0) >= achievementCount
+ );
+}
+
+export function matchesSearchQuery(game: LibraryGame, searchQuery: string) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ if (!normalizedQuery) return true;
+
+ const titleLower = game.title.toLowerCase();
+ let queryIndex = 0;
+
+ for (
+ let titleIndex = 0;
+ titleIndex < titleLower.length && queryIndex < normalizedQuery.length;
+ titleIndex++
+ ) {
+ if (titleLower[titleIndex] === normalizedQuery[queryIndex]) queryIndex++;
+ }
+
+ return queryIndex === normalizedQuery.length;
+}
+
+export function filterLibraryByTab(
+ library: LibraryGame[],
+ selectedTab: LibraryFilterTab
+) {
+ if (selectedTab === "favorites") {
+ return library.filter((game) => game.favorite);
+ }
+
+ if (selectedTab === "completed") {
+ return library.filter(isCompletedGame);
+ }
+
+ return library;
+}
+
+export function getLibraryFilterCounts(
+ library: LibraryGame[]
+): LibraryFilterCounts {
+ return {
+ all: library.length,
+ favorites: library.filter((game) => game.favorite).length,
+ completed: library.filter(isCompletedGame).length,
+ };
+}
+
+export function getLastPlayedGames(library: LibraryGame[]) {
+ return library
+ .filter((game) => game.lastTimePlayed != null)
+ .slice(0, LAST_PLAYED_GAMES_COUNT);
+}
+
+export function getHeroPlaytimeLabel(playTimeInMilliseconds?: number | null) {
+ if (!playTimeInMilliseconds) return "0h";
+
+ const totalHours = Math.max(
+ 1,
+ Math.round(playTimeInMilliseconds / 3_600_000)
+ );
+
+ return `${totalHours}h`;
+}
diff --git a/src/big-picture/src/components/pages/library/navigation.ts b/src/big-picture/src/components/pages/library/navigation.ts
new file mode 100644
index 000000000..6f6450eb0
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/navigation.ts
@@ -0,0 +1,25 @@
+export const LIBRARY_HERO_ACTIONS_REGION_ID = "library-hero-actions";
+export const LIBRARY_HERO_LAUNCH_BUTTON_ID = "library-hero-launch-button";
+export const LIBRARY_HERO_OPTIONS_BUTTON_ID = "library-hero-options-button";
+export const LIBRARY_HERO_FAVORITE_BUTTON_ID = "library-hero-favorite-button";
+export const LIBRARY_FILTERS_TOOLBAR_REGION_ID = "library-filters-toolbar";
+export const LIBRARY_FILTERS_TABS_REGION_ID = "library-filters-tabs";
+export const LIBRARY_FILTERS_SEARCH_INPUT_ID = "library-filters-search-input";
+export const LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID =
+ "library-filters-list-view-button";
+export const LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID =
+ "library-filters-grid-view-button";
+export const LIBRARY_FILTERS_ALL_TAB_ID = "library-filters-tab-all";
+export const LIBRARY_FILTERS_FAVORITES_TAB_ID = "library-filters-tab-favorites";
+export const LIBRARY_FILTERS_COMPLETED_TAB_ID = "library-filters-tab-completed";
+export const LIBRARY_FOCUS_GRID_REGION_ID = "library-focus-grid";
+
+export function getLibraryFocusGridItemId(gameId: string) {
+ return `library-focus-grid-item-${gameId}`;
+}
+
+export function getFirstLibraryFocusGridItemId(gameId?: string | null) {
+ if (!gameId) return null;
+
+ return getLibraryFocusGridItemId(gameId);
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/helpers.ts b/src/big-picture/src/components/pages/library/settings-modal/helpers.ts
new file mode 100644
index 000000000..afe30b064
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/helpers.ts
@@ -0,0 +1,113 @@
+import type { LibraryGame } from "@types";
+import { GameSettingsCloudSyncState } from "./use-game-settings-cloud-sync";
+import type {
+ GameSettingsAssetType,
+ GameSettingsCategoryId,
+} from "./use-game-settings-controller";
+
+export const GAME_SETTINGS_ASSET_TYPES = [
+ "icon",
+ "logo",
+ "hero",
+] satisfies GameSettingsAssetType[];
+
+export function categoryLabel(categoryId: GameSettingsCategoryId) {
+ switch (categoryId) {
+ case "general":
+ return "General";
+ case "assets":
+ return "Assets";
+ case "hydra_cloud":
+ return "Hydra Cloud";
+ case "compatibility":
+ return "Compatibility";
+ case "downloads":
+ return "Downloads";
+ case "danger_zone":
+ return "Danger Zone";
+ }
+}
+
+export function assetLabel(assetType: GameSettingsAssetType) {
+ if (assetType === "icon") return "Icon";
+ if (assetType === "logo") return "Logo";
+
+ return "Hero";
+}
+
+export function formatDateTime(value: string | Date) {
+ return new Date(value).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+export function formatBackupStateLabel(
+ state: GameSettingsCloudSyncState,
+ loadingPreview: boolean,
+ uploadingBackup: boolean,
+ restoringBackup: boolean,
+ progress: number
+) {
+ if (uploadingBackup) return "Uploading backup...";
+ if (restoringBackup) return `Restoring backup ${Math.round(progress * 100)}%`;
+ if (loadingPreview) return "Loading save preview...";
+ if (state === GameSettingsCloudSyncState.New) return "New save files found";
+ if (state === GameSettingsCloudSyncState.Different)
+ return "Save files changed";
+ if (state === GameSettingsCloudSyncState.Same) return "Save files are synced";
+
+ return "No backup preview";
+}
+
+export function getGameAssetUrl(
+ game: LibraryGame,
+ assetType: GameSettingsAssetType
+) {
+ if (assetType === "icon") return game.customIconUrl ?? game.iconUrl ?? null;
+ if (assetType === "logo")
+ return game.customLogoImageUrl ?? game.logoImageUrl ?? null;
+
+ return game.customHeroImageUrl ?? game.libraryHeroImageUrl ?? null;
+}
+
+export function getGameSidebarCoverUrl(game: LibraryGame) {
+ return (
+ game.customHeroImageUrl ??
+ game.libraryHeroImageUrl ??
+ game.libraryImageUrl ??
+ game.coverImageUrl ??
+ game.customIconUrl ??
+ game.iconUrl ??
+ null
+ );
+}
+
+export function getGameOriginalAssetPath(
+ game: LibraryGame,
+ assetType: GameSettingsAssetType
+) {
+ if (game.shop === "custom") {
+ if (assetType === "icon") return game.originalIconPath ?? game.iconUrl;
+ if (assetType === "logo") return game.originalLogoPath ?? game.logoImageUrl;
+
+ return game.originalHeroPath ?? game.libraryHeroImageUrl;
+ }
+
+ if (assetType === "icon")
+ return game.customOriginalIconPath ?? game.customIconUrl ?? game.iconUrl;
+
+ if (assetType === "logo") {
+ return (
+ game.customOriginalLogoPath ??
+ game.customLogoImageUrl ??
+ game.logoImageUrl
+ );
+ }
+
+ return (
+ game.customOriginalHeroPath ??
+ game.customHeroImageUrl ??
+ game.libraryHeroImageUrl
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/index.tsx b/src/big-picture/src/components/pages/library/settings-modal/index.tsx
new file mode 100644
index 000000000..eae2ccf96
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/index.tsx
@@ -0,0 +1,230 @@
+import "./styles.scss";
+
+import type { GameArtifact, LibraryGame } from "@types";
+import cn from "classnames";
+import { useMemo, useState } from "react";
+import {
+ HorizontalFocusGroup,
+ Modal,
+ NavigationLayer,
+ VerticalFocusGroup,
+} from "../../../common";
+import { GameSettingsSidebar } from "./sidebar";
+import { GameSettingsPanelContent } from "./sections";
+import {
+ ChangePlaytimeModal,
+ ConfirmationModal,
+ CreateSteamShortcutModal,
+ ManageFilesModal,
+ RenameArtifactModal,
+} from "./submodals";
+import type { GameSettingsConfirmation } from "./types";
+import { useGameSettingsCloudSync } from "./use-game-settings-cloud-sync";
+import {
+ type GameSettingsCategoryId,
+ useGameSettingsController,
+} from "./use-game-settings-controller";
+
+export interface GameSettingsModalProps {
+ visible: boolean;
+ game: LibraryGame | null;
+ onClose: () => void;
+ onGameUpdated?: (game: LibraryGame | null) => void;
+}
+
+const ROOT_REGION_ID = "library-game-settings-modal-root";
+const CATEGORIES_REGION_ID = "library-game-settings-modal-categories";
+const PANEL_REGION_ID = "library-game-settings-modal-panel";
+
+function getSettingsCategories() {
+ const categories: GameSettingsCategoryId[] = [
+ "general",
+ "assets",
+ "hydra_cloud",
+ "downloads",
+ "danger_zone",
+ ];
+
+ if (globalThis.window.electron?.platform === "linux") {
+ categories.splice(3, 0, "compatibility");
+ }
+
+ return categories;
+}
+
+export function GameSettingsModal({
+ visible,
+ game: initialGame,
+ onClose,
+ onGameUpdated,
+}: Readonly) {
+ const controller = useGameSettingsController({
+ visible,
+ initialGame,
+ onClose,
+ onGameUpdated,
+ });
+ const cloudSync = useGameSettingsCloudSync({
+ visible,
+ game: controller.game,
+ hasActiveSubscription: controller.hasActiveSubscription,
+ onFeedback: controller.notify,
+ });
+ const [showSteamShortcutModal, setShowSteamShortcutModal] = useState(false);
+ const [confirmation, setConfirmation] =
+ useState(null);
+ const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
+ const [artifactToRename, setArtifactToRename] = useState(
+ null
+ );
+ const [artifactToDelete, setArtifactToDelete] = useState(
+ null
+ );
+ const [showManageFilesModal, setShowManageFilesModal] = useState(false);
+ const categories = useMemo(getSettingsCategories, []);
+ const game = controller.game;
+
+ if (!game) return null;
+
+ return (
+ <>
+
+
+
+
+
+
+ {controller.feedback && (
+
+ {controller.feedback.message}
+
+ )}
+
+
+
+ setShowSteamShortcutModal(true)
+ }
+ onOpenChangePlaytimeModal={() =>
+ setShowChangePlaytimeModal(true)
+ }
+ onOpenManageFilesModal={() => setShowManageFilesModal(true)}
+ onRenameArtifact={setArtifactToRename}
+ onDeleteArtifact={(artifact) => {
+ setArtifactToDelete(artifact);
+ setConfirmation("delete-artifact");
+ }}
+ onRequestConfirmation={setConfirmation}
+ />
+
+
+
+
+
+
+ setShowSteamShortcutModal(false)}
+ onConfirm={controller.handleCreateSteamShortcut}
+ />
+
+ setShowChangePlaytimeModal(false)}
+ onConfirm={controller.handleChangePlaytime}
+ />
+
+ setArtifactToRename(null)}
+ onConfirm={cloudSync.renameGameArtifact}
+ />
+
+ setShowManageFilesModal(false)}
+ onSetBackupPath={cloudSync.setBackupPath}
+ />
+
+ setConfirmation(null)}
+ onConfirm={controller.handleRemoveFromLibrary}
+ />
+
+ setConfirmation(null)}
+ onConfirm={controller.handleResetAchievements}
+ />
+
+ setConfirmation(null)}
+ onConfirm={controller.handleRemoveGameFiles}
+ />
+
+ {
+ setConfirmation(null);
+ setArtifactToDelete(null);
+ }}
+ onConfirm={async () => {
+ if (artifactToDelete) {
+ await cloudSync.deleteGameArtifact(artifactToDelete.id);
+ }
+ }}
+ />
+ >
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/sections.tsx b/src/big-picture/src/components/pages/library/settings-modal/sections.tsx
new file mode 100644
index 000000000..5bdd558a7
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/sections.tsx
@@ -0,0 +1,707 @@
+import {
+ ArrowsClockwiseIcon,
+ CloudArrowDownIcon,
+ CloudArrowUpIcon,
+ CloudIcon,
+ FileIcon,
+ FloppyDiskIcon,
+ FolderOpenIcon,
+ HardDriveIcon,
+ ImageIcon,
+ PencilSimpleIcon,
+ PushPinIcon,
+ PushPinSlashIcon,
+ SparkleIcon,
+ TrashIcon,
+} from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+import type { GameArtifact, LibraryGame } from "@types";
+import { formatBytes } from "@shared";
+import { Button, HorizontalFocusGroup } from "../../../common";
+import { resolveImageSource } from "../../../../helpers";
+import type { GameSettingsCloudSync } from "./use-game-settings-cloud-sync";
+import type { GameSettingsController } from "./use-game-settings-controller";
+import type { GameSettingsConfirmation } from "./types";
+import { FocusableInput, SettingsSection, ToggleAction } from "./shared";
+import {
+ assetLabel,
+ formatBackupStateLabel,
+ formatDateTime,
+ GAME_SETTINGS_ASSET_TYPES,
+ getGameAssetUrl,
+ getGameOriginalAssetPath,
+} from "./helpers";
+
+type GameSettingsPanelContentProps = Readonly<{
+ controller: GameSettingsController;
+ cloudSync: GameSettingsCloudSync;
+ onOpenSteamShortcutModal: () => void;
+ onOpenChangePlaytimeModal: () => void;
+ onOpenManageFilesModal: () => void;
+ onRenameArtifact: (artifact: GameArtifact) => void;
+ onDeleteArtifact: (artifact: GameArtifact) => void;
+ onRequestConfirmation: (confirmation: GameSettingsConfirmation) => void;
+}>;
+
+function GeneralSection({
+ game,
+ controller,
+ onOpenSteamShortcutModal,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+ onOpenSteamShortcutModal: () => void;
+}>) {
+ const canOpenSaveFolder =
+ game.shop !== "custom" && globalThis.window.electron.platform === "win32";
+
+ let saveFolderButtonLabel = "No save folder found";
+ if (controller.loadingSaveFolder) {
+ saveFolderButtonLabel = "Searching save folder";
+ } else if (controller.saveFolderPath) {
+ saveFolderButtonLabel = "Open save folder";
+ }
+
+ return (
+ <>
+
+
+
+ }
+ disabled={controller.updatingTitle}
+ onClick={controller.handleSaveTitle}
+ >
+ Save
+
+
+
+
+
+
+ {game.executablePath || "No executable selected"}
+
+
+ }
+ onClick={controller.handleSelectExecutable}
+ >
+ Select executable
+
+
+
+ {canOpenSaveFolder && (
+
+ )}
+
+
+
+ {game.executablePath && (
+
+
+
+ {globalThis.window.electron.platform === "win32" && (
+
+ )}
+ {game.shop !== "custom" &&
+ (controller.steamShortcutExists ? (
+
+ ) : (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function AssetsSection({
+ game,
+ controller,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+}>) {
+ return (
+
+
+ {GAME_SETTINGS_ASSET_TYPES.map((assetType) => {
+ const assetUrl = getGameAssetUrl(game, assetType);
+ const originalPath = getGameOriginalAssetPath(game, assetType);
+
+ return (
+
+
+ {assetUrl ? (
+
})
+ ) : (
+
+ )}
+
+
+
{assetLabel(assetType)}
+
{originalPath || "No custom asset selected"}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function HydraCloudSection({
+ game,
+ controller,
+ cloudSync,
+ onOpenManageFilesModal,
+ onRenameArtifact,
+ onDeleteArtifact,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+ cloudSync: GameSettingsCloudSync;
+ onOpenManageFilesModal: () => void;
+ onRenameArtifact: (artifact: GameArtifact) => void;
+ onDeleteArtifact: (artifact: GameArtifact) => void;
+}>) {
+ const cloudActionsDisabled =
+ cloudSync.uploadingBackup ||
+ cloudSync.restoringBackup ||
+ cloudSync.freezingArtifact ||
+ cloudSync.deletingArtifact;
+ const backupsLimit = controller.userDetails?.quirks?.backupsPerGameLimit ?? 0;
+ const hasReachedBackupsLimit =
+ backupsLimit > 0 && cloudSync.artifacts.length >= backupsLimit;
+
+ let hydraCloudBody: ReactNode;
+ if (game.shop === "custom") {
+ hydraCloudBody = (
+
+ Settings are not available for custom games.
+
+ );
+ } else if (controller.hasActiveSubscription) {
+ hydraCloudBody = (
+ <>
+
+
+
+
+ {formatBackupStateLabel(
+ cloudSync.backupState,
+ cloudSync.loadingPreview,
+ cloudSync.uploadingBackup,
+ cloudSync.restoringBackup,
+ cloudSync.backupDownloadProgress?.progress ?? 0
+ )}
+
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={
+ cloudActionsDisabled ||
+ !cloudSync.backupPreview?.overall.totalGames ||
+ hasReachedBackupsLimit
+ }
+ onClick={cloudSync.uploadSaveGame}
+ >
+ Create backup
+
+
+
+
Backups
+ {cloudSync.artifacts.length}
+
+
+ {cloudSync.artifacts.length === 0 ? (
+
+ No backups created.
+
+ ) : (
+ cloudSync.artifacts
+ .toSorted((a, b) => Number(b.isFrozen) - Number(a.isFrozen))
+ .map((artifact) => (
+
+ ))
+ )}
+
+ >
+ );
+ } else {
+ hydraCloudBody = (
+
+
+
Hydra Cloud is available with an active subscription.
+
+
+ );
+ }
+
+ return (
+
+ {hydraCloudBody}
+
+ );
+}
+
+function BackupCard({
+ artifact,
+ cloudActionsDisabled,
+ cloudSync,
+ onRenameArtifact,
+ onDeleteArtifact,
+}: Readonly<{
+ artifact: GameArtifact;
+ cloudActionsDisabled: boolean;
+ cloudSync: GameSettingsCloudSync;
+ onRenameArtifact: (artifact: GameArtifact) => void;
+ onDeleteArtifact: (artifact: GameArtifact) => void;
+}>) {
+ const artifactName =
+ artifact.label ?? `Backup from ${formatDateTime(artifact.createdAt)}`;
+
+ return (
+
+
+
}
+ onClick={() => onRenameArtifact(artifact)}
+ >
+ {artifactName}
+
+
+ {formatBytes(artifact.artifactLengthInBytes)} - {artifact.hostname} -{" "}
+ {formatDateTime(artifact.createdAt)}
+
+
{artifact.downloadOptionTitle ?? "No download option info"}
+
+
+ }
+ disabled={cloudActionsDisabled}
+ onClick={() => cloudSync.downloadGameArtifact(artifact.id)}
+ >
+ Install
+
+
+ ) : (
+
+ )
+ }
+ disabled={cloudActionsDisabled}
+ onClick={() =>
+ cloudSync.toggleArtifactFreeze(artifact.id, !artifact.isFrozen)
+ }
+ >
+ {artifact.isFrozen ? "Unfreeze" : "Freeze"}
+
+ }
+ disabled={cloudActionsDisabled || artifact.isFrozen}
+ onClick={() => onDeleteArtifact(artifact)}
+ >
+ Delete
+
+
+
+ );
+}
+
+function CompatibilitySection({
+ game,
+ controller,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+}>) {
+ return (
+ <>
+
+
+
+ {controller.displayedWinePrefixPath || "No directory selected"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {controller.protonVersions.map((version) => (
+
+ ))}
+
+
+ >
+ );
+}
+
+function DownloadsSection({
+ game,
+ controller,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+}>) {
+ return (
+
+ {game.shop === "custom" ? (
+
+ Settings are not available for custom games.
+
+ ) : (
+
+
+ }
+ disabled={!game.download?.downloadPath}
+ onClick={controller.handleOpenDownloadFolder}
+ >
+ Open download location
+
+
+ )}
+
+ );
+}
+
+function DangerZoneSection({
+ game,
+ controller,
+ onOpenChangePlaytimeModal,
+ onRequestConfirmation,
+}: Readonly<{
+ game: LibraryGame;
+ controller: GameSettingsController;
+ onOpenChangePlaytimeModal: () => void;
+ onRequestConfirmation: (confirmation: GameSettingsConfirmation) => void;
+}>) {
+ return (
+
+
+ }
+ onClick={() => onRequestConfirmation("remove-library")}
+ >
+ Remove from library
+
+ {game.shop !== "custom" && (
+ }
+ disabled={
+ controller.isDeletingAchievements ||
+ !controller.hasAchievements ||
+ !controller.userDetails
+ }
+ onClick={() => onRequestConfirmation("reset-achievements")}
+ >
+ Reset achievements
+
+ )}
+ }
+ onClick={onOpenChangePlaytimeModal}
+ >
+ Update playtime
+
+ {game.shop !== "custom" && (
+ }
+ disabled={
+ controller.isGameDownloading ||
+ controller.isDeletingGameFiles ||
+ !game.download?.downloadPath
+ }
+ onClick={() => onRequestConfirmation("remove-files")}
+ >
+ Remove files
+
+ )}
+
+
+ );
+}
+
+export function GameSettingsPanelContent({
+ controller,
+ cloudSync,
+ onOpenSteamShortcutModal,
+ onOpenChangePlaytimeModal,
+ onOpenManageFilesModal,
+ onRenameArtifact,
+ onDeleteArtifact,
+ onRequestConfirmation,
+}: GameSettingsPanelContentProps) {
+ const game = controller.game;
+ if (!game) return null;
+
+ if (controller.selectedCategory === "general") {
+ return (
+
+ );
+ }
+
+ if (controller.selectedCategory === "assets") {
+ return ;
+ }
+
+ if (controller.selectedCategory === "hydra_cloud") {
+ return (
+
+ );
+ }
+
+ if (controller.selectedCategory === "compatibility") {
+ return ;
+ }
+
+ if (controller.selectedCategory === "downloads") {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/shared.tsx b/src/big-picture/src/components/pages/library/settings-modal/shared.tsx
new file mode 100644
index 000000000..fcfb95bd8
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/shared.tsx
@@ -0,0 +1,78 @@
+import cn from "classnames";
+import { type ReactNode } from "react";
+import { Button, Input } from "../../../common";
+
+export function SettingsSection({
+ title,
+ description,
+ children,
+ danger = false,
+}: Readonly<{
+ title: string;
+ description?: string;
+ children: ReactNode;
+ danger?: boolean;
+}>) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {children}
+
+ );
+}
+
+export function FocusableInput({
+ value,
+ onChange,
+ label,
+ placeholder,
+ disabled,
+ type = "text",
+}: Readonly<{
+ value: string;
+ onChange: (value: string) => void;
+ label: string;
+ placeholder?: string;
+ disabled?: boolean;
+ type?: string;
+}>) {
+ return (
+ onChange(event.target.value)}
+ />
+ );
+}
+
+export function ToggleAction({
+ label,
+ checked,
+ disabled,
+ onToggle,
+}: Readonly<{
+ label: string;
+ checked: boolean;
+ disabled?: boolean;
+ onToggle: () => void;
+}>) {
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/sidebar.tsx b/src/big-picture/src/components/pages/library/settings-modal/sidebar.tsx
new file mode 100644
index 000000000..2db6a1346
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/sidebar.tsx
@@ -0,0 +1,82 @@
+import type { LibraryGame } from "@types";
+import cn from "classnames";
+import { FocusItem, VerticalFocusGroup } from "../../../common";
+import { resolveImageSource } from "../../../../helpers";
+import type { GameSettingsCategoryId } from "./use-game-settings-controller";
+import { categoryLabel, getGameSidebarCoverUrl } from "./helpers";
+
+function SidebarItem({
+ categoryId,
+ active,
+ onClick,
+}: Readonly<{
+ categoryId: GameSettingsCategoryId;
+ active: boolean;
+ onClick: () => void;
+}>) {
+ return (
+
+
+
+ );
+}
+
+export function GameSettingsSidebar({
+ game,
+ categories,
+ selectedCategory,
+ regionId,
+ onCategoryChange,
+}: Readonly<{
+ game: LibraryGame;
+ categories: GameSettingsCategoryId[];
+ selectedCategory: GameSettingsCategoryId;
+ regionId: string;
+ onCategoryChange: (categoryId: GameSettingsCategoryId) => void;
+}>) {
+ const sidebarCoverUrl = getGameSidebarCoverUrl(game);
+
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/styles.scss b/src/big-picture/src/components/pages/library/settings-modal/styles.scss
new file mode 100644
index 000000000..65757b372
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/styles.scss
@@ -0,0 +1,466 @@
+.modal.game-settings-modal {
+ width: min(1200px, calc(100vw - calc(var(--spacing-unit) * 12)));
+ max-width: none;
+ height: min(820px, calc(100vh - calc(var(--spacing-unit) * 10)));
+ max-height: none;
+ border: 1px solid var(--secondary-border);
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: var(--surface);
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
+ flex-direction: row;
+}
+
+.modal.game-settings-modal__submodal {
+ width: min(520px, calc(100vw - calc(var(--spacing-unit) * 12)));
+ max-width: none;
+ border: 1px solid var(--secondary-border);
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: var(--surface);
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
+}
+
+.modal.game-settings-modal__submodal--wide {
+ width: min(760px, calc(100vw - calc(var(--spacing-unit) * 12)));
+}
+
+.game-settings-modal {
+ &__shell {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ flex: 1;
+ padding: 0;
+ }
+
+ &__sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ width: 320px;
+ min-width: 320px;
+ height: 100%;
+ padding: 0;
+ border-right: 1px solid var(--secondary-border);
+ background-color: var(--background);
+ }
+
+ &__sidebar-cover {
+ position: relative;
+ width: 100%;
+ height: 88px;
+ min-height: 88px;
+ overflow: hidden;
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(0, 0, 0, 0.08) 0%,
+ rgba(0, 0, 0, 0.28) 56%,
+ var(--background) 100%
+ ),
+ var(--secondary);
+
+ h2 {
+ position: absolute;
+ right: 28px;
+ bottom: 24px;
+ left: 28px;
+ max-width: calc(100% - 56px);
+ margin: 0;
+ overflow: hidden;
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 24px;
+ letter-spacing: -0.02em;
+ text-overflow: ellipsis;
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
+ white-space: nowrap;
+ z-index: 1;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ filter: saturate(1.08);
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 180deg,
+ transparent 45%,
+ color-mix(in srgb, var(--background) 72%, transparent) 78%,
+ var(--background) 100%
+ );
+ pointer-events: none;
+ }
+ }
+
+ &__sidebar-divider {
+ width: calc(100% - 48px);
+ height: 1px;
+ margin: 0 24px 24px;
+ flex-shrink: 0;
+ background-color: var(--secondary-border);
+ }
+
+ &__sidebar-list {
+ width: 100%;
+ }
+
+ &__feedback {
+ margin: 0 0 calc(var(--spacing-unit) * 4);
+ padding: calc(var(--spacing-unit) * 2.5) calc(var(--spacing-unit) * 3);
+ border: 1px solid var(--secondary-border);
+ border-radius: var(--spacing-unit);
+ background-color: var(--background);
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+
+ &--success {
+ border-color: color-mix(
+ in srgb,
+ var(--success) 32%,
+ var(--secondary-border)
+ );
+ color: var(--success);
+ }
+
+ &--error {
+ border-color: var(--error-border);
+ color: var(--error);
+ background-color: var(--error-background);
+ }
+ }
+
+ &__sidebar-item {
+ appearance: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ min-height: 0;
+ padding: 16px 24px;
+ border: 0;
+ border-radius: 0;
+ background-color: transparent;
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: normal;
+ letter-spacing: -0.02em;
+ text-align: left;
+ cursor: pointer;
+ outline: none;
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease,
+ opacity 0.18s ease;
+
+ &:hover,
+ &[data-focus-visible="true"] {
+ background-color: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ }
+
+ &--active {
+ background-color: rgba(255, 255, 255, 0.15);
+ color: var(--text);
+ }
+ }
+
+ &__sidebar-item-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__panel {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1;
+ height: 100%;
+ overflow: auto;
+ padding: calc(var(--spacing-unit) * 5);
+ background-color: var(--surface);
+ }
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2.5);
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+
+ & + & {
+ padding-top: calc(var(--spacing-unit) * 4);
+ border-top: 1px solid var(--secondary-border);
+ }
+
+ &--danger {
+ color: var(--error);
+ }
+ }
+
+ &__section-header {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 1);
+
+ h3 {
+ margin: 0;
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 1.2;
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 1.4;
+ }
+ }
+
+ &__section--danger &__section-header h3 {
+ color: var(--error);
+ }
+
+ &__field-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: calc(var(--spacing-unit) * 2.5);
+ }
+
+ &__form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: calc(var(--spacing-unit) * 2.5);
+ }
+
+ &__actions {
+ flex-wrap: wrap;
+ gap: calc(var(--spacing-unit) * 2) !important;
+ margin-top: 0;
+ }
+
+ &__path-card {
+ min-height: 44px;
+ padding: calc(var(--spacing-unit) * 2.5);
+ border: 1px solid var(--secondary-border);
+ border-radius: var(--spacing-unit);
+ background-color: var(--background);
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+ line-height: 18px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__assets,
+ &__backups,
+ &__danger-actions,
+ &__proton-list,
+ &__file-list {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2.5);
+ }
+
+ &__asset-card,
+ &__backup-card {
+ display: grid;
+ grid-template-columns: 148px minmax(0, 1fr);
+ gap: calc(var(--spacing-unit) * 3);
+ padding: calc(var(--spacing-unit) * 2.5);
+ border: 1px solid var(--secondary-border);
+ border-radius: var(--spacing-unit);
+ background-color: var(--background);
+ }
+
+ &__asset-preview {
+ display: grid;
+ place-items: center;
+ min-height: 92px;
+ overflow: hidden;
+ border-radius: var(--spacing-unit);
+ background-color: var(--secondary);
+ color: var(--text-secondary);
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ &__asset-info,
+ &__backup-card > div:first-child {
+ min-width: 0;
+
+ h4 {
+ margin: 0 0 calc(var(--spacing-unit) * 1.5);
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 12px;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ &__upgrade-card,
+ &__cloud-header,
+ &__backups-title,
+ &__file-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 2.5);
+ }
+
+ &__upgrade-card,
+ &__cloud-header {
+ padding: calc(var(--spacing-unit) * 2.5);
+ border: 1px solid var(--secondary-border);
+ border-radius: var(--spacing-unit);
+ background-color: var(--background);
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+ line-height: 1.4;
+ }
+ }
+
+ &__backups-title {
+ h4 {
+ margin: 0;
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ span {
+ min-width: 24px;
+ padding: 2px calc(var(--spacing-unit) * 2);
+ border-radius: var(--spacing-unit);
+ background-color: var(--secondary);
+ color: var(--text-secondary);
+ font-size: 12px;
+ text-align: center;
+ }
+ }
+
+ &__backup-card {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ }
+
+ &__empty-note {
+ margin: 0;
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+ }
+
+ &__submodal-content {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ padding: calc(var(--spacing-unit) * 5);
+
+ h2 {
+ margin: 0;
+ color: var(--text);
+ font-family: var(--font-space-grotesk);
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 13px;
+ line-height: 1.4;
+ }
+ }
+
+ &__file-list {
+ max-height: 320px;
+ overflow: auto;
+ padding: 0;
+ list-style: none;
+ }
+
+ &__file-item {
+ padding: calc(var(--spacing-unit) * 3);
+ border-radius: var(--spacing-unit);
+ background-color: var(--background);
+
+ span {
+ color: var(--text-secondary);
+ font-family: var(--font-space-grotesk);
+ font-size: 12px;
+ }
+ }
+}
+
+@media (max-width: 900px) {
+ .modal.game-settings-modal {
+ width: calc(100vw - calc(var(--spacing-unit) * 6));
+ height: calc(100vh - calc(var(--spacing-unit) * 6));
+ }
+
+ .game-settings-modal {
+ &__shell {
+ flex-direction: column;
+ }
+
+ &__sidebar {
+ width: 100%;
+ min-width: 0;
+ height: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--secondary-border);
+ }
+
+ &__asset-card,
+ &__backup-card,
+ &__field-row {
+ grid-template-columns: 1fr;
+ }
+ }
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/submodals.tsx b/src/big-picture/src/components/pages/library/settings-modal/submodals.tsx
new file mode 100644
index 000000000..ff6e2d36f
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/submodals.tsx
@@ -0,0 +1,308 @@
+import type {
+ CreateSteamShortcutOptions,
+ GameArtifact,
+ LibraryGame,
+} from "@types";
+import { formatBytes } from "@shared";
+import { useMemo, useState } from "react";
+import {
+ Button,
+ HorizontalFocusGroup,
+ Modal,
+ NavigationLayer,
+ VerticalFocusGroup,
+} from "../../../common";
+import type { useGameSettingsCloudSync } from "./use-game-settings-cloud-sync";
+import { FocusableInput, ToggleAction } from "./shared";
+
+export function ConfirmationModal({
+ visible,
+ title,
+ description,
+ confirmLabel,
+ danger = false,
+ onClose,
+ onConfirm,
+}: Readonly<{
+ visible: boolean;
+ title: string;
+ description: string;
+ confirmLabel: string;
+ danger?: boolean;
+ onClose: () => void;
+ onConfirm: () => Promise | void;
+}>) {
+ return (
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function CreateSteamShortcutModal({
+ visible,
+ creating,
+ onClose,
+ onConfirm,
+}: Readonly<{
+ visible: boolean;
+ creating: boolean;
+ onClose: () => void;
+ onConfirm: (options: CreateSteamShortcutOptions) => Promise;
+}>) {
+ const [openVr, setOpenVr] = useState(false);
+
+ return (
+
+
+
+
+
Create Steam shortcut
+ setOpenVr((value) => !value)}
+ />
+
+
+
+
+
+
+
+
+ );
+}
+
+export function ChangePlaytimeModal({
+ visible,
+ game,
+ onClose,
+ onConfirm,
+}: Readonly<{
+ visible: boolean;
+ game: LibraryGame | null;
+ onClose: () => void;
+ onConfirm: (playtimeInSeconds: number) => Promise;
+}>) {
+ const totalMinutes = Math.floor((game?.playTimeInMilliseconds ?? 0) / 60_000);
+ const [hours, setHours] = useState(
+ totalMinutes ? String(Math.floor(totalMinutes / 60)) : ""
+ );
+ const [minutes, setMinutes] = useState(
+ totalMinutes ? String(totalMinutes % 60) : ""
+ );
+
+ return (
+
+
+
+
+
Update playtime
+
Set the manual playtime for {game?.title ?? "this game"}.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function RenameArtifactModal({
+ visible,
+ artifact,
+ onClose,
+ onConfirm,
+}: Readonly<{
+ visible: boolean;
+ artifact: GameArtifact | null;
+ onClose: () => void;
+ onConfirm: (artifactId: string, label: string) => Promise;
+}>) {
+ const [label, setLabel] = useState(artifact?.label ?? "");
+
+ return (
+
+
+
+
+
Rename backup
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function ManageFilesModal({
+ visible,
+ backupPreview,
+ onClose,
+ onSetBackupPath,
+}: Readonly<{
+ visible: boolean;
+ backupPreview: ReturnType["backupPreview"];
+ onClose: () => void;
+ onSetBackupPath: (path: string | null) => Promise;
+}>) {
+ const files = useMemo(() => {
+ if (!backupPreview) return [];
+
+ const [gameBackup] = Object.values(backupPreview.games);
+ if (!gameBackup) return [];
+
+ return Object.entries(gameBackup.files).map(([path, file]) => ({
+ path,
+ bytes: file.bytes,
+ }));
+ }, [backupPreview]);
+
+ return (
+
+
+
+
+
Manage files
+
Choose automatic mapping or a custom folder for this game.
+
+
+
+
+
+ {files.map((file) => (
+ -
+
+ {formatBytes(file.bytes)}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/settings-modal/types.ts b/src/big-picture/src/components/pages/library/settings-modal/types.ts
new file mode 100644
index 000000000..dc9c83260
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/types.ts
@@ -0,0 +1,5 @@
+export type GameSettingsConfirmation =
+ | "remove-library"
+ | "reset-achievements"
+ | "remove-files"
+ | "delete-artifact";
diff --git a/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-cloud-sync.ts b/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-cloud-sync.ts
new file mode 100644
index 000000000..4fcdb6497
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-cloud-sync.ts
@@ -0,0 +1,283 @@
+import type { GameArtifact, LibraryGame, LudusaviBackup } from "@types";
+import type { AxiosProgressEvent } from "axios";
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+interface UseGameSettingsCloudSyncProps {
+ visible: boolean;
+ game: LibraryGame | null;
+ hasActiveSubscription: boolean;
+ onFeedback: (type: "success" | "error" | "info", message: string) => void;
+}
+
+export enum GameSettingsCloudSyncState {
+ New,
+ Different,
+ Same,
+ Unknown,
+}
+
+export function useGameSettingsCloudSync({
+ visible,
+ game,
+ hasActiveSubscription,
+ onFeedback,
+}: UseGameSettingsCloudSyncProps) {
+ const [artifacts, setArtifacts] = useState([]);
+ const [backupPreview, setBackupPreview] = useState(
+ null
+ );
+ const [loadingPreview, setLoadingPreview] = useState(false);
+ const [uploadingBackup, setUploadingBackup] = useState(false);
+ const [restoringBackup, setRestoringBackup] = useState(false);
+ const [freezingArtifact, setFreezingArtifact] = useState(false);
+ const [deletingArtifact, setDeletingArtifact] = useState(false);
+ const [backupDownloadProgress, setBackupDownloadProgress] =
+ useState(null);
+
+ const getGameArtifacts = useCallback(async () => {
+ if (!game || game.shop === "custom" || !hasActiveSubscription) {
+ setArtifacts([]);
+ return;
+ }
+
+ const params = new URLSearchParams({
+ objectId: game.objectId,
+ shop: game.shop,
+ });
+
+ const { electron } = globalThis as typeof globalThis & Window;
+
+ const results = await electron.hydraApi
+ .get(`/profile/games/artifacts?${params.toString()}`, {
+ needsSubscription: true,
+ })
+ .catch(() => []);
+
+ setArtifacts(results);
+ }, [game, hasActiveSubscription]);
+
+ const getGameBackupPreview = useCallback(async () => {
+ if (!game || game.shop === "custom" || !hasActiveSubscription) {
+ setBackupPreview(null);
+ return;
+ }
+
+ setLoadingPreview(true);
+ try {
+ const preview = await globalThis.window.electron.getGameBackupPreview(
+ game.objectId,
+ game.shop
+ );
+ setBackupPreview(preview);
+ } catch {
+ setBackupPreview(null);
+ onFeedback("error", "Could not load backup preview");
+ } finally {
+ setLoadingPreview(false);
+ }
+ }, [game, hasActiveSubscription, onFeedback]);
+
+ const refreshCloudSync = useCallback(async () => {
+ await Promise.all([getGameBackupPreview(), getGameArtifacts()]);
+ }, [getGameArtifacts, getGameBackupPreview]);
+
+ useEffect(() => {
+ if (!visible || !game || game.shop === "custom" || !hasActiveSubscription) {
+ setArtifacts([]);
+ setBackupPreview(null);
+ return;
+ }
+
+ void refreshCloudSync();
+ }, [game, hasActiveSubscription, refreshCloudSync, visible]);
+
+ useEffect(() => {
+ if (!visible || !game) return;
+
+ const removeUploadCompleteListener =
+ globalThis.window.electron.onUploadComplete(
+ game.objectId,
+ game.shop,
+ () => {
+ setUploadingBackup(false);
+ onFeedback("success", "Backup uploaded");
+ void refreshCloudSync();
+ }
+ );
+
+ const removeDownloadCompleteListener =
+ globalThis.window.electron.onBackupDownloadComplete(
+ game.objectId,
+ game.shop,
+ () => {
+ setRestoringBackup(false);
+ setBackupDownloadProgress(null);
+ onFeedback("success", "Backup restored");
+ void refreshCloudSync();
+ }
+ );
+
+ const removeDownloadProgressListener =
+ globalThis.window.electron.onBackupDownloadProgress(
+ game.objectId,
+ game.shop,
+ setBackupDownloadProgress
+ );
+
+ return () => {
+ removeUploadCompleteListener();
+ removeDownloadCompleteListener();
+ removeDownloadProgressListener();
+ };
+ }, [game, onFeedback, refreshCloudSync, visible]);
+
+ useEffect(() => {
+ setArtifacts([]);
+ setBackupPreview(null);
+ setUploadingBackup(false);
+ setRestoringBackup(false);
+ setBackupDownloadProgress(null);
+ }, [game?.id]);
+
+ const uploadSaveGame = useCallback(async () => {
+ if (!game) return;
+
+ setUploadingBackup(true);
+ try {
+ await globalThis.window.electron.uploadSaveGame(
+ game.objectId,
+ game.shop,
+ null
+ );
+ } catch {
+ setUploadingBackup(false);
+ onFeedback("error", "Backup failed");
+ }
+ }, [game, onFeedback]);
+
+ const downloadGameArtifact = useCallback(
+ async (gameArtifactId: string) => {
+ if (!game) return;
+
+ setRestoringBackup(true);
+ setBackupDownloadProgress(null);
+ try {
+ await globalThis.window.electron.downloadGameArtifact(
+ game.objectId,
+ game.shop,
+ gameArtifactId
+ );
+ } catch {
+ setRestoringBackup(false);
+ onFeedback("error", "Could not restore backup");
+ }
+ },
+ [game, onFeedback]
+ );
+
+ const deleteGameArtifact = useCallback(
+ async (gameArtifactId: string) => {
+ setDeletingArtifact(true);
+ try {
+ await globalThis.window.electron.hydraApi.delete(
+ `/profile/games/artifacts/${gameArtifactId}`
+ );
+ await refreshCloudSync();
+ onFeedback("success", "Backup deleted");
+ } catch {
+ onFeedback("error", "Could not delete backup");
+ } finally {
+ setDeletingArtifact(false);
+ }
+ },
+ [onFeedback, refreshCloudSync]
+ );
+
+ const renameGameArtifact = useCallback(
+ async (gameArtifactId: string, label: string) => {
+ await globalThis.window.electron.hydraApi.put(
+ `/profile/games/artifacts/${gameArtifactId}`,
+ {
+ data: {
+ label,
+ },
+ }
+ );
+ await getGameArtifacts();
+ onFeedback("success", "Backup renamed");
+ },
+ [getGameArtifacts, onFeedback]
+ );
+
+ const toggleArtifactFreeze = useCallback(
+ async (gameArtifactId: string, freeze: boolean) => {
+ setFreezingArtifact(true);
+ try {
+ const endpoint = freeze ? "freeze" : "unfreeze";
+ await globalThis.window.electron.hydraApi.put(
+ `/profile/games/artifacts/${gameArtifactId}/${endpoint}`
+ );
+ await getGameArtifacts();
+ onFeedback("success", freeze ? "Backup frozen" : "Backup unfrozen");
+ } catch {
+ onFeedback("error", "Could not update backup");
+ } finally {
+ setFreezingArtifact(false);
+ }
+ },
+ [getGameArtifacts, onFeedback]
+ );
+
+ const setBackupPath = useCallback(
+ async (backupPath: string | null) => {
+ if (!game) return;
+
+ await globalThis.window.electron.selectGameBackupPath(
+ game.shop,
+ game.objectId,
+ backupPath
+ );
+ await getGameBackupPreview();
+ onFeedback(
+ "success",
+ backupPath ? "Custom backup location set" : "Automatic mapping enabled"
+ );
+ },
+ [game, getGameBackupPreview, onFeedback]
+ );
+
+ const backupState = useMemo(() => {
+ if (!backupPreview) return GameSettingsCloudSyncState.Unknown;
+ if (backupPreview.overall.changedGames.new)
+ return GameSettingsCloudSyncState.New;
+ if (backupPreview.overall.changedGames.different)
+ return GameSettingsCloudSyncState.Different;
+ if (backupPreview.overall.changedGames.same)
+ return GameSettingsCloudSyncState.Same;
+
+ return GameSettingsCloudSyncState.Unknown;
+ }, [backupPreview]);
+
+ return {
+ artifacts,
+ backupPreview,
+ backupState,
+ loadingPreview,
+ uploadingBackup,
+ restoringBackup,
+ freezingArtifact,
+ deletingArtifact,
+ backupDownloadProgress,
+ getGameArtifacts,
+ getGameBackupPreview,
+ refreshCloudSync,
+ uploadSaveGame,
+ downloadGameArtifact,
+ deleteGameArtifact,
+ renameGameArtifact,
+ toggleArtifactFreeze,
+ setBackupPath,
+ };
+}
+
+export type GameSettingsCloudSync = ReturnType;
diff --git a/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-controller.ts b/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-controller.ts
new file mode 100644
index 000000000..28c647aa6
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/settings-modal/use-game-settings-controller.ts
@@ -0,0 +1,861 @@
+import type {
+ CreateSteamShortcutOptions,
+ LibraryGame,
+ ProtonVersion,
+ ShortcutLocation,
+ UserAchievement,
+ UserDetails,
+ UserPreferences,
+} from "@types";
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+type FeedbackType = "success" | "error" | "info";
+
+export interface GameSettingsFeedback {
+ type: FeedbackType;
+ message: string;
+}
+
+export type GameSettingsCategoryId =
+ | "general"
+ | "assets"
+ | "hydra_cloud"
+ | "compatibility"
+ | "downloads"
+ | "danger_zone";
+
+export type GameSettingsAssetType = "icon" | "logo" | "hero";
+
+interface UseGameSettingsControllerProps {
+ visible: boolean;
+ initialGame: LibraryGame | null;
+ onClose: () => void;
+ onGameUpdated?: (game: LibraryGame | null) => void;
+}
+
+function getLocalPath(value?: string | null) {
+ return value?.startsWith("local:") ? value.slice("local:".length) : value;
+}
+
+function getAssetField(assetType: GameSettingsAssetType) {
+ if (assetType === "icon") {
+ return {
+ customUrl: "customIconUrl",
+ customOriginalPath: "customOriginalIconPath",
+ url: "iconUrl",
+ originalPath: "originalIconPath",
+ } as const;
+ }
+
+ if (assetType === "logo") {
+ return {
+ customUrl: "customLogoImageUrl",
+ customOriginalPath: "customOriginalLogoPath",
+ url: "logoImageUrl",
+ originalPath: "originalLogoPath",
+ } as const;
+ }
+
+ return {
+ customUrl: "customHeroImageUrl",
+ customOriginalPath: "customOriginalHeroPath",
+ url: "libraryHeroImageUrl",
+ originalPath: "originalHeroPath",
+ } as const;
+}
+
+function isSubscriptionActive(userDetails: UserDetails | null) {
+ const expiresAt = new Date(userDetails?.subscription?.expiresAt ?? 0);
+
+ return expiresAt > new Date();
+}
+
+async function updateGameTitle(game: LibraryGame, title: string) {
+ if (game.shop === "custom") {
+ await globalThis.window.electron.updateCustomGame({
+ shop: game.shop,
+ objectId: game.objectId,
+ title,
+ iconUrl: game.iconUrl || undefined,
+ logoImageUrl: game.logoImageUrl || undefined,
+ libraryHeroImageUrl: game.libraryHeroImageUrl || undefined,
+ originalIconPath: getLocalPath(game.iconUrl) || undefined,
+ originalLogoPath: getLocalPath(game.logoImageUrl) || undefined,
+ originalHeroPath: getLocalPath(game.libraryHeroImageUrl) || undefined,
+ });
+ return;
+ }
+
+ await globalThis.window.electron.updateGameCustomAssets({
+ shop: game.shop,
+ objectId: game.objectId,
+ title,
+ });
+}
+
+async function updateGameAsset(
+ game: LibraryGame,
+ assetType: GameSettingsAssetType,
+ copiedAssetUrl: string | null,
+ sourcePath: string | null
+) {
+ const fields = getAssetField(assetType);
+
+ if (game.shop === "custom") {
+ await globalThis.window.electron.updateCustomGame({
+ shop: game.shop,
+ objectId: game.objectId,
+ title: game.title,
+ iconUrl:
+ fields.url === "iconUrl"
+ ? copiedAssetUrl || undefined
+ : game.iconUrl || undefined,
+ logoImageUrl:
+ fields.url === "logoImageUrl"
+ ? copiedAssetUrl || undefined
+ : game.logoImageUrl || undefined,
+ libraryHeroImageUrl:
+ fields.url === "libraryHeroImageUrl"
+ ? copiedAssetUrl || undefined
+ : game.libraryHeroImageUrl || undefined,
+ [fields.originalPath]: sourcePath || undefined,
+ });
+ return;
+ }
+
+ await globalThis.window.electron.updateGameCustomAssets({
+ shop: game.shop,
+ objectId: game.objectId,
+ title: game.title,
+ [fields.customUrl]: copiedAssetUrl,
+ [fields.customOriginalPath]: sourcePath,
+ });
+}
+
+export function useGameSettingsController({
+ visible,
+ initialGame,
+ onClose,
+ onGameUpdated,
+}: UseGameSettingsControllerProps) {
+ const [game, setGame] = useState(initialGame);
+ const [selectedCategory, setSelectedCategory] =
+ useState("general");
+ const [feedback, setFeedback] = useState(null);
+ const [gameTitle, setGameTitle] = useState(initialGame?.title ?? "");
+ const [launchOptions, setLaunchOptions] = useState(
+ initialGame?.launchOptions ?? ""
+ );
+ const [updatingTitle, setUpdatingTitle] = useState(false);
+ const [busyAction, setBusyAction] = useState(null);
+ const [saveFolderPath, setSaveFolderPath] = useState(null);
+ const [loadingSaveFolder, setLoadingSaveFolder] = useState(false);
+ const [steamShortcutExists, setSteamShortcutExists] = useState(false);
+ const [userPreferences, setUserPreferences] =
+ useState(null);
+ const [userDetails, setUserDetails] = useState(null);
+ const [achievements, setAchievements] = useState([]);
+ const [protonVersions, setProtonVersions] = useState([]);
+ const [selectedProtonPath, setSelectedProtonPath] = useState(
+ initialGame?.protonPath ?? ""
+ );
+ const [defaultWinePrefixPath, setDefaultWinePrefixPath] = useState<
+ string | null
+ >(null);
+ const [gamemodeAvailable, setGamemodeAvailable] = useState(false);
+ const [mangohudAvailable, setMangohudAvailable] = useState(false);
+ const [winetricksAvailable, setWinetricksAvailable] = useState(false);
+ const [autoRunGamemode, setAutoRunGamemode] = useState(false);
+ const [autoRunMangohud, setAutoRunMangohud] = useState(false);
+ const [isDeletingGameFiles, setIsDeletingGameFiles] = useState(false);
+ const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
+
+ const notify = useCallback((type: FeedbackType, message: string) => {
+ setFeedback({ type, message });
+ }, []);
+
+ const refreshGame = useCallback(async () => {
+ if (!initialGame) return null;
+
+ const updatedGame = await globalThis.window.electron.getGameByObjectId(
+ initialGame.shop,
+ initialGame.objectId
+ );
+
+ setGame(updatedGame);
+ onGameUpdated?.(updatedGame);
+
+ return updatedGame;
+ }, [initialGame, onGameUpdated]);
+
+ useEffect(() => {
+ if (!visible) return;
+
+ setGame(initialGame);
+ setSelectedCategory("general");
+ setFeedback(null);
+ setGameTitle(initialGame?.title ?? "");
+ setLaunchOptions(initialGame?.launchOptions ?? "");
+ setSelectedProtonPath(initialGame?.protonPath ?? "");
+ setAutoRunGamemode(initialGame?.autoRunGamemode === true);
+ setAutoRunMangohud(initialGame?.autoRunMangohud === true);
+ }, [initialGame, visible]);
+
+ useEffect(() => {
+ if (!visible || !game) return;
+
+ globalThis.window.electron
+ .getUserPreferences()
+ .then(setUserPreferences)
+ .catch(noop);
+ globalThis.window.electron
+ .getMe()
+ .then(setUserDetails)
+ .catch(() => setUserDetails(null));
+ if (game.shop === "custom") return;
+
+ globalThis.window.electron
+ .getUnlockedAchievements(game.objectId, game.shop)
+ .then(setAchievements)
+ .catch(() => setAchievements([]));
+ globalThis.window.electron
+ .checkSteamShortcut(game.shop, game.objectId)
+ .then(setSteamShortcutExists)
+ .catch(() => setSteamShortcutExists(false));
+ }, [game, visible]);
+
+ useEffect(() => {
+ if (
+ !visible ||
+ !game ||
+ game.shop === "custom" ||
+ globalThis.window.electron.platform !== "win32"
+ ) {
+ setSaveFolderPath(null);
+ return;
+ }
+
+ setLoadingSaveFolder(true);
+ globalThis.window.electron
+ .getGameSaveFolder(game.shop, game.objectId)
+ .then(setSaveFolderPath)
+ .catch(() => setSaveFolderPath(null))
+ .finally(() => setLoadingSaveFolder(false));
+ }, [game, visible]);
+
+ useEffect(() => {
+ if (!visible || globalThis.window.electron.platform !== "linux") {
+ setProtonVersions([]);
+ setDefaultWinePrefixPath(null);
+ setGamemodeAvailable(false);
+ setMangohudAvailable(false);
+ setWinetricksAvailable(false);
+ return;
+ }
+
+ globalThis.window.electron
+ .getInstalledProtonVersions()
+ .then(setProtonVersions)
+ .catch(() => setProtonVersions([]));
+ globalThis.window.electron
+ .getDefaultWinePrefixSelectionPath()
+ .then(setDefaultWinePrefixPath)
+ .catch(() => setDefaultWinePrefixPath(null));
+ globalThis.window.electron
+ .isGamemodeAvailable()
+ .then(setGamemodeAvailable)
+ .catch(() => setGamemodeAvailable(false));
+ globalThis.window.electron
+ .isMangohudAvailable()
+ .then(setMangohudAvailable)
+ .catch(() => setMangohudAvailable(false));
+ globalThis.window.electron
+ .isWinetricksAvailable()
+ .then(setWinetricksAvailable)
+ .catch(() => setWinetricksAvailable(false));
+ }, [visible]);
+
+ const runAction = useCallback(
+ async (actionId: string, action: () => Promise, success?: string) => {
+ setBusyAction(actionId);
+ setFeedback(null);
+
+ try {
+ await action();
+ if (success) notify("success", success);
+ } catch (error) {
+ notify(
+ "error",
+ error instanceof Error ? error.message : "Something went wrong"
+ );
+ } finally {
+ setBusyAction(null);
+ }
+ },
+ [notify]
+ );
+
+ const handleSaveTitle = useCallback(async () => {
+ if (!game) return;
+
+ const trimmedTitle = gameTitle.trim();
+ if (!trimmedTitle) {
+ notify("error", "Title is required");
+ setGameTitle(game.title);
+ return;
+ }
+
+ if (trimmedTitle === game.title) return;
+
+ setUpdatingTitle(true);
+ try {
+ await updateGameTitle(game, trimmedTitle);
+ await refreshGame();
+ notify("success", "Game title updated");
+ } catch (error) {
+ setGameTitle(game.title);
+ notify(
+ "error",
+ error instanceof Error ? error.message : "Could not update title"
+ );
+ } finally {
+ setUpdatingTitle(false);
+ }
+ }, [game, gameTitle, notify, refreshGame]);
+
+ const handleSelectExecutable = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "select-executable",
+ async () => {
+ const defaultPath =
+ userPreferences?.downloadsPath ??
+ (await globalThis.window.electron.getDefaultDownloadsPath());
+ const { filePaths } = await globalThis.window.electron.showOpenDialog({
+ properties: ["openFile"],
+ defaultPath,
+ filters: [
+ {
+ name: "Game executable",
+ extensions: ["exe", "lnk"],
+ },
+ ],
+ });
+
+ const executablePath = filePaths[0];
+ if (!executablePath) return;
+
+ const gameUsingPath =
+ await globalThis.window.electron.verifyExecutablePathInUse(
+ executablePath
+ );
+ if (gameUsingPath) {
+ throw new Error(`Executable already used by ${gameUsingPath.title}`);
+ }
+
+ await globalThis.window.electron.updateExecutablePath(
+ game.shop,
+ game.objectId,
+ executablePath
+ );
+ await refreshGame();
+ },
+ "Executable path updated"
+ );
+ }, [game, refreshGame, runAction, userPreferences?.downloadsPath]);
+
+ const handleClearExecutable = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "clear-executable",
+ async () => {
+ await globalThis.window.electron.updateExecutablePath(
+ game.shop,
+ game.objectId,
+ null
+ );
+ await refreshGame();
+ },
+ "Executable path cleared"
+ );
+ }, [game, refreshGame, runAction]);
+
+ const handleSaveLaunchOptions = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "save-launch-options",
+ async () => {
+ const trimmedValue = launchOptions.trim();
+ await globalThis.window.electron.updateLaunchOptions(
+ game.shop,
+ game.objectId,
+ trimmedValue || null
+ );
+ await refreshGame();
+ },
+ "Launch options updated"
+ );
+ }, [game, launchOptions, refreshGame, runAction]);
+
+ const handleClearLaunchOptions = useCallback(async () => {
+ setLaunchOptions("");
+ if (!game) return;
+
+ await runAction(
+ "clear-launch-options",
+ async () => {
+ await globalThis.window.electron.updateLaunchOptions(
+ game.shop,
+ game.objectId,
+ null
+ );
+ await refreshGame();
+ },
+ "Launch options cleared"
+ );
+ }, [game, refreshGame, runAction]);
+
+ const handleOpenExecutableFolder = useCallback(async () => {
+ if (!game) return;
+
+ await runAction("open-executable", async () => {
+ await globalThis.window.electron.openGameExecutablePath(
+ game.shop,
+ game.objectId
+ );
+ });
+ }, [game, runAction]);
+
+ const handleOpenSaveFolder = useCallback(async () => {
+ if (!game || !saveFolderPath) return;
+
+ await runAction("open-save-folder", async () => {
+ await globalThis.window.electron.openGameSaveFolder(
+ game.shop,
+ game.objectId,
+ saveFolderPath
+ );
+ });
+ }, [game, runAction, saveFolderPath]);
+
+ const handleCreateShortcut = useCallback(
+ async (location: ShortcutLocation) => {
+ if (!game) return;
+
+ await runAction(
+ `create-shortcut-${location}`,
+ async () => {
+ const success = await globalThis.window.electron.createGameShortcut(
+ game.shop,
+ game.objectId,
+ location
+ );
+ if (!success) throw new Error("Could not create shortcut");
+ },
+ "Shortcut created"
+ );
+ },
+ [game, runAction]
+ );
+
+ const handleCreateSteamShortcut = useCallback(
+ async (options: CreateSteamShortcutOptions) => {
+ if (!game) return;
+
+ await runAction(
+ "create-steam-shortcut",
+ async () => {
+ await globalThis.window.electron.createSteamShortcut(
+ game.shop,
+ game.objectId,
+ options
+ );
+ const exists = await globalThis.window.electron.checkSteamShortcut(
+ game.shop,
+ game.objectId
+ );
+ setSteamShortcutExists(exists);
+ await refreshGame();
+ },
+ "Steam shortcut created"
+ );
+ },
+ [game, refreshGame, runAction]
+ );
+
+ const handleDeleteSteamShortcut = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "delete-steam-shortcut",
+ async () => {
+ await globalThis.window.electron.deleteSteamShortcut(
+ game.shop,
+ game.objectId
+ );
+ setSteamShortcutExists(false);
+ await refreshGame();
+ },
+ "Steam shortcut deleted"
+ );
+ }, [game, refreshGame, runAction]);
+
+ const handleUpdateAsset = useCallback(
+ async (assetType: GameSettingsAssetType, sourcePath: string | null) => {
+ if (!game) return;
+
+ await runAction(
+ `update-asset-${assetType}`,
+ async () => {
+ const copiedAssetUrl = sourcePath
+ ? await globalThis.window.electron.copyCustomGameAsset(
+ sourcePath,
+ assetType
+ )
+ : null;
+
+ await updateGameAsset(game, assetType, copiedAssetUrl, sourcePath);
+ await refreshGame();
+ },
+ sourcePath ? "Asset updated" : "Asset removed"
+ );
+ },
+ [game, refreshGame, runAction]
+ );
+
+ const handleChooseAsset = useCallback(
+ async (assetType: GameSettingsAssetType) => {
+ const { filePaths } = await globalThis.window.electron.showOpenDialog({
+ properties: ["openFile"],
+ filters: [
+ {
+ name: "Images",
+ extensions: ["jpg", "jpeg", "png", "webp", "ico"],
+ },
+ ],
+ });
+
+ if (filePaths[0]) {
+ await handleUpdateAsset(assetType, filePaths[0]);
+ }
+ },
+ [handleUpdateAsset]
+ );
+
+ const handleResetAsset = useCallback(
+ async (assetType: GameSettingsAssetType) => {
+ if (game?.shop !== "custom") {
+ await handleUpdateAsset(assetType, null);
+ return;
+ }
+
+ const fields = getAssetField(assetType);
+ const originalPath = getLocalPath(game?.[fields.url]);
+
+ await handleUpdateAsset(assetType, originalPath ?? null);
+ },
+ [game, handleUpdateAsset]
+ );
+
+ const handleChangeWinePrefixPath = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "wine-prefix",
+ async () => {
+ const { filePaths } = await globalThis.window.electron.showOpenDialog({
+ properties: ["openDirectory"],
+ defaultPath: game.winePrefixPath ?? defaultWinePrefixPath ?? "",
+ });
+ if (!filePaths[0]) return;
+
+ await globalThis.window.electron.selectGameWinePrefix(
+ game.shop,
+ game.objectId,
+ filePaths[0]
+ );
+ await refreshGame();
+ },
+ "Wine prefix updated"
+ );
+ }, [defaultWinePrefixPath, game, refreshGame, runAction]);
+
+ const handleClearWinePrefixPath = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "clear-wine-prefix",
+ async () => {
+ await globalThis.window.electron.selectGameWinePrefix(
+ game.shop,
+ game.objectId,
+ null
+ );
+ await refreshGame();
+ },
+ "Wine prefix cleared"
+ );
+ }, [game, refreshGame, runAction]);
+
+ const handleChangeProtonVersion = useCallback(
+ async (protonPath: string) => {
+ if (!game) return;
+
+ setSelectedProtonPath(protonPath);
+ await runAction(
+ "proton-version",
+ async () => {
+ await globalThis.window.electron.selectGameProtonPath(
+ game.shop,
+ game.objectId,
+ protonPath || null
+ );
+ await refreshGame();
+ },
+ "Proton version updated"
+ );
+ },
+ [game, refreshGame, runAction]
+ );
+
+ const handleToggleGamemode = useCallback(async () => {
+ if (!game) return;
+ const nextValue = !autoRunGamemode;
+ setAutoRunGamemode(nextValue);
+
+ await runAction("gamemode", async () => {
+ await globalThis.window.electron.toggleGameGamemode(
+ game.shop,
+ game.objectId,
+ nextValue
+ );
+ await refreshGame();
+ });
+ }, [autoRunGamemode, game, refreshGame, runAction]);
+
+ const handleToggleMangohud = useCallback(async () => {
+ if (!game) return;
+ const nextValue = !autoRunMangohud;
+ setAutoRunMangohud(nextValue);
+
+ await runAction("mangohud", async () => {
+ await globalThis.window.electron.toggleGameMangohud(
+ game.shop,
+ game.objectId,
+ nextValue
+ );
+ await refreshGame();
+ });
+ }, [autoRunMangohud, game, refreshGame, runAction]);
+
+ const handleOpenWinetricks = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "winetricks",
+ async () => {
+ const success = await globalThis.window.electron.openGameWinetricks(
+ game.shop,
+ game.objectId
+ );
+ if (!success) throw new Error("Could not open Winetricks");
+ },
+ "Winetricks opened"
+ );
+ }, [game, runAction]);
+
+ const handleToggleAutomaticCloudSync = useCallback(async () => {
+ if (!game) return;
+ const nextValue = !game.automaticCloudSync;
+
+ await runAction("automatic-cloud-sync", async () => {
+ await globalThis.window.electron.toggleAutomaticCloudSync(
+ game.shop,
+ game.objectId,
+ nextValue
+ );
+ await refreshGame();
+ });
+ }, [game, refreshGame, runAction]);
+
+ const handleOpenDownloadFolder = useCallback(async () => {
+ if (!game) return;
+
+ await runAction("download-folder", async () => {
+ await globalThis.window.electron.openGameInstallerPath(
+ game.shop,
+ game.objectId
+ );
+ });
+ }, [game, runAction]);
+
+ const handleRemoveGameFiles = useCallback(async () => {
+ if (!game) return;
+
+ setIsDeletingGameFiles(true);
+ await runAction(
+ "remove-files",
+ async () => {
+ await globalThis.window.electron.deleteGameFolder(
+ game.shop,
+ game.objectId
+ );
+ await refreshGame();
+ },
+ "Game files removed"
+ );
+ setIsDeletingGameFiles(false);
+ }, [game, refreshGame, runAction]);
+
+ const handleRemoveFromLibrary = useCallback(async () => {
+ if (!game) return;
+
+ await runAction(
+ "remove-library",
+ async () => {
+ if (game.download?.status === "active") {
+ await globalThis.window.electron.cancelGameDownload(
+ game.shop,
+ game.objectId
+ );
+ }
+ await globalThis.window.electron.removeGameFromLibrary(
+ game.shop,
+ game.objectId
+ );
+ onGameUpdated?.(null);
+ onClose();
+ },
+ "Game removed from library"
+ );
+ }, [game, onClose, onGameUpdated, runAction]);
+
+ const handleResetAchievements = useCallback(async () => {
+ if (!game) return;
+
+ setIsDeletingAchievements(true);
+ await runAction(
+ "reset-achievements",
+ async () => {
+ await globalThis.window.electron.resetGameAchievements(
+ game.shop,
+ game.objectId
+ );
+ await refreshGame();
+ setAchievements([]);
+ },
+ "Achievements reset"
+ );
+ setIsDeletingAchievements(false);
+ }, [game, refreshGame, runAction]);
+
+ const handleChangePlaytime = useCallback(
+ async (playtimeInSeconds: number) => {
+ if (!game) return;
+
+ await runAction(
+ "change-playtime",
+ async () => {
+ await globalThis.window.electron.changeGamePlayTime(
+ game.shop,
+ game.objectId,
+ playtimeInSeconds
+ );
+ await refreshGame();
+ },
+ "Playtime updated"
+ );
+ },
+ [game, refreshGame, runAction]
+ );
+
+ const displayedWinePrefixPath = useMemo(() => {
+ if (!game) return null;
+
+ return (
+ game.winePrefixPath ??
+ (defaultWinePrefixPath
+ ? `${defaultWinePrefixPath}/${game.objectId}`
+ : null)
+ );
+ }, [defaultWinePrefixPath, game]);
+
+ const hasAchievements =
+ achievements.some((achievement) => achievement.unlocked) ||
+ (game?.unlockedAchievementCount ?? 0) > 0;
+ const hasActiveSubscription = isSubscriptionActive(userDetails);
+ const globalAutoRunGamemode = userPreferences?.autoRunGamemode === true;
+ const globalAutoRunMangohud = userPreferences?.autoRunMangohud === true;
+ const isGameDownloading = game?.download?.status === "active";
+
+ return {
+ game,
+ selectedCategory,
+ setSelectedCategory,
+ feedback,
+ setFeedback,
+ gameTitle,
+ setGameTitle,
+ launchOptions,
+ setLaunchOptions,
+ updatingTitle,
+ busyAction,
+ saveFolderPath,
+ loadingSaveFolder,
+ steamShortcutExists,
+ userDetails,
+ hasActiveSubscription,
+ hasAchievements,
+ protonVersions,
+ selectedProtonPath,
+ displayedWinePrefixPath,
+ gamemodeAvailable,
+ mangohudAvailable,
+ winetricksAvailable,
+ autoRunGamemode,
+ autoRunMangohud,
+ globalAutoRunGamemode,
+ globalAutoRunMangohud,
+ isGameDownloading,
+ isDeletingGameFiles,
+ isDeletingAchievements,
+ notify,
+ refreshGame,
+ handleSaveTitle,
+ handleSelectExecutable,
+ handleClearExecutable,
+ handleSaveLaunchOptions,
+ handleClearLaunchOptions,
+ handleOpenExecutableFolder,
+ handleOpenSaveFolder,
+ handleCreateShortcut,
+ handleCreateSteamShortcut,
+ handleDeleteSteamShortcut,
+ handleChooseAsset,
+ handleUpdateAsset,
+ handleResetAsset,
+ handleChangeWinePrefixPath,
+ handleClearWinePrefixPath,
+ handleChangeProtonVersion,
+ handleToggleGamemode,
+ handleToggleMangohud,
+ handleOpenWinetricks,
+ handleToggleAutomaticCloudSync,
+ handleOpenDownloadFolder,
+ handleRemoveGameFiles,
+ handleRemoveFromLibrary,
+ handleResetAchievements,
+ handleChangePlaytime,
+ };
+}
+
+export type GameSettingsController = ReturnType<
+ typeof useGameSettingsController
+>;
+
+function noop() {
+ return undefined;
+}
diff --git a/src/big-picture/src/components/pages/library/use-library-favorite.ts b/src/big-picture/src/components/pages/library/use-library-favorite.ts
new file mode 100644
index 000000000..f61a023f4
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/use-library-favorite.ts
@@ -0,0 +1,34 @@
+import type { LibraryGame } from "@types";
+import { useCallback, useState } from "react";
+import { IS_DESKTOP } from "../../../constants";
+
+export function useLibraryFavorite(updateLibrary: () => Promise) {
+ const [favoriteLoadingGameId, setFavoriteLoadingGameId] = useState<
+ string | null
+ >(null);
+
+ const toggleFavorite = useCallback(
+ async (game: LibraryGame) => {
+ if (!IS_DESKTOP) return;
+
+ setFavoriteLoadingGameId(game.id);
+
+ try {
+ const toggle = game.favorite
+ ? globalThis.window.electron.removeGameFromFavorites
+ : globalThis.window.electron.addGameToFavorites;
+
+ await toggle(game.shop, game.objectId);
+ await updateLibrary();
+ } finally {
+ setFavoriteLoadingGameId(null);
+ }
+ },
+ [updateLibrary]
+ );
+
+ return {
+ favoriteLoadingGameId,
+ toggleFavorite,
+ };
+}
diff --git a/src/big-picture/src/components/pages/library/use-library-launch-game.ts b/src/big-picture/src/components/pages/library/use-library-launch-game.ts
new file mode 100644
index 000000000..2aa9e1e76
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/use-library-launch-game.ts
@@ -0,0 +1,26 @@
+import type { LibraryGame } from "@types";
+import { useCallback } from "react";
+import { IS_DESKTOP } from "../../../constants";
+
+export function useLibraryLaunchGame(
+ onMissingExecutable: (game: LibraryGame) => void
+) {
+ return useCallback(
+ async (game: LibraryGame) => {
+ if (!IS_DESKTOP) return;
+
+ if (!game.executablePath) {
+ onMissingExecutable(game);
+ return;
+ }
+
+ await globalThis.window.electron.openGame(
+ game.shop,
+ game.objectId,
+ game.executablePath,
+ game.launchOptions
+ );
+ },
+ [onMissingExecutable]
+ );
+}
diff --git a/src/big-picture/src/components/pages/library/use-library-page-data.ts b/src/big-picture/src/components/pages/library/use-library-page-data.ts
new file mode 100644
index 000000000..4e8fd7d51
--- /dev/null
+++ b/src/big-picture/src/components/pages/library/use-library-page-data.ts
@@ -0,0 +1,48 @@
+import type { LibraryGame } from "@types";
+import { useDeferredValue, useMemo } from "react";
+import { getFirstLibraryFocusGridItemId } from "./navigation";
+import {
+ filterLibraryByTab,
+ getLastPlayedGames,
+ getLibraryFilterCounts,
+ matchesSearchQuery,
+ sortByLastPlayed,
+ type LibraryFilterTab,
+} from "./library-data";
+
+export function useLibraryPageData(
+ library: LibraryGame[],
+ selectedTab: LibraryFilterTab,
+ search: string
+) {
+ const deferredSearch = useDeferredValue(search);
+
+ const sortedLibrary = useMemo(() => {
+ return [...library].sort(sortByLastPlayed);
+ }, [library]);
+
+ const filteredLibrary = useMemo(() => {
+ return filterLibraryByTab(sortedLibrary, selectedTab).filter((game) =>
+ matchesSearchQuery(game, deferredSearch)
+ );
+ }, [deferredSearch, selectedTab, sortedLibrary]);
+
+ const filterCounts = useMemo(() => {
+ return getLibraryFilterCounts(library);
+ }, [library]);
+
+ const lastPlayedGames = useMemo(() => {
+ return getLastPlayedGames(sortedLibrary);
+ }, [sortedLibrary]);
+
+ const firstGridItemId = useMemo(() => {
+ return getFirstLibraryFocusGridItemId(filteredLibrary[0]?.id);
+ }, [filteredLibrary]);
+
+ return {
+ filteredLibrary,
+ filterCounts,
+ firstGridItemId,
+ lastPlayedGames,
+ };
+}
diff --git a/src/big-picture/src/components/providers/index.ts b/src/big-picture/src/components/providers/index.ts
new file mode 100644
index 000000000..d550729eb
--- /dev/null
+++ b/src/big-picture/src/components/providers/index.ts
@@ -0,0 +1,3 @@
+export * from "./navigation-input.provider";
+export * from "./navigation-auto-scroll.provider";
+export * from "./navigation-state-bridge.provider";
diff --git a/src/big-picture/src/components/providers/navigation-auto-scroll.provider.tsx b/src/big-picture/src/components/providers/navigation-auto-scroll.provider.tsx
new file mode 100644
index 000000000..f2ab1481b
--- /dev/null
+++ b/src/big-picture/src/components/providers/navigation-auto-scroll.provider.tsx
@@ -0,0 +1,32 @@
+import { scrollNavigationIntoView } from "../../helpers";
+import { useNavigationSnapshot } from "../../stores";
+import { useEffect, useRef } from "react";
+
+export function NavigationAutoScrollBridge() {
+ const { currentFocusId, nodes, regions } = useNavigationSnapshot();
+ const previousFocusIdRef = useRef(null);
+
+ useEffect(() => {
+ if (!currentFocusId) {
+ previousFocusIdRef.current = null;
+ return;
+ }
+
+ const animationFrameId = globalThis.requestAnimationFrame(() => {
+ scrollNavigationIntoView({
+ currentFocusId,
+ previousFocusId: previousFocusIdRef.current,
+ nodes,
+ regions,
+ });
+
+ previousFocusIdRef.current = currentFocusId;
+ });
+
+ return () => {
+ globalThis.cancelAnimationFrame(animationFrameId);
+ };
+ }, [currentFocusId, nodes, regions]);
+
+ return null;
+}
diff --git a/src/big-picture/src/components/providers/navigation-input.provider.tsx b/src/big-picture/src/components/providers/navigation-input.provider.tsx
new file mode 100644
index 000000000..12e777446
--- /dev/null
+++ b/src/big-picture/src/components/providers/navigation-input.provider.tsx
@@ -0,0 +1,421 @@
+import { useGamepad, useNavigationActions } from "../../hooks";
+import { useNavigationStore } from "../../stores";
+import { GamepadAxisDirection, GamepadButtonType } from "../../types";
+import { type ReactNode, useCallback, useEffect, useRef } from "react";
+
+interface NavigationInputProviderProps {
+ children: ReactNode;
+}
+
+type HoldManagedButton = "a" | "b" | "x" | "y" | "start" | "select";
+type HoldSession = {
+ isPressed: boolean;
+ holdTriggered: boolean;
+ timerId: number | null;
+};
+
+const HOLD_THRESHOLD_MS = 400;
+
+function createInitialHoldSessions(): Record {
+ return {
+ a: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ b: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ x: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ y: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ start: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ select: {
+ isPressed: false,
+ holdTriggered: false,
+ timerId: null,
+ },
+ };
+}
+
+function isEditableElement(element: EventTarget | null) {
+ if (!(element instanceof HTMLElement)) {
+ return false;
+ }
+
+ const tagName = element.tagName.toLowerCase();
+
+ return (
+ tagName === "input" ||
+ tagName === "textarea" ||
+ tagName === "select" ||
+ element.isContentEditable
+ );
+}
+
+function shouldIgnoreKeyboardNavigation(event: KeyboardEvent) {
+ return (
+ isEditableElement(event.target) || isEditableElement(document.activeElement)
+ );
+}
+
+export function NavigationInputProvider({
+ children,
+}: Readonly) {
+ const {
+ moveFocus,
+ triggerPrimary,
+ triggerSecondary,
+ triggerItemPress,
+ triggerItemHold,
+ triggerScreenPress,
+ triggerScreenHold,
+ canResolveFocusedPrimaryAction,
+ canResolveFocusedSecondaryAction,
+ hasFocusedItemPressAction,
+ hasFocusedItemHoldAction,
+ hasScreenPressAction,
+ hasScreenHoldAction,
+ } = useNavigationActions();
+ const {
+ onButtonPressed,
+ onStickMove,
+ isButtonPressed,
+ isActiveGamepadEvent,
+ activeGamepadIndex,
+ } = useGamepad();
+ const currentFocusId = useNavigationStore((state) => state.currentFocusId);
+ const holdSessionsRef = useRef(createInitialHoldSessions());
+ const warnedConflictsRef = useRef(new Set());
+
+ const warnActionConflict = useCallback(
+ (
+ mode: "press" | "hold",
+ button: HoldManagedButton | "a" | "b" | "x" | "y"
+ ) => {
+ if (process.env.NODE_ENV === "production" || !currentFocusId) {
+ return;
+ }
+
+ const warningKey = `${currentFocusId}:${mode}:${button}`;
+
+ if (warnedConflictsRef.current.has(warningKey)) {
+ return;
+ }
+
+ console.warn(
+ `Navigation input conflict detected for ${mode}.${button}. The focused item "${currentFocusId}" and an active screen action both handle this input. The focused item will take priority. Remove one handler or move the screen action to a narrower scope to avoid ambiguous behavior.`
+ );
+ warnedConflictsRef.current.add(warningKey);
+ },
+ [currentFocusId]
+ );
+
+ const resetHoldSessions = useCallback(() => {
+ const holdSessions = holdSessionsRef.current;
+
+ (Object.keys(holdSessions) as HoldManagedButton[]).forEach((button) => {
+ const session = holdSessions[button];
+
+ if (session.timerId !== null) {
+ globalThis.window.clearTimeout(session.timerId);
+ }
+
+ session.isPressed = false;
+ session.holdTriggered = false;
+ session.timerId = null;
+ });
+ }, []);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (shouldIgnoreKeyboardNavigation(event)) {
+ return;
+ }
+
+ const key = event.key.toLowerCase();
+
+ if (event.key === "ArrowUp" || key === "w") {
+ event.preventDefault();
+ moveFocus("up");
+ }
+
+ if (event.key === "ArrowLeft" || key === "a") {
+ event.preventDefault();
+ moveFocus("left");
+ }
+
+ if (event.key === "ArrowDown" || key === "s") {
+ event.preventDefault();
+ moveFocus("down");
+ }
+
+ if (event.key === "ArrowRight" || key === "d") {
+ event.preventDefault();
+ moveFocus("right");
+ }
+
+ const isPrimaryKey =
+ event.key === "Enter" || event.key === " " || key === "spacebar";
+
+ if (isPrimaryKey && !event.repeat) {
+ event.preventDefault();
+ if (!triggerPrimary(event)) {
+ triggerScreenPress("a", event);
+ }
+ }
+
+ if (event.key === "Escape" && !event.repeat) {
+ event.preventDefault();
+ triggerScreenPress("b", event);
+ }
+ };
+
+ globalThis.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ globalThis.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [moveFocus, triggerPrimary, triggerScreenPress]);
+
+ useEffect(() => {
+ const unsubDpadUp = onButtonPressed(GamepadButtonType.DPAD_UP, (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("up");
+ });
+
+ const unsubDpadLeft = onButtonPressed(
+ GamepadButtonType.DPAD_LEFT,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("left");
+ }
+ );
+
+ const unsubDpadDown = onButtonPressed(
+ GamepadButtonType.DPAD_DOWN,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("down");
+ }
+ );
+
+ const unsubDpadRight = onButtonPressed(
+ GamepadButtonType.DPAD_RIGHT,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("right");
+ }
+ );
+
+ const unsubStickUp = onStickMove(
+ "left",
+ GamepadAxisDirection.UP,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("up");
+ }
+ );
+
+ const unsubStickLeft = onStickMove(
+ "left",
+ GamepadAxisDirection.LEFT,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("left");
+ }
+ );
+
+ const unsubStickDown = onStickMove(
+ "left",
+ GamepadAxisDirection.DOWN,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("down");
+ }
+ );
+
+ const unsubStickRight = onStickMove(
+ "left",
+ GamepadAxisDirection.RIGHT,
+ (event) => {
+ if (!isActiveGamepadEvent(event)) return;
+
+ moveFocus("right");
+ }
+ );
+
+ return () => {
+ unsubDpadUp();
+ unsubDpadDown();
+ unsubDpadLeft();
+ unsubDpadRight();
+ unsubStickUp();
+ unsubStickDown();
+ unsubStickLeft();
+ unsubStickRight();
+ };
+ }, [isActiveGamepadEvent, moveFocus, onButtonPressed, onStickMove]);
+
+ useEffect(() => {
+ resetHoldSessions();
+ }, [activeGamepadIndex, resetHoldSessions]);
+
+ const isAPressed = isButtonPressed(GamepadButtonType.BUTTON_A);
+ const isBPressed = isButtonPressed(GamepadButtonType.BUTTON_B);
+ const isXPressed = isButtonPressed(GamepadButtonType.BUTTON_X);
+ const isYPressed = isButtonPressed(GamepadButtonType.BUTTON_Y);
+ const isStartPressed = isButtonPressed(GamepadButtonType.START);
+ const isSelectPressed = isButtonPressed(GamepadButtonType.BACK);
+
+ useEffect(() => {
+ const holdSessions = holdSessionsRef.current;
+ const buttonStates: Record = {
+ a: isAPressed,
+ b: isBPressed,
+ x: isXPressed,
+ y: isYPressed,
+ start: isStartPressed,
+ select: isSelectPressed,
+ };
+
+ const dispatchHold = (button: HoldManagedButton) => {
+ if (button === "y") {
+ if (canResolveFocusedSecondaryAction() && hasScreenHoldAction("y")) {
+ warnActionConflict("hold", "y");
+ }
+
+ return triggerSecondary() || triggerScreenHold("y");
+ }
+
+ if (button === "start" || button === "select") {
+ return triggerScreenHold(button);
+ }
+
+ if (hasFocusedItemHoldAction(button) && hasScreenHoldAction(button)) {
+ warnActionConflict("hold", button);
+ }
+
+ return triggerItemHold(button) || triggerScreenHold(button);
+ };
+
+ const dispatchPress = (button: HoldManagedButton) => {
+ if (button === "a") {
+ if (canResolveFocusedPrimaryAction() && hasScreenPressAction("a")) {
+ warnActionConflict("press", "a");
+ }
+
+ return triggerPrimary() || triggerScreenPress("a");
+ }
+
+ if (button === "b") {
+ return triggerScreenPress("b");
+ }
+
+ if (button === "x" || button === "y") {
+ if (hasFocusedItemPressAction(button) && hasScreenPressAction(button)) {
+ warnActionConflict("press", button);
+ }
+
+ return triggerItemPress(button) || triggerScreenPress(button);
+ }
+
+ if (button === "start" || button === "select") {
+ return triggerScreenPress(button);
+ }
+
+ return false;
+ };
+
+ (Object.keys(buttonStates) as HoldManagedButton[]).forEach((button) => {
+ const isPressed = buttonStates[button];
+ const session = holdSessions[button];
+
+ if (isPressed && !session.isPressed) {
+ session.isPressed = true;
+ session.holdTriggered = false;
+ session.timerId = globalThis.window.setTimeout(() => {
+ const wasHandled = dispatchHold(button);
+
+ if (wasHandled) {
+ session.holdTriggered = true;
+ }
+
+ session.timerId = null;
+ }, HOLD_THRESHOLD_MS);
+
+ return;
+ }
+
+ if (!isPressed && session.isPressed) {
+ if (session.timerId !== null) {
+ globalThis.window.clearTimeout(session.timerId);
+ }
+
+ if (!session.holdTriggered) {
+ dispatchPress(button);
+ }
+
+ session.isPressed = false;
+ session.holdTriggered = false;
+ session.timerId = null;
+ }
+ });
+ }, [
+ isAPressed,
+ isBPressed,
+ isXPressed,
+ isYPressed,
+ isStartPressed,
+ isSelectPressed,
+ triggerItemHold,
+ triggerItemPress,
+ triggerPrimary,
+ triggerScreenHold,
+ triggerScreenPress,
+ triggerSecondary,
+ canResolveFocusedPrimaryAction,
+ canResolveFocusedSecondaryAction,
+ hasFocusedItemPressAction,
+ hasFocusedItemHoldAction,
+ hasScreenPressAction,
+ hasScreenHoldAction,
+ warnActionConflict,
+ ]);
+
+ useEffect(() => {
+ resetHoldSessions();
+ }, [currentFocusId, resetHoldSessions]);
+
+ useEffect(() => {
+ warnedConflictsRef.current.clear();
+ }, [currentFocusId]);
+
+ useEffect(() => {
+ return () => {
+ resetHoldSessions();
+ };
+ }, [resetHoldSessions]);
+
+ return children;
+}
diff --git a/src/big-picture/src/components/providers/navigation-state-bridge.provider.tsx b/src/big-picture/src/components/providers/navigation-state-bridge.provider.tsx
new file mode 100644
index 000000000..c3a8b9bf1
--- /dev/null
+++ b/src/big-picture/src/components/providers/navigation-state-bridge.provider.tsx
@@ -0,0 +1,17 @@
+import { NavigationService } from "../../services";
+import { useNavigationStore } from "../../stores";
+import { useEffect } from "react";
+
+const navigation = NavigationService.getInstance();
+
+export function NavigationStateBridge() {
+ useEffect(() => {
+ useNavigationStore.getState().syncFromService(navigation);
+
+ return navigation.subscribe(() => {
+ useNavigationStore.getState().syncFromService(navigation);
+ });
+ }, []);
+
+ return null;
+}
diff --git a/src/big-picture/src/constants.ts b/src/big-picture/src/constants.ts
new file mode 100644
index 000000000..4860675f7
--- /dev/null
+++ b/src/big-picture/src/constants.ts
@@ -0,0 +1,6 @@
+export const IS_BROWSER =
+ globalThis.self !== undefined &&
+ globalThis.Window !== undefined &&
+ globalThis.self instanceof globalThis.Window;
+
+export const IS_DESKTOP = IS_BROWSER && !!globalThis.window.electron;
diff --git a/src/big-picture/src/helpers/color.ts b/src/big-picture/src/helpers/color.ts
new file mode 100644
index 000000000..ba85ea820
--- /dev/null
+++ b/src/big-picture/src/helpers/color.ts
@@ -0,0 +1,372 @@
+const DEFAULT_SAMPLE_SIZE = 48;
+const DEFAULT_ALPHA_THRESHOLD = 128;
+const DEFAULT_QUANTIZATION_STEP = 24;
+
+interface RGBColor {
+ r: number;
+ g: number;
+ b: number;
+}
+
+interface DominantColorOptions {
+ sampleSize?: number;
+ alphaThreshold?: number;
+ quantizationStep?: number;
+}
+
+interface ColorBucket {
+ count: number;
+ r: number;
+ g: number;
+ b: number;
+}
+
+interface HSLColor {
+ h: number;
+ s: number;
+ l: number;
+}
+
+interface ColorMetrics {
+ saturation: number;
+ lightness: number;
+ chroma: number;
+ value: number;
+}
+
+const LIGHT_TEXT_COLOR = "var(--primary)";
+const DARK_TEXT_COLOR = "var(--background)";
+
+const clampChannel = (value: number) => {
+ return Math.max(0, Math.min(255, Math.round(value)));
+};
+
+const toHex = (value: number) => {
+ return clampChannel(value).toString(16).padStart(2, "0");
+};
+
+const rgbToHex = ({ r, g, b }: RGBColor) => {
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+};
+
+const rgbToHsl = ({ r, g, b }: RGBColor): HSLColor => {
+ const normalizedRed = r / 255;
+ const normalizedGreen = g / 255;
+ const normalizedBlue = b / 255;
+ const maxChannel = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
+ const minChannel = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
+ const delta = maxChannel - minChannel;
+ const lightness = (maxChannel + minChannel) / 2;
+
+ if (delta === 0) {
+ return { h: 0, s: 0, l: lightness };
+ }
+
+ const saturation = delta / (1 - Math.abs(2 * lightness - 1));
+
+ let hue = 0;
+
+ switch (maxChannel) {
+ case normalizedRed:
+ hue = ((normalizedGreen - normalizedBlue) / delta) % 6;
+ break;
+ case normalizedGreen:
+ hue = (normalizedBlue - normalizedRed) / delta + 2;
+ break;
+ default:
+ hue = (normalizedRed - normalizedGreen) / delta + 4;
+ break;
+ }
+
+ return {
+ h: (hue * 60 + 360) % 360,
+ s: saturation,
+ l: lightness,
+ };
+};
+
+const hslToRgb = ({ h, s, l }: HSLColor): RGBColor => {
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
+ const hueSegment = h / 60;
+ const x = chroma * (1 - Math.abs((hueSegment % 2) - 1));
+
+ let red = 0;
+ let green = 0;
+ let blue = 0;
+
+ if (hueSegment >= 0 && hueSegment < 1) {
+ red = chroma;
+ green = x;
+ } else if (hueSegment < 2) {
+ red = x;
+ green = chroma;
+ } else if (hueSegment < 3) {
+ green = chroma;
+ blue = x;
+ } else if (hueSegment < 4) {
+ green = x;
+ blue = chroma;
+ } else if (hueSegment < 5) {
+ red = x;
+ blue = chroma;
+ } else {
+ red = chroma;
+ blue = x;
+ }
+
+ const match = l - chroma / 2;
+
+ return {
+ r: (red + match) * 255,
+ g: (green + match) * 255,
+ b: (blue + match) * 255,
+ };
+};
+
+const getColorMetrics = (color: RGBColor): ColorMetrics => {
+ const hsl = rgbToHsl(color);
+ const maxChannel = Math.max(color.r, color.g, color.b) / 255;
+ const minChannel = Math.min(color.r, color.g, color.b) / 255;
+
+ return {
+ saturation: hsl.s,
+ lightness: hsl.l,
+ chroma: (maxChannel - minChannel) * 255,
+ value: maxChannel,
+ };
+};
+
+const boostAccentColor = (color: RGBColor): RGBColor => {
+ const hsl = rgbToHsl(color);
+
+ return hslToRgb({
+ h: hsl.h,
+ s: Math.min(1, Math.max(hsl.s, 0.68)),
+ l: Math.max(0.42, Math.min(0.62, hsl.l)),
+ });
+};
+
+const quantizeChannel = (value: number, step: number) => {
+ return Math.min(255, Math.round(value / step) * step);
+};
+
+const normalizeHexColor = (value: string) => {
+ const normalizedValue = value.trim().replace(/^#/, "");
+
+ if (/^[\da-f]{3}$/i.test(normalizedValue)) {
+ return normalizedValue
+ .split("")
+ .map((char) => `${char}${char}`)
+ .join("");
+ }
+
+ if (/^[\da-f]{6}$/i.test(normalizedValue)) {
+ return normalizedValue;
+ }
+
+ return null;
+};
+
+const parseHexColor = (value: string): RGBColor | null => {
+ const normalizedValue = normalizeHexColor(value);
+
+ if (!normalizedValue) return null;
+
+ return {
+ r: Number.parseInt(normalizedValue.slice(0, 2), 16),
+ g: Number.parseInt(normalizedValue.slice(2, 4), 16),
+ b: Number.parseInt(normalizedValue.slice(4, 6), 16),
+ };
+};
+
+const toRelativeLuminance = (channel: number) => {
+ const normalizedChannel = channel / 255;
+
+ if (normalizedChannel <= 0.03928) {
+ return normalizedChannel / 12.92;
+ }
+
+ return ((normalizedChannel + 0.055) / 1.055) ** 2.4;
+};
+
+const getContrastRatio = (foreground: RGBColor, background: RGBColor) => {
+ const foregroundLuminance =
+ 0.2126 * toRelativeLuminance(foreground.r) +
+ 0.7152 * toRelativeLuminance(foreground.g) +
+ 0.0722 * toRelativeLuminance(foreground.b);
+ const backgroundLuminance =
+ 0.2126 * toRelativeLuminance(background.r) +
+ 0.7152 * toRelativeLuminance(background.g) +
+ 0.0722 * toRelativeLuminance(background.b);
+ const lighterLuminance = Math.max(foregroundLuminance, backgroundLuminance);
+ const darkerLuminance = Math.min(foregroundLuminance, backgroundLuminance);
+
+ return (lighterLuminance + 0.05) / (darkerLuminance + 0.05);
+};
+
+export function getContrastTextColor(
+ backgroundColor: string | null | undefined
+) {
+ if (!backgroundColor) return DARK_TEXT_COLOR;
+
+ const background = parseHexColor(backgroundColor);
+
+ if (!background) return DARK_TEXT_COLOR;
+
+ const lightTextContrast = getContrastRatio(
+ { r: 255, g: 255, b: 255 },
+ background
+ );
+ const darkTextContrast = getContrastRatio({ r: 8, g: 8, b: 8 }, background);
+
+ return lightTextContrast >= darkTextContrast
+ ? LIGHT_TEXT_COLOR
+ : DARK_TEXT_COLOR;
+}
+
+const loadImage = (src: string) => {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+
+ image.crossOrigin = "anonymous";
+ image.decoding = "async";
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error(`Failed to load image: ${src}`));
+ image.src = src;
+ });
+};
+
+export async function getDominantColorFromImage(
+ imageUrl: string | null | undefined,
+ options: DominantColorOptions = {}
+) {
+ if (!imageUrl) return null;
+
+ const {
+ sampleSize = DEFAULT_SAMPLE_SIZE,
+ alphaThreshold = DEFAULT_ALPHA_THRESHOLD,
+ quantizationStep = DEFAULT_QUANTIZATION_STEP,
+ } = options;
+
+ try {
+ const image = await loadImage(imageUrl);
+ const canvas = globalThis.document.createElement("canvas");
+ const context = canvas.getContext("2d", { willReadFrequently: true });
+
+ if (!context) return null;
+
+ canvas.width = sampleSize;
+ canvas.height = sampleSize;
+ context.drawImage(image, 0, 0, sampleSize, sampleSize);
+
+ const { data } = context.getImageData(0, 0, sampleSize, sampleSize);
+ const buckets = new Map();
+
+ for (let index = 0; index < data.length; index += 4) {
+ const alpha = data[index + 3];
+ if (alpha < alphaThreshold) continue;
+
+ const r = data[index];
+ const g = data[index + 1];
+ const b = data[index + 2];
+
+ const key = [
+ quantizeChannel(r, quantizationStep),
+ quantizeChannel(g, quantizationStep),
+ quantizeChannel(b, quantizationStep),
+ ].join("-");
+
+ const bucket = buckets.get(key);
+
+ if (bucket) {
+ bucket.count += 1;
+ bucket.r += r;
+ bucket.g += g;
+ bucket.b += b;
+ continue;
+ }
+
+ buckets.set(key, {
+ count: 1,
+ r,
+ g,
+ b,
+ });
+ }
+
+ const bucketEntries = Array.from(buckets.values()).map((bucket) => {
+ const color = {
+ r: bucket.r / bucket.count,
+ g: bucket.g / bucket.count,
+ b: bucket.b / bucket.count,
+ };
+ const metrics = getColorMetrics(color);
+
+ return {
+ bucket,
+ color,
+ metrics,
+ population: bucket.count / (sampleSize * sampleSize),
+ };
+ });
+
+ const sortedBucketEntries = bucketEntries.toSorted((a, b) => {
+ return b.bucket.count - a.bucket.count;
+ });
+ const dominantBucket = sortedBucketEntries[0];
+
+ if (!dominantBucket) return null;
+
+ const accentCandidates = sortedBucketEntries.filter((entry) => {
+ return (
+ entry.population >= 0.015 &&
+ entry.metrics.saturation >= 0.22 &&
+ entry.metrics.chroma >= 32 &&
+ entry.metrics.lightness >= 0.12 &&
+ entry.metrics.lightness <= 0.82 &&
+ entry.metrics.value >= 0.18
+ );
+ });
+
+ const accentSafeCandidates = sortedBucketEntries.filter((entry) => {
+ return (
+ entry.population >= 0.008 &&
+ entry.metrics.saturation >= 0.12 &&
+ entry.metrics.chroma >= 18 &&
+ entry.metrics.lightness >= 0.08 &&
+ entry.metrics.lightness <= 0.88
+ );
+ });
+
+ const sortByVibrancy = (candidates: typeof bucketEntries) => {
+ return [...candidates].sort((a, b) => {
+ const aLightnessPenalty = Math.abs(a.metrics.lightness - 0.52);
+ const bLightnessPenalty = Math.abs(b.metrics.lightness - 0.52);
+ const aScore =
+ a.metrics.saturation * 4.2 +
+ (a.metrics.chroma / 255) * 3.4 +
+ a.population * 1.8 -
+ aLightnessPenalty * 1.4;
+ const bScore =
+ b.metrics.saturation * 4.2 +
+ (b.metrics.chroma / 255) * 3.4 +
+ b.population * 1.8 -
+ bLightnessPenalty * 1.4;
+
+ if (bScore !== aScore) return bScore - aScore;
+ if (b.population !== a.population) return b.population - a.population;
+
+ return aLightnessPenalty - bLightnessPenalty;
+ });
+ };
+
+ const selectedEntry =
+ sortByVibrancy(accentCandidates)[0] ??
+ sortByVibrancy(accentSafeCandidates)[0] ??
+ dominantBucket;
+
+ return rgbToHex(boostAccentColor(selectedEntry.color));
+ } catch {
+ return null;
+ }
+}
diff --git a/src/big-picture/src/helpers/date.ts b/src/big-picture/src/helpers/date.ts
new file mode 100644
index 000000000..14ac756d3
--- /dev/null
+++ b/src/big-picture/src/helpers/date.ts
@@ -0,0 +1,95 @@
+import { isYesterday, parseISO } from "date-fns";
+
+const UTC_SUFFIX = "Z";
+const TIMEZONE_OFFSET_REGEX = /(?:Z|[+-]\d{2}:\d{2})$/i;
+const ISO_WITHOUT_TIMEZONE_REGEX = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+)?$/;
+
+export function formatPlayedTime(
+ valueInMilliseconds: number | null | undefined,
+ options?: {
+ zeroFallback?: string;
+ }
+) {
+ const safeValue = Math.max(0, valueInMilliseconds ?? 0);
+
+ if (safeValue === 0 && options?.zeroFallback) {
+ return options.zeroFallback;
+ }
+
+ const totalSeconds = Math.floor(safeValue / 1000);
+
+ if (totalSeconds < 60) {
+ const seconds = Math.max(1, totalSeconds);
+ return `${seconds} ${seconds === 1 ? "second" : "seconds"} played`;
+ }
+
+ const totalMinutes = Math.floor(totalSeconds / 60);
+ if (totalMinutes < 60) {
+ return `${totalMinutes} ${totalMinutes === 1 ? "minute" : "minutes"} played`;
+ }
+
+ const totalHours = Math.floor(totalMinutes / 60);
+ return `${totalHours} ${totalHours === 1 ? "hour" : "hours"} played`;
+}
+
+export function formatRelativeDate(
+ value: Date | string | number | null | undefined,
+ options?: {
+ locale?: string;
+ fallback?: string;
+ now?: Date | number;
+ }
+) {
+ if (value == null) return options?.fallback ?? "";
+
+ let date: Date;
+ if (typeof value === "string") {
+ let isoString = value;
+ if (
+ ISO_WITHOUT_TIMEZONE_REGEX.test(value) &&
+ !TIMEZONE_OFFSET_REGEX.test(value)
+ ) {
+ isoString = `${value}${UTC_SUFFIX}`;
+ }
+ date = parseISO(isoString);
+ } else {
+ date = new Date(value);
+ }
+
+ const now =
+ options?.now instanceof Date
+ ? options.now
+ : new Date(options?.now ?? Date.now());
+
+ if (Number.isNaN(date.getTime()) || Number.isNaN(now.getTime())) {
+ return options?.fallback ?? "";
+ }
+
+ const diffMs = Math.max(0, now.getTime() - date.getTime());
+ const diffSec = Math.floor(diffMs / 1000);
+
+ if (diffSec < 60) return "just now";
+
+ const rtf = new Intl.RelativeTimeFormat(options?.locale ?? "en", {
+ numeric: "always",
+ });
+
+ const minutes = Math.floor(diffSec / 60);
+ if (minutes < 60) return rtf.format(-minutes, "minute");
+
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return rtf.format(-hours, "hour");
+
+ if (isYesterday(date)) return "yesterday";
+
+ const days = Math.floor(hours / 24);
+ if (days < 30) return rtf.format(-days, "day");
+
+ const months = Math.floor(days / 30);
+ if (months < 12) return rtf.format(-Math.max(months, 1), "month");
+
+ const years = Math.floor(days / 365);
+ if (years === 1) return "last year";
+
+ return rtf.format(-years, "year");
+}
diff --git a/src/big-picture/src/helpers/focus-auto-scroll.ts b/src/big-picture/src/helpers/focus-auto-scroll.ts
new file mode 100644
index 000000000..ea97c3fde
--- /dev/null
+++ b/src/big-picture/src/helpers/focus-auto-scroll.ts
@@ -0,0 +1,403 @@
+import type { FocusAutoScrollMode, FocusNode, FocusRegion } from "../services";
+
+const SAFE_SCROLL_MARGIN = 96;
+const ROW_TOLERANCE_PX = 24;
+const SCROLL_ANIMATION_DURATION = 120;
+
+const scrollAnimationFrames = new WeakMap();
+
+type ScrollBehaviorMode = "keep-visible" | "prefer-center";
+
+interface RowItem {
+ id: string;
+ rect: DOMRect;
+}
+
+interface ResolvedScrollTarget {
+ container: HTMLElement;
+ rect: DOMRect;
+ key: string;
+}
+
+function easeOutCubic(progress: number): number {
+ return 1 - Math.pow(1 - progress, 3);
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+function isScrollableElement(element: HTMLElement): boolean {
+ const style = globalThis.getComputedStyle(element);
+ const overflow = `${style.overflow} ${style.overflowX} ${style.overflowY}`;
+ const canScroll = /(auto|scroll|overlay)/.test(overflow);
+
+ return (
+ canScroll &&
+ (element.scrollHeight > element.clientHeight ||
+ element.scrollWidth > element.clientWidth)
+ );
+}
+
+function getScrollContainer(element: HTMLElement): HTMLElement {
+ let current = element.parentElement;
+
+ while (current && current !== document.body) {
+ if (isScrollableElement(current)) {
+ return current;
+ }
+
+ current = current.parentElement;
+ }
+
+ return (document.scrollingElement ?? document.documentElement) as HTMLElement;
+}
+
+function getContainerRect(container: HTMLElement): DOMRect {
+ if (container === document.scrollingElement) {
+ return new DOMRect(0, 0, globalThis.innerWidth, globalThis.innerHeight);
+ }
+
+ return container.getBoundingClientRect();
+}
+
+function getSafeMargin(containerRect: DOMRect): { x: number; y: number } {
+ return {
+ x: Math.min(SAFE_SCROLL_MARGIN, Math.max(0, containerRect.width / 2 - 1)),
+ y: Math.min(SAFE_SCROLL_MARGIN, Math.max(0, containerRect.height / 2 - 1)),
+ };
+}
+
+function getRectCenter(rect: DOMRect) {
+ return {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ };
+}
+
+function toRowRect(items: RowItem[]): DOMRect {
+ const top = Math.min(...items.map((item) => item.rect.top));
+ const right = Math.max(...items.map((item) => item.rect.right));
+ const bottom = Math.max(...items.map((item) => item.rect.bottom));
+ const left = Math.min(...items.map((item) => item.rect.left));
+
+ return new DOMRect(left, top, right - left, bottom - top);
+}
+
+function groupItemsIntoRows(items: RowItem[]) {
+ const sortedItems = [...items].sort((leftItem, rightItem) => {
+ if (Math.abs(leftItem.rect.top - rightItem.rect.top) > ROW_TOLERANCE_PX) {
+ return leftItem.rect.top - rightItem.rect.top;
+ }
+
+ return leftItem.rect.left - rightItem.rect.left;
+ });
+
+ return sortedItems.reduce((rows, item) => {
+ const lastRow = rows.at(-1);
+
+ if (!lastRow) {
+ rows.push([item]);
+ return rows;
+ }
+
+ if (Math.abs(lastRow[0].rect.top - item.rect.top) > ROW_TOLERANCE_PX) {
+ rows.push([item]);
+ return rows;
+ }
+
+ lastRow.push(item);
+ lastRow.sort(
+ (leftItem, rightItem) => leftItem.rect.left - rightItem.rect.left
+ );
+ return rows;
+ }, []);
+}
+
+function cancelScrollAnimation(container: HTMLElement): void {
+ const animationFrame = scrollAnimationFrames.get(container);
+
+ if (animationFrame === undefined) return;
+
+ globalThis.cancelAnimationFrame(animationFrame);
+ scrollAnimationFrames.delete(container);
+}
+
+function animateScroll(
+ container: HTMLElement,
+ target: { left: number; top: number }
+): void {
+ cancelScrollAnimation(container);
+
+ const startLeft = container.scrollLeft;
+ const startTop = container.scrollTop;
+ const distanceLeft = target.left - startLeft;
+ const distanceTop = target.top - startTop;
+
+ if (distanceLeft === 0 && distanceTop === 0) return;
+
+ const startTime = performance.now();
+
+ const step = (now: number) => {
+ const elapsed = now - startTime;
+ const progress = clamp(elapsed / SCROLL_ANIMATION_DURATION, 0, 1);
+ const easedProgress = easeOutCubic(progress);
+
+ container.scrollLeft = startLeft + distanceLeft * easedProgress;
+ container.scrollTop = startTop + distanceTop * easedProgress;
+
+ if (progress < 1) {
+ scrollAnimationFrames.set(
+ container,
+ globalThis.requestAnimationFrame(step)
+ );
+ return;
+ }
+
+ container.scrollLeft = target.left;
+ container.scrollTop = target.top;
+ scrollAnimationFrames.delete(container);
+ };
+
+ scrollAnimationFrames.set(container, globalThis.requestAnimationFrame(step));
+}
+
+function getKeepVisibleTarget(rect: DOMRect, container: HTMLElement) {
+ const containerRect = getContainerRect(container);
+ const safeMargin = getSafeMargin(containerRect);
+
+ const safeTop = containerRect.top + safeMargin.y;
+ const safeBottom = containerRect.bottom - safeMargin.y;
+ const safeLeft = containerRect.left + safeMargin.x;
+ const safeRight = containerRect.right - safeMargin.x;
+
+ let deltaY = 0;
+ let deltaX = 0;
+
+ if (rect.top < safeTop) {
+ deltaY = rect.top - safeTop;
+ } else if (rect.bottom > safeBottom) {
+ deltaY = rect.bottom - safeBottom;
+ }
+
+ if (rect.left < safeLeft) {
+ deltaX = rect.left - safeLeft;
+ } else if (rect.right > safeRight) {
+ deltaX = rect.right - safeRight;
+ }
+
+ const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
+ const maxLeft = Math.max(0, container.scrollWidth - container.clientWidth);
+
+ return {
+ left: clamp(container.scrollLeft + deltaX, 0, maxLeft),
+ top: clamp(container.scrollTop + deltaY, 0, maxTop),
+ };
+}
+
+function getPreferCenterTarget(rect: DOMRect, container: HTMLElement) {
+ const containerRect = getContainerRect(container);
+ const safeMargin = getSafeMargin(containerRect);
+ const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
+ const maxLeft = Math.max(0, container.scrollWidth - container.clientWidth);
+
+ const currentRectHeight = rect.height;
+ const availableHeight = Math.max(0, containerRect.height - safeMargin.y * 2);
+ const currentCenter = getRectCenter(rect);
+ const containerCenter = getRectCenter(containerRect);
+
+ const top =
+ currentRectHeight >= availableHeight
+ ? container.scrollTop + (rect.top - containerRect.top) - safeMargin.y
+ : container.scrollTop + (currentCenter.y - containerCenter.y);
+
+ const horizontalTarget = getKeepVisibleTarget(rect, container);
+
+ return {
+ left: clamp(horizontalTarget.left, 0, maxLeft),
+ top: clamp(top, 0, maxTop),
+ };
+}
+
+function getRegionMap(regions: FocusRegion[]) {
+ return new Map(regions.map((region) => [region.id, region]));
+}
+
+function resolveRegionAutoScrollMode(
+ region: FocusRegion,
+ parentRegion: FocusRegion | null
+): FocusAutoScrollMode {
+ if (region.autoScrollMode && region.autoScrollMode !== "auto") {
+ return region.autoScrollMode;
+ }
+
+ if (region.orientation === "grid") {
+ return "row";
+ }
+
+ if (
+ region.orientation === "horizontal" &&
+ parentRegion?.orientation === "vertical"
+ ) {
+ return "region";
+ }
+
+ return "item";
+}
+
+function resolveRegionAnchor(region: FocusRegion): HTMLElement | null {
+ const explicitAnchor = region.getScrollAnchor?.() ?? null;
+
+ if (explicitAnchor) return explicitAnchor;
+
+ return region.getElement?.() ?? null;
+}
+
+function resolveRowTarget(
+ node: FocusNode,
+ region: FocusRegion,
+ nodes: FocusNode[]
+): ResolvedScrollTarget | null {
+ const currentElement = node.getElement?.() ?? null;
+
+ if (!currentElement) return null;
+
+ const container = getScrollContainer(currentElement);
+ const rowItems = nodes
+ .filter(
+ (candidate) =>
+ candidate.regionId === region.id &&
+ candidate.navigationState === "active"
+ )
+ .map((candidate) => {
+ const element = candidate.getElement?.() ?? null;
+ const rect = element?.getBoundingClientRect() ?? null;
+
+ if (!element || !rect) return null;
+
+ return {
+ id: candidate.id,
+ rect,
+ };
+ })
+ .filter((item): item is RowItem => item !== null);
+
+ if (rowItems.length === 0) return null;
+
+ const rows = groupItemsIntoRows(rowItems);
+ const currentRowIndex = rows.findIndex((row) =>
+ row.some((item) => item.id === node.id)
+ );
+
+ if (currentRowIndex === -1) return null;
+
+ return {
+ container,
+ rect: toRowRect(rows[currentRowIndex]),
+ key: `row:${region.id}:${currentRowIndex}`,
+ };
+}
+
+function resolveScrollTarget(options: {
+ node: FocusNode;
+ nodes: FocusNode[];
+ regions: FocusRegion[];
+}): ResolvedScrollTarget | null {
+ const regionMap = getRegionMap(options.regions);
+ const currentRegion = regionMap.get(options.node.regionId) ?? null;
+
+ if (!currentRegion) return null;
+
+ const parentRegion = currentRegion.parentRegionId
+ ? (regionMap.get(currentRegion.parentRegionId) ?? null)
+ : null;
+
+ const autoScrollMode = resolveRegionAutoScrollMode(
+ currentRegion,
+ parentRegion
+ );
+
+ if (autoScrollMode === "row") {
+ const rowTarget = resolveRowTarget(
+ options.node,
+ currentRegion,
+ options.nodes
+ );
+
+ if (rowTarget) return rowTarget;
+ }
+
+ if (autoScrollMode === "region") {
+ const anchor = resolveRegionAnchor(currentRegion);
+
+ if (anchor) {
+ return {
+ container: getScrollContainer(anchor),
+ rect: anchor.getBoundingClientRect(),
+ key: `region:${currentRegion.id}`,
+ };
+ }
+ }
+
+ const element = options.node.getElement?.() ?? null;
+
+ if (!element) return null;
+
+ return {
+ container: getScrollContainer(element),
+ rect: element.getBoundingClientRect(),
+ key: `item:${options.node.id}`,
+ };
+}
+
+function getScrollBehaviorMode(
+ currentTarget: ResolvedScrollTarget,
+ previousTarget: ResolvedScrollTarget | null
+): ScrollBehaviorMode {
+ if (!previousTarget) return "prefer-center";
+ if (currentTarget.container !== previousTarget.container)
+ return "prefer-center";
+ if (currentTarget.key !== previousTarget.key) return "prefer-center";
+ return "keep-visible";
+}
+
+export function scrollNavigationIntoView(options: {
+ currentFocusId: string | null;
+ previousFocusId?: string | null;
+ nodes: FocusNode[];
+ regions: FocusRegion[];
+}): void {
+ if (!options.currentFocusId) return;
+
+ const nodesById = new Map(options.nodes.map((node) => [node.id, node]));
+ const currentNode = nodesById.get(options.currentFocusId) ?? null;
+
+ if (!currentNode) return;
+
+ const currentTarget = resolveScrollTarget({
+ node: currentNode,
+ nodes: options.nodes,
+ regions: options.regions,
+ });
+
+ if (!currentTarget) return;
+
+ const previousNode = options.previousFocusId
+ ? (nodesById.get(options.previousFocusId) ?? null)
+ : null;
+ const previousTarget = previousNode
+ ? resolveScrollTarget({
+ node: previousNode,
+ nodes: options.nodes,
+ regions: options.regions,
+ })
+ : null;
+
+ const behaviorMode = getScrollBehaviorMode(currentTarget, previousTarget);
+ const target =
+ behaviorMode === "prefer-center"
+ ? getPreferCenterTarget(currentTarget.rect, currentTarget.container)
+ : getKeepVisibleTarget(currentTarget.rect, currentTarget.container);
+
+ animateScroll(currentTarget.container, target);
+}
diff --git a/src/big-picture/src/helpers/game.ts b/src/big-picture/src/helpers/game.ts
new file mode 100644
index 000000000..8e66e7a4a
--- /dev/null
+++ b/src/big-picture/src/helpers/game.ts
@@ -0,0 +1,42 @@
+import type { GameShop, LibraryGame } from "@types";
+import { IS_DESKTOP } from "../constants";
+
+export interface GameAchievementProgress {
+ label?: string;
+ value?: number;
+}
+
+export function getGameAchievementProgress(
+ game: Pick
+): GameAchievementProgress {
+ const achievementCount = game.achievementCount ?? 0;
+
+ if (achievementCount <= 0) {
+ return {};
+ }
+
+ const unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
+
+ return {
+ label: `${unlockedAchievementCount}/${achievementCount}`,
+ value: unlockedAchievementCount / achievementCount,
+ };
+}
+
+export function getBigPictureGameDetailsPath(
+ game: Pick & {
+ title?: string | null;
+ shop: GameShop;
+ }
+) {
+ const basePath = IS_DESKTOP ? "/big-picture" : "";
+ const searchParams = new URLSearchParams();
+
+ if (game.title) {
+ searchParams.set("title", game.title);
+ }
+
+ const query = searchParams.toString();
+
+ return `${basePath}/game/${game.shop}/${game.objectId}${query ? `?${query}` : ""}`;
+}
diff --git a/src/big-picture/src/helpers/gamepad-layout.ts b/src/big-picture/src/helpers/gamepad-layout.ts
new file mode 100644
index 000000000..ad55ba089
--- /dev/null
+++ b/src/big-picture/src/helpers/gamepad-layout.ts
@@ -0,0 +1,353 @@
+import { GamepadAxisType, GamepadButtonType } from "../types";
+
+export type GamepadAxisButtonDirection = "negative" | "positive";
+export type GamepadPlatform = "linux" | "windows" | "mac" | "unknown";
+
+export interface GamepadPhysicalButtonMapping {
+ index: number;
+ source: "button";
+ type: GamepadButtonType;
+}
+
+export interface GamepadPhysicalAxisMapping {
+ index: number;
+ source: "axis";
+ type: GamepadAxisType;
+ invert?: boolean;
+}
+
+export interface GamepadAxisButtonMapping {
+ axis: number;
+ source: "axis-button";
+ direction: GamepadAxisButtonDirection;
+ type: GamepadButtonType;
+ threshold?: number;
+}
+
+export interface GamepadAxisTriggerMapping {
+ axis: number;
+ source: "axis-trigger";
+ type: GamepadButtonType;
+ min?: number;
+ max?: number;
+ threshold?: number;
+}
+
+export type GamepadInputMapping =
+ | GamepadPhysicalButtonMapping
+ | GamepadPhysicalAxisMapping
+ | GamepadAxisButtonMapping
+ | GamepadAxisTriggerMapping;
+
+export interface GamepadLayout {
+ name: string;
+ mappings: GamepadInputMapping[];
+ idPatterns: RegExp[];
+ platforms?: GamepadPlatform[];
+}
+
+const STANDARD_GAMEPAD_MAPPINGS: GamepadInputMapping[] = [
+ { index: 0, source: "button", type: GamepadButtonType.BUTTON_A },
+ { index: 1, source: "button", type: GamepadButtonType.BUTTON_B },
+ { index: 2, source: "button", type: GamepadButtonType.BUTTON_X },
+ { index: 3, source: "button", type: GamepadButtonType.BUTTON_Y },
+ { index: 4, source: "button", type: GamepadButtonType.LEFT_BUMPER },
+ { index: 5, source: "button", type: GamepadButtonType.RIGHT_BUMPER },
+ { index: 6, source: "button", type: GamepadButtonType.LEFT_TRIGGER },
+ { index: 7, source: "button", type: GamepadButtonType.RIGHT_TRIGGER },
+ { index: 8, source: "button", type: GamepadButtonType.BACK },
+ { index: 9, source: "button", type: GamepadButtonType.START },
+ { index: 10, source: "button", type: GamepadButtonType.LEFT_STICK_PRESS },
+ { index: 11, source: "button", type: GamepadButtonType.RIGHT_STICK_PRESS },
+ { index: 12, source: "button", type: GamepadButtonType.DPAD_UP },
+ { index: 13, source: "button", type: GamepadButtonType.DPAD_DOWN },
+ { index: 14, source: "button", type: GamepadButtonType.DPAD_LEFT },
+ { index: 15, source: "button", type: GamepadButtonType.DPAD_RIGHT },
+ { index: 16, source: "button", type: GamepadButtonType.HOME },
+ { index: 0, source: "axis", type: GamepadAxisType.LEFT_STICK_X },
+ { index: 1, source: "axis", type: GamepadAxisType.LEFT_STICK_Y },
+ { index: 2, source: "axis", type: GamepadAxisType.RIGHT_STICK_X },
+ { index: 3, source: "axis", type: GamepadAxisType.RIGHT_STICK_Y },
+];
+
+const PLAYSTATION_GAMEPAD_MAPPINGS: GamepadInputMapping[] = [
+ ...STANDARD_GAMEPAD_MAPPINGS,
+ { index: 17, source: "button", type: GamepadButtonType.TRACKPAD },
+];
+
+const LINUX_XINPUT_MAPPINGS: GamepadInputMapping[] = [
+ { index: 0, source: "button", type: GamepadButtonType.BUTTON_A },
+ { index: 1, source: "button", type: GamepadButtonType.BUTTON_B },
+ { index: 2, source: "button", type: GamepadButtonType.BUTTON_X },
+ { index: 3, source: "button", type: GamepadButtonType.BUTTON_Y },
+ { index: 4, source: "button", type: GamepadButtonType.LEFT_BUMPER },
+ { index: 5, source: "button", type: GamepadButtonType.RIGHT_BUMPER },
+ { axis: 2, source: "axis-trigger", type: GamepadButtonType.LEFT_TRIGGER },
+ { axis: 5, source: "axis-trigger", type: GamepadButtonType.RIGHT_TRIGGER },
+ { index: 6, source: "button", type: GamepadButtonType.BACK },
+ { index: 7, source: "button", type: GamepadButtonType.START },
+ { index: 8, source: "button", type: GamepadButtonType.HOME },
+ { index: 9, source: "button", type: GamepadButtonType.LEFT_STICK_PRESS },
+ { index: 10, source: "button", type: GamepadButtonType.RIGHT_STICK_PRESS },
+ {
+ axis: 7,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_UP,
+ },
+ {
+ axis: 7,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_DOWN,
+ },
+ {
+ axis: 6,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_LEFT,
+ },
+ {
+ axis: 6,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_RIGHT,
+ },
+ { index: 0, source: "axis", type: GamepadAxisType.LEFT_STICK_X },
+ { index: 1, source: "axis", type: GamepadAxisType.LEFT_STICK_Y },
+ { index: 3, source: "axis", type: GamepadAxisType.RIGHT_STICK_X },
+ { index: 4, source: "axis", type: GamepadAxisType.RIGHT_STICK_Y },
+];
+
+const LINUX_IBUFFALO_MAPPINGS: GamepadInputMapping[] = [
+ { index: 1, source: "button", type: GamepadButtonType.BUTTON_A },
+ { index: 0, source: "button", type: GamepadButtonType.BUTTON_B },
+ { index: 3, source: "button", type: GamepadButtonType.BUTTON_X },
+ { index: 2, source: "button", type: GamepadButtonType.BUTTON_Y },
+ { index: 4, source: "button", type: GamepadButtonType.LEFT_TRIGGER },
+ { index: 5, source: "button", type: GamepadButtonType.RIGHT_TRIGGER },
+ { index: 6, source: "button", type: GamepadButtonType.BACK },
+ { index: 7, source: "button", type: GamepadButtonType.START },
+ {
+ axis: 1,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_UP,
+ },
+ {
+ axis: 1,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_DOWN,
+ },
+ {
+ axis: 0,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_LEFT,
+ },
+ {
+ axis: 0,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_RIGHT,
+ },
+];
+
+const LINUX_XGEAR_MAPPINGS: GamepadInputMapping[] = [
+ { index: 2, source: "button", type: GamepadButtonType.BUTTON_A },
+ { index: 1, source: "button", type: GamepadButtonType.BUTTON_B },
+ { index: 3, source: "button", type: GamepadButtonType.BUTTON_X },
+ { index: 0, source: "button", type: GamepadButtonType.BUTTON_Y },
+ { index: 6, source: "button", type: GamepadButtonType.LEFT_BUMPER },
+ { index: 7, source: "button", type: GamepadButtonType.RIGHT_BUMPER },
+ { index: 4, source: "button", type: GamepadButtonType.LEFT_TRIGGER },
+ { index: 5, source: "button", type: GamepadButtonType.RIGHT_TRIGGER },
+ {
+ axis: 5,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_UP,
+ },
+ {
+ axis: 5,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_DOWN,
+ },
+ {
+ axis: 4,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_LEFT,
+ },
+ {
+ axis: 4,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_RIGHT,
+ },
+ { index: 0, source: "axis", type: GamepadAxisType.LEFT_STICK_X },
+ { index: 1, source: "axis", type: GamepadAxisType.LEFT_STICK_Y },
+ { index: 3, source: "axis", type: GamepadAxisType.RIGHT_STICK_X },
+ { index: 2, source: "axis", type: GamepadAxisType.RIGHT_STICK_Y },
+];
+
+const LINUX_DRAGONRISE_MAPPINGS: GamepadInputMapping[] = [
+ ...STANDARD_GAMEPAD_MAPPINGS,
+ {
+ axis: 6,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_UP,
+ },
+ {
+ axis: 6,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_DOWN,
+ },
+ {
+ axis: 5,
+ source: "axis-button",
+ direction: "negative",
+ type: GamepadButtonType.DPAD_LEFT,
+ },
+ {
+ axis: 5,
+ source: "axis-button",
+ direction: "positive",
+ type: GamepadButtonType.DPAD_RIGHT,
+ },
+ { index: 0, source: "axis", type: GamepadAxisType.LEFT_STICK_X },
+ { index: 1, source: "axis", type: GamepadAxisType.LEFT_STICK_Y },
+ { index: 3, source: "axis", type: GamepadAxisType.RIGHT_STICK_X },
+ { index: 4, source: "axis", type: GamepadAxisType.RIGHT_STICK_Y },
+];
+
+const GAMEPAD_LAYOUTS: GamepadLayout[] = [
+ {
+ name: "Linux XInput Controller",
+ platforms: ["linux"],
+ idPatterns: [
+ /xinput/i,
+ /x[-\s]?box/i,
+ /xbox/i,
+ /xbox 360/i,
+ /Vendor:\s*045e\s+Product:\s*(028e|028f|0719)/i,
+ /Vendor:\s*046d\s+Product:\s*(c21d|c21e|c21f)/i,
+ /Vendor:\s*3537\s+Product:\s*100b/i,
+ ],
+ mappings: LINUX_XINPUT_MAPPINGS,
+ },
+ {
+ name: "Linux DragonRise Generic USB",
+ platforms: ["linux"],
+ idPatterns: [/Vendor:\s*0079\s+Product:\s*0006/i],
+ mappings: LINUX_DRAGONRISE_MAPPINGS,
+ },
+ {
+ name: "Linux iBuffalo Classic",
+ platforms: ["linux"],
+ idPatterns: [/Vendor:\s*0583\s+Product:\s*2060/i],
+ mappings: LINUX_IBUFFALO_MAPPINGS,
+ },
+ {
+ name: "Linux XGEAR PS2 Controller",
+ platforms: ["linux"],
+ idPatterns: [/Vendor:\s*0e8f\s+Product:\s*0003/i],
+ mappings: LINUX_XGEAR_MAPPINGS,
+ },
+ {
+ name: "Standard Gamepad",
+ idPatterns: [],
+ mappings: STANDARD_GAMEPAD_MAPPINGS,
+ },
+ {
+ name: "Linux Standard Gamepad",
+ platforms: ["linux"],
+ idPatterns: [],
+ mappings: LINUX_XINPUT_MAPPINGS,
+ },
+ {
+ name: "Xbox Controller",
+ idPatterns: [/xbox/i, /xinput/i, /microsoft/i, /xbox 360/i],
+ mappings: STANDARD_GAMEPAD_MAPPINGS,
+ },
+ {
+ name: "PlayStation Controller",
+ idPatterns: [
+ /playstation/i,
+ /dualshock/i,
+ /dualsense/i,
+ /sony/i,
+ /054c/i,
+ /09cc/i,
+ /0ce6/i,
+ ],
+ mappings: PLAYSTATION_GAMEPAD_MAPPINGS,
+ },
+ {
+ name: "Nintendo Switch Pro Controller",
+ idPatterns: [/switch/i, /pro controller/i, /057e/i, /2009/i],
+ mappings: STANDARD_GAMEPAD_MAPPINGS,
+ },
+];
+
+export const gamepadLayouts = GAMEPAD_LAYOUTS;
+
+function getGamepadPlatform(): GamepadPlatform {
+ const platformText = getNavigatorPlatformText();
+
+ if (platformText.includes("linux")) return "linux";
+ if (platformText.includes("mac")) return "mac";
+ if (platformText.includes("win")) return "windows";
+
+ return "unknown";
+}
+
+function isLayoutAvailableForPlatform(
+ layout: GamepadLayout,
+ platform: GamepadPlatform
+) {
+ return !layout.platforms || layout.platforms.includes(platform);
+}
+
+const STANDARD_GAMEPAD_LAYOUT = GAMEPAD_LAYOUTS.find(
+ (layout) => layout.name === "Standard Gamepad"
+);
+
+const LINUX_STANDARD_GAMEPAD_LAYOUT = GAMEPAD_LAYOUTS.find(
+ (layout) => layout.name === "Linux Standard Gamepad"
+);
+
+function getNavigatorPlatformText() {
+ if (typeof navigator === "undefined") return "";
+
+ const userAgentDataPlatform =
+ "userAgentData" in navigator
+ ? (navigator as Navigator & { userAgentData?: { platform?: string } })
+ .userAgentData?.platform
+ : undefined;
+
+ return `${userAgentDataPlatform ?? ""} ${navigator.userAgent}`.toLowerCase();
+}
+
+export const getGamepadLayout = (gamepad: globalThis.Gamepad) => {
+ const platform = getGamepadPlatform();
+
+ for (const layout of GAMEPAD_LAYOUTS) {
+ if (
+ isLayoutAvailableForPlatform(layout, platform) &&
+ layout.idPatterns.some((pattern: RegExp) => pattern.test(gamepad.id))
+ ) {
+ return layout;
+ }
+ }
+
+ if (platform === "linux" && LINUX_STANDARD_GAMEPAD_LAYOUT) {
+ return LINUX_STANDARD_GAMEPAD_LAYOUT;
+ }
+
+ return STANDARD_GAMEPAD_LAYOUT ?? GAMEPAD_LAYOUTS[0];
+};
diff --git a/src/big-picture/src/helpers/image.ts b/src/big-picture/src/helpers/image.ts
new file mode 100644
index 000000000..d61df28b0
--- /dev/null
+++ b/src/big-picture/src/helpers/image.ts
@@ -0,0 +1,47 @@
+import type { LibraryGame } from "@types";
+
+export function resolveImageSource(
+ imageUrl: string | null | undefined
+): string {
+ if (!imageUrl) return "";
+
+ const trimmedImageUrl = imageUrl.trim();
+ if (!trimmedImageUrl) return "";
+
+ if (
+ trimmedImageUrl.startsWith("http://") ||
+ trimmedImageUrl.startsWith("https://") ||
+ trimmedImageUrl.startsWith("data:") ||
+ trimmedImageUrl.startsWith("blob:")
+ ) {
+ return trimmedImageUrl;
+ }
+
+ if (trimmedImageUrl.startsWith("local:")) {
+ const normalizedLocalPath = trimmedImageUrl
+ .slice("local:".length)
+ .replaceAll("\\", "/");
+ return `local:${normalizedLocalPath}`;
+ }
+
+ const normalizedPath = trimmedImageUrl.replaceAll("\\", "/");
+
+ if (/^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("/")) {
+ return `local:${normalizedPath}`;
+ }
+
+ return normalizedPath;
+}
+
+export function getGameImageSources(game: LibraryGame) {
+ return [
+ game.customIconUrl,
+ game.coverImageUrl,
+ game.libraryImageUrl,
+ game.iconUrl,
+ ]
+ .map((source) => resolveImageSource(source))
+ .filter((source, index, array) => {
+ return source !== "" && array.indexOf(source) === index;
+ });
+}
diff --git a/src/big-picture/src/helpers/index.ts b/src/big-picture/src/helpers/index.ts
new file mode 100644
index 000000000..bf12545b4
--- /dev/null
+++ b/src/big-picture/src/helpers/index.ts
@@ -0,0 +1,8 @@
+export * from "./color";
+export * from "./date";
+export * from "./focus-auto-scroll";
+export * from "./game";
+export * from "./gamepad-layout";
+export * from "./image";
+export * from "./library-game-state";
+export * from "./strings";
diff --git a/src/big-picture/src/helpers/library-game-state.ts b/src/big-picture/src/helpers/library-game-state.ts
new file mode 100644
index 000000000..c94c82457
--- /dev/null
+++ b/src/big-picture/src/helpers/library-game-state.ts
@@ -0,0 +1,22 @@
+import type { GameShop, LibraryGame } from "@types";
+
+export type LibraryGameState = {
+ libraryGame: LibraryGame | null;
+ isInLibrary: boolean;
+ hasExecutable: boolean;
+};
+
+export function getLibraryGameState(
+ library: LibraryGame[],
+ shop: GameShop,
+ objectId: string
+): LibraryGameState {
+ const libraryGame =
+ library.find((g) => g.shop === shop && g.objectId === objectId) ?? null;
+
+ return {
+ libraryGame,
+ isInLibrary: libraryGame !== null,
+ hasExecutable: Boolean(libraryGame?.executablePath),
+ };
+}
diff --git a/src/big-picture/src/helpers/strings.ts b/src/big-picture/src/helpers/strings.ts
new file mode 100644
index 000000000..4d52a0888
--- /dev/null
+++ b/src/big-picture/src/helpers/strings.ts
@@ -0,0 +1,39 @@
+export const toSlug = (name: string) => {
+ return name
+ .toLowerCase()
+ .normalize("NFD")
+ .replaceAll(/[\u0300-\u036f]/g, "")
+ .replaceAll(/[^a-z0-9\s-]/g, "")
+ .trim()
+ .replaceAll(/\s+/g, "-")
+ .replaceAll(/-+/g, "-");
+};
+
+export const normalizeRequirementsHtml = (html: string) => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, "text/html");
+
+ const list = doc.querySelector("ul");
+ if (!list) return html;
+
+ const items = list.querySelectorAll("li");
+
+ if (items.length === 1) {
+ const singleItem = items[0];
+ const parts = singleItem.innerHTML
+ .split(/
/)
+ .map((part) => part.trim())
+ .filter(Boolean);
+
+ if (parts.length > 1) {
+ list.innerHTML = parts.map((part) => `${part}`).join("");
+ }
+ }
+
+ const firstLi = list.querySelector("li");
+ if (firstLi && !firstLi.querySelector("strong")) {
+ firstLi.remove();
+ }
+
+ return list.outerHTML;
+};
diff --git a/src/big-picture/src/hooks/index.ts b/src/big-picture/src/hooks/index.ts
new file mode 100644
index 000000000..a27107e4c
--- /dev/null
+++ b/src/big-picture/src/hooks/index.ts
@@ -0,0 +1,13 @@
+export * from "./use-game-details.hook";
+export * from "./use-library.hook";
+export * from "./use-library-game-state";
+export * from "./use-dominant-color.hook";
+export * from "./use-user-details.hook";
+export * from "./use-gamepad.hook";
+export * from "./use-navigation.hook";
+export * from "./use-navigation-focus-bridge.hook";
+export * from "./use-navigation-screen-actions.hook";
+export * from "./use-search.hook";
+export * from "./use-format.hook";
+export * from "./use-date.hook";
+export * from "./use-game-details.hook";
diff --git a/src/big-picture/src/hooks/use-date.hook.ts b/src/big-picture/src/hooks/use-date.hook.ts
new file mode 100644
index 000000000..3b28b69dd
--- /dev/null
+++ b/src/big-picture/src/hooks/use-date.hook.ts
@@ -0,0 +1,88 @@
+import {
+ ptBR,
+ enUS,
+ es,
+ fr,
+ pl,
+ hu,
+ tr,
+ ru,
+ it,
+ be,
+ zhCN,
+ da,
+} from "date-fns/locale";
+import { format, formatDistance, subMilliseconds } from "date-fns";
+import type { FormatDistanceOptions } from "date-fns";
+
+export type DateLike = number | Date | string;
+
+export const getDateLocale = (language: string) => {
+ if (language.startsWith("pt")) return ptBR;
+ if (language.startsWith("es")) return es;
+ if (language.startsWith("fr")) return fr;
+ if (language.startsWith("hu")) return hu;
+ if (language.startsWith("pl")) return pl;
+ if (language.startsWith("tr")) return tr;
+ if (language.startsWith("ru")) return ru;
+ if (language.startsWith("it")) return it;
+ if (language.startsWith("be")) return be;
+ if (language.startsWith("zh")) return zhCN;
+ if (language.startsWith("da")) return da;
+
+ return enUS;
+};
+
+export const formatDate = (date: DateLike, language: string): string => {
+ if (Number.isNaN(new Date(date).getDate())) return "N/A";
+ return format(date, language == "en" ? "MM-dd-yyyy" : "dd/MM/yyyy");
+};
+
+export function useDate() {
+ return {
+ formatDistance: (
+ date: DateLike,
+ baseDate: DateLike,
+ options?: FormatDistanceOptions
+ ) => {
+ try {
+ return formatDistance(date, baseDate, {
+ ...options,
+ locale: getDateLocale("en"),
+ });
+ } catch (err) {
+ console.error(err);
+ return "";
+ }
+ },
+
+ formatDiffInMillis: (
+ millis: number,
+ baseDate: DateLike,
+ options?: FormatDistanceOptions
+ ) => {
+ try {
+ return formatDistance(subMilliseconds(new Date(), millis), baseDate, {
+ ...options,
+ locale: getDateLocale("en"),
+ });
+ } catch (err) {
+ console.error(err);
+ return "";
+ }
+ },
+
+ formatDateTime: (date: DateLike): string => {
+ try {
+ return format(date, "MM-dd-yyyy - hh:mm a", {
+ locale: getDateLocale("en"),
+ });
+ } catch (err) {
+ console.error(err);
+ return "";
+ }
+ },
+
+ formatDate: (date: DateLike) => formatDate(date, "en"),
+ };
+}
diff --git a/src/big-picture/src/hooks/use-dominant-color.hook.ts b/src/big-picture/src/hooks/use-dominant-color.hook.ts
new file mode 100644
index 000000000..bb6810414
--- /dev/null
+++ b/src/big-picture/src/hooks/use-dominant-color.hook.ts
@@ -0,0 +1,39 @@
+import { getDominantColorFromImage } from "../helpers";
+import { useEffect, useState } from "react";
+
+const dominantColorCache = new Map();
+
+export function useDominantColor(imageUrl: string | null) {
+ const [dominantColor, setDominantColor] = useState(() => {
+ if (!imageUrl) return null;
+ return dominantColorCache.get(imageUrl) ?? null;
+ });
+
+ useEffect(() => {
+ if (!imageUrl) {
+ setDominantColor(null);
+ return;
+ }
+
+ if (dominantColorCache.has(imageUrl)) {
+ setDominantColor(dominantColorCache.get(imageUrl) ?? null);
+ return;
+ }
+
+ let isMounted = true;
+
+ getDominantColorFromImage(imageUrl).then((nextColor) => {
+ dominantColorCache.set(imageUrl, nextColor);
+
+ if (!isMounted) return;
+
+ setDominantColor(nextColor);
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [imageUrl]);
+
+ return dominantColor;
+}
diff --git a/src/big-picture/src/hooks/use-format.hook.ts b/src/big-picture/src/hooks/use-format.hook.ts
new file mode 100644
index 000000000..1b51502a5
--- /dev/null
+++ b/src/big-picture/src/hooks/use-format.hook.ts
@@ -0,0 +1,38 @@
+import { useCallback, useMemo } from "react";
+
+export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
+
+export function useFormat() {
+ const numberFormatter = useMemo(() => {
+ return new Intl.NumberFormat("en", {
+ maximumFractionDigits: 0,
+ });
+ }, []);
+
+ const compactNumberFormatter = useMemo(() => {
+ return new Intl.NumberFormat("en", {
+ maximumFractionDigits: 0,
+ notation: "compact",
+ });
+ }, []);
+
+ const formatPlayTime = useCallback(
+ (playTimeInSeconds: number) => {
+ const minutes = playTimeInSeconds / 60;
+
+ if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
+ return `${minutes.toFixed(0)} minutes`;
+ }
+
+ const hours = minutes / 60;
+ return `${numberFormatter.format(hours)} hours`;
+ },
+ [numberFormatter]
+ );
+
+ return {
+ formatNumber: numberFormatter.format,
+ formatCompactNumber: compactNumberFormatter.format,
+ formatPlayTime,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-game-details.hook.ts b/src/big-picture/src/hooks/use-game-details.hook.ts
new file mode 100644
index 000000000..d223206b2
--- /dev/null
+++ b/src/big-picture/src/hooks/use-game-details.hook.ts
@@ -0,0 +1,139 @@
+import { useCallback, useEffect, useState } from "react";
+import { IS_DESKTOP } from "../constants";
+import type {
+ GameShop,
+ GameStats,
+ HowLongToBeatCategory,
+ LibraryGame,
+ ShopDetailsWithAssets,
+ UserAchievement,
+} from "@types";
+
+export function useGameDetails(objectId: string, shop: GameShop) {
+ const [shopDetails, setShopDetails] = useState(
+ null
+ );
+ const [stats, setStats] = useState(null);
+ const [game, setGame] = useState(null);
+ const [isGameRunning, setIsGameRunning] = useState(false);
+ const [howLongToBeat, setHowLongToBeat] = useState<
+ HowLongToBeatCategory[] | null
+ >(null);
+ const [achievements, setAchievements] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const updateGame = useCallback(async () => {
+ if (!IS_DESKTOP) return;
+ const result = await globalThis.window.electron.getGameByObjectId(
+ shop,
+ objectId
+ );
+ setGame(result);
+ }, [objectId, shop]);
+
+ const fetchGameDetails = useCallback(async () => {
+ if (!IS_DESKTOP) return;
+
+ setIsLoading(true);
+
+ const [shopDetailsResult, statsResult, assets] = await Promise.all([
+ globalThis.window.electron.getGameShopDetails(
+ objectId,
+ shop,
+ navigator.language
+ ),
+ shop === "custom"
+ ? Promise.resolve(null)
+ : globalThis.window.electron.getGameStats(objectId, shop),
+ globalThis.window.electron.getGameAssets(objectId, shop),
+ ]);
+
+ if (shopDetailsResult) {
+ shopDetailsResult.assets = assets ?? shopDetailsResult.assets;
+ }
+
+ setShopDetails(shopDetailsResult);
+ setStats(statsResult);
+ setIsLoading(false);
+ }, [objectId, shop]);
+
+ useEffect(() => {
+ fetchGameDetails();
+ updateGame();
+
+ if (IS_DESKTOP && shop !== "custom") {
+ globalThis.window.electron.hydraApi
+ .get(
+ `/games/${shop}/${objectId}/how-long-to-beat`,
+ { needsAuth: false }
+ )
+ .then(setHowLongToBeat)
+ .catch(() => setHowLongToBeat(null));
+
+ globalThis.window.electron
+ .getUnlockedAchievements(objectId, shop)
+ .then((result) => {
+ if (result) {
+ setAchievements(result);
+ }
+ })
+ .catch(() => setAchievements([]));
+ }
+ }, [fetchGameDetails, updateGame, objectId, shop]);
+
+ useEffect(() => {
+ if (!IS_DESKTOP || !game?.id) return;
+
+ const gameId = game.id;
+ const unsubscribe = globalThis.window.electron.onGamesRunning(
+ (gamesRunning) => {
+ setIsGameRunning(gamesRunning.some((g) => g.id == gameId));
+ }
+ );
+
+ return () => {
+ unsubscribe();
+ };
+ }, [game?.id]);
+
+ const openGame = useCallback(async () => {
+ if (!game?.executablePath) return;
+ globalThis.window.electron.openGame(
+ game.shop,
+ game.objectId,
+ game.executablePath,
+ game.launchOptions
+ );
+ }, [game]);
+
+ const closeGame = useCallback(() => {
+ if (!game) return;
+ globalThis.window.electron.closeGame(game.shop, game.objectId);
+ }, [game]);
+
+ const toggleFavorite = useCallback(async () => {
+ if (!game) return;
+
+ if (game.favorite) {
+ await globalThis.window.electron.removeGameFromFavorites(shop, objectId);
+ } else {
+ await globalThis.window.electron.addGameToFavorites(shop, objectId);
+ }
+
+ updateGame();
+ globalThis.window.dispatchEvent(new Event("library-update"));
+ }, [game, shop, objectId, updateGame]);
+
+ return {
+ shopDetails,
+ stats,
+ game,
+ isGameRunning,
+ isLoading,
+ howLongToBeat,
+ achievements,
+ openGame,
+ closeGame,
+ toggleFavorite,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-gamepad.hook.ts b/src/big-picture/src/hooks/use-gamepad.hook.ts
new file mode 100644
index 000000000..61b7347fb
--- /dev/null
+++ b/src/big-picture/src/hooks/use-gamepad.hook.ts
@@ -0,0 +1,201 @@
+import {
+ GamepadButtonType,
+ GamepadAxisDirection,
+ GamepadStickSide,
+ GamepadAxisType,
+ GamepadVibrationOptions,
+ GamepadButtonPressEvent,
+ GamepadStickMoveEvent,
+} from "../types";
+import { useGamepadStore } from "../stores";
+import { useEffect, useRef, useCallback } from "react";
+
+const DEFAULT_VIBRATION_DURATION = 200;
+const DEFAULT_WEAK_MAGNITUDE = 0.5;
+const DEFAULT_STRONG_MAGNITUDE = 0.5;
+
+export interface UseGamepadReturn {
+ isButtonPressed: (button: GamepadButtonType) => boolean;
+ getButtonValue: (button: GamepadButtonType) => number;
+ getAxisValue: (axis: GamepadAxisType) => number;
+ vibrate: (options: GamepadVibrationOptions) => void;
+ activeGamepad: { index: number; name: string; layout: string } | null;
+ activeGamepadIndex: number | null;
+ connectedGamepads: { index: number; name: string; layout: string }[];
+ hasGamepadConnected: boolean;
+ isActiveGamepadEvent: (
+ event: GamepadButtonPressEvent | GamepadStickMoveEvent
+ ) => boolean;
+
+ onButtonPressed: (
+ button: GamepadButtonType,
+ callback: (event: GamepadButtonPressEvent) => void
+ ) => () => void;
+
+ onStickMove: (
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection,
+ callback: (event: GamepadStickMoveEvent) => void
+ ) => () => void;
+}
+
+export function useGamepad(): UseGamepadReturn {
+ const {
+ states,
+ hasGamepadConnected,
+ activeGamepadIndex,
+ connectedGamepads,
+ getActiveGamepad,
+ getService,
+ sync,
+ } = useGamepadStore();
+
+ const callbackRefs = useRef void>>(new Set());
+ const activeGamepad = getActiveGamepad();
+
+ useEffect(() => {
+ sync();
+ }, [sync]);
+
+ useEffect(() => {
+ const currentCallbacks = callbackRefs.current;
+
+ return () => {
+ currentCallbacks.forEach((removeCallback) => removeCallback());
+ currentCallbacks.clear();
+ };
+ }, []);
+
+ const getButtonState = useCallback(
+ (button: GamepadButtonType) => {
+ if (!hasGamepadConnected) return null;
+
+ const activeGamepad = getActiveGamepad();
+ if (!activeGamepad) return null;
+
+ const state = states.get(activeGamepad.index);
+ if (!state) return null;
+
+ return state.buttons.get(button) ?? null;
+ },
+ [hasGamepadConnected, getActiveGamepad, states]
+ );
+
+ const getAxisState = useCallback(
+ (axis: GamepadAxisType) => {
+ if (!hasGamepadConnected) return null;
+
+ const activeGamepad = getActiveGamepad();
+ if (!activeGamepad) return null;
+
+ const state = states.get(activeGamepad.index);
+ if (!state) return null;
+
+ return state.axes.get(axis) ?? null;
+ },
+ [hasGamepadConnected, getActiveGamepad, states]
+ );
+
+ const isButtonPressed = useCallback(
+ (button: GamepadButtonType) => {
+ const buttonState = getButtonState(button);
+ if (!buttonState) return false;
+
+ return buttonState.pressed;
+ },
+ [getButtonState]
+ );
+
+ const getButtonValue = useCallback(
+ (button: GamepadButtonType) => {
+ const buttonState = getButtonState(button);
+ if (!buttonState) return 0;
+
+ return buttonState.value;
+ },
+ [getButtonState]
+ );
+
+ const getAxisValue = useCallback(
+ (axis: GamepadAxisType) => {
+ const axisState = getAxisState(axis);
+ if (!axisState) return 0;
+
+ return axisState.value;
+ },
+ [getAxisState]
+ );
+
+ const onButtonPressed = useCallback(
+ (
+ button: GamepadButtonType,
+ callback: (event: GamepadButtonPressEvent) => void
+ ) => {
+ const service = getService();
+ const removeCallback = service.onButtonPress(button, callback);
+
+ callbackRefs.current.add(removeCallback);
+
+ return () => {
+ removeCallback();
+ callbackRefs.current.delete(removeCallback);
+ };
+ },
+ [getService]
+ );
+
+ const onStickMove = useCallback(
+ (
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection,
+ callback: (event: GamepadStickMoveEvent) => void
+ ) => {
+ const service = getService();
+ const removeCallback = service.onStickMove(side, direction, callback);
+
+ callbackRefs.current.add(removeCallback);
+
+ return () => {
+ removeCallback();
+ callbackRefs.current.delete(removeCallback);
+ };
+ },
+ [getService]
+ );
+
+ const vibrate = useCallback(
+ (options: GamepadVibrationOptions = {}) => {
+ const service = getService();
+ const {
+ duration = DEFAULT_VIBRATION_DURATION,
+ weakMagnitude = DEFAULT_WEAK_MAGNITUDE,
+ strongMagnitude = DEFAULT_STRONG_MAGNITUDE,
+ gamepadIndex = getActiveGamepad()?.index ?? -1,
+ } = options;
+
+ service.vibrate(duration, weakMagnitude, strongMagnitude, gamepadIndex);
+ },
+ [getService, getActiveGamepad]
+ );
+
+ const isActiveGamepadEvent = useCallback(
+ (event: GamepadButtonPressEvent | GamepadStickMoveEvent) => {
+ return event.accepted && event.gamepadIndex === event.activeGamepadIndex;
+ },
+ []
+ );
+
+ return {
+ isButtonPressed,
+ getButtonValue,
+ getAxisValue,
+ onButtonPressed,
+ onStickMove,
+ vibrate,
+ activeGamepad,
+ activeGamepadIndex,
+ hasGamepadConnected,
+ connectedGamepads,
+ isActiveGamepadEvent,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-library-game-state.ts b/src/big-picture/src/hooks/use-library-game-state.ts
new file mode 100644
index 000000000..2c6139907
--- /dev/null
+++ b/src/big-picture/src/hooks/use-library-game-state.ts
@@ -0,0 +1,34 @@
+import type { GameShop, LibraryGame } from "@types";
+import { useMemo } from "react";
+import { getLibraryGameState, type LibraryGameState } from "../helpers";
+import { useLibrary } from "./use-library.hook";
+
+const emptyLibraryState: LibraryGameState = {
+ libraryGame: null,
+ isInLibrary: false,
+ hasExecutable: false,
+};
+
+export type UseLibraryGameStateResult = LibraryGameState & {
+ library: LibraryGame[];
+ updateLibrary: () => Promise;
+};
+
+export function useLibraryGameState(
+ shop: GameShop | null | undefined,
+ objectId: string | null | undefined
+): UseLibraryGameStateResult {
+ const { library, updateLibrary } = useLibrary();
+ const state = useMemo((): LibraryGameState => {
+ if (shop == null || objectId == null) {
+ return emptyLibraryState;
+ }
+ return getLibraryGameState(library, shop, objectId);
+ }, [library, shop, objectId]);
+
+ return {
+ ...state,
+ library,
+ updateLibrary,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-library.hook.ts b/src/big-picture/src/hooks/use-library.hook.ts
new file mode 100644
index 000000000..275fdf1f2
--- /dev/null
+++ b/src/big-picture/src/hooks/use-library.hook.ts
@@ -0,0 +1,38 @@
+import { useCallback, useEffect, useState } from "react";
+import { IS_DESKTOP } from "../constants";
+import type { LibraryGame } from "@types";
+
+export function useLibrary() {
+ const [library, setLibrary] = useState([]);
+
+ const updateLibrary = useCallback(async () => {
+ if (!IS_DESKTOP) return;
+ const updatedLibrary = await globalThis.window.electron.getLibrary();
+ setLibrary(updatedLibrary);
+ }, []);
+
+ useEffect(() => {
+ updateLibrary();
+
+ if (!IS_DESKTOP) return;
+
+ const unsubscribe = globalThis.window.electron.onLibraryBatchComplete(
+ () => {
+ updateLibrary();
+ }
+ );
+
+ const handleLibraryUpdate = () => updateLibrary();
+ globalThis.window.addEventListener("library-update", handleLibraryUpdate);
+
+ return () => {
+ unsubscribe();
+ globalThis.window.removeEventListener(
+ "library-update",
+ handleLibraryUpdate
+ );
+ };
+ }, [updateLibrary]);
+
+ return { library, updateLibrary };
+}
diff --git a/src/big-picture/src/hooks/use-navigation-focus-bridge.hook.ts b/src/big-picture/src/hooks/use-navigation-focus-bridge.hook.ts
new file mode 100644
index 000000000..11601e5ec
--- /dev/null
+++ b/src/big-picture/src/hooks/use-navigation-focus-bridge.hook.ts
@@ -0,0 +1,17 @@
+import { NavigationFocusBridgeService } from "../services";
+import { useCallback, useEffect } from "react";
+
+const navigationFocusBridge = NavigationFocusBridgeService.getInstance();
+
+export function useNavigationFocusBridge(
+ itemId: string,
+ onFocused: () => void
+) {
+ useEffect(() => {
+ return navigationFocusBridge.register(itemId, onFocused);
+ }, [itemId, onFocused]);
+
+ return useCallback(() => {
+ return navigationFocusBridge.focus(itemId);
+ }, [itemId]);
+}
diff --git a/src/big-picture/src/hooks/use-navigation-screen-actions.hook.ts b/src/big-picture/src/hooks/use-navigation-screen-actions.hook.ts
new file mode 100644
index 000000000..9a31b0ef3
--- /dev/null
+++ b/src/big-picture/src/hooks/use-navigation-screen-actions.hook.ts
@@ -0,0 +1,27 @@
+import type { ScreenActions } from "../types";
+import { NavigationScreenActionsService } from "../services";
+import { useEffect, useRef } from "react";
+
+const navigationScreenActions = NavigationScreenActionsService.getInstance();
+
+export function useNavigationScreenActions(actions: ScreenActions) {
+ const registrationIdRef = useRef(null);
+ const initialActionsRef = useRef(actions);
+
+ useEffect(() => {
+ const registration = navigationScreenActions.createRegistration(
+ initialActionsRef.current
+ );
+ registrationIdRef.current = registration.id;
+
+ return registration.unregister;
+ }, []);
+
+ useEffect(() => {
+ if (registrationIdRef.current === null) {
+ return;
+ }
+
+ navigationScreenActions.updateActions(registrationIdRef.current, actions);
+ }, [actions]);
+}
diff --git a/src/big-picture/src/hooks/use-navigation.hook.ts b/src/big-picture/src/hooks/use-navigation.hook.ts
new file mode 100644
index 000000000..e74deb862
--- /dev/null
+++ b/src/big-picture/src/hooks/use-navigation.hook.ts
@@ -0,0 +1,163 @@
+import {
+ NavigationItemActionsService,
+ NavigationScreenActionsService,
+ NavigationService,
+ type FocusDirection,
+} from "../services";
+import type {
+ FocusItemHoldButton,
+ FocusItemPressButton,
+ NavigationActionButton,
+} from "../types";
+import { useNavigationSnapshot } from "../stores";
+import { useCallback } from "react";
+
+const navigation = NavigationService.getInstance();
+const navigationItemActions = NavigationItemActionsService.getInstance();
+const navigationScreenActions = NavigationScreenActionsService.getInstance();
+
+export function useNavigationActions() {
+ const registerRegion = useCallback(
+ (region: Parameters[0]) => {
+ return navigation.registerRegion(region);
+ },
+ []
+ );
+
+ const registerNavigationNode = useCallback(
+ (node: Parameters[0]) => {
+ return navigation.registerNavigationNode(node);
+ },
+ []
+ );
+
+ const setFocus = useCallback((id: string) => {
+ return navigation.setFocus(id);
+ }, []);
+
+ const setFocusRegion = useCallback(
+ (regionId: string, entryDirection: FocusDirection = "right") => {
+ return navigation.setFocusRegion(regionId, entryDirection);
+ },
+ []
+ );
+
+ const moveFocus = useCallback((direction: FocusDirection) => {
+ return navigation.moveFocus(direction);
+ }, []);
+
+ const triggerPrimary = useCallback((originalEvent: Event | null = null) => {
+ return navigationItemActions.triggerPrimaryForFocusedItem(originalEvent);
+ }, []);
+
+ const triggerSecondary = useCallback((originalEvent: Event | null = null) => {
+ return navigationItemActions.triggerSecondaryForFocusedItem(originalEvent);
+ }, []);
+
+ const triggerItemPress = useCallback(
+ (button: FocusItemPressButton, originalEvent: Event | null = null) => {
+ return navigationItemActions.triggerPressActionForFocusedItem(
+ button,
+ originalEvent
+ );
+ },
+ []
+ );
+
+ const triggerItemHold = useCallback(
+ (button: FocusItemHoldButton, originalEvent: Event | null = null) => {
+ return navigationItemActions.triggerHoldActionForFocusedItem(
+ button,
+ originalEvent
+ );
+ },
+ []
+ );
+
+ const triggerScreenPress = useCallback(
+ (button: NavigationActionButton, originalEvent: Event | null = null) => {
+ return navigationScreenActions.triggerAction(
+ "press",
+ button,
+ originalEvent
+ );
+ },
+ []
+ );
+
+ const triggerScreenHold = useCallback(
+ (button: NavigationActionButton, originalEvent: Event | null = null) => {
+ return navigationScreenActions.triggerAction(
+ "hold",
+ button,
+ originalEvent
+ );
+ },
+ []
+ );
+
+ const canResolveFocusedPrimaryAction = useCallback(() => {
+ return navigationItemActions.canResolvePrimaryForFocusedItem();
+ }, []);
+
+ const canResolveFocusedSecondaryAction = useCallback(() => {
+ return navigationItemActions.hasSecondaryActionForFocusedItem();
+ }, []);
+
+ const hasFocusedItemPressAction = useCallback(
+ (button: FocusItemPressButton) => {
+ return navigationItemActions.hasPressActionForFocusedItem(button);
+ },
+ []
+ );
+
+ const hasFocusedItemHoldAction = useCallback(
+ (button: FocusItemHoldButton) => {
+ return navigationItemActions.hasHoldActionForFocusedItem(button);
+ },
+ []
+ );
+
+ const hasScreenPressAction = useCallback((button: NavigationActionButton) => {
+ return navigationScreenActions.hasAction("press", button);
+ }, []);
+
+ const hasScreenHoldAction = useCallback((button: NavigationActionButton) => {
+ return navigationScreenActions.hasAction("hold", button);
+ }, []);
+
+ return {
+ registerRegion,
+ registerNavigationNode,
+ setFocus,
+ setFocusRegion,
+ moveFocus,
+ triggerPrimary,
+ triggerSecondary,
+ triggerItemPress,
+ triggerItemHold,
+ triggerScreenPress,
+ triggerScreenHold,
+ canResolveFocusedPrimaryAction,
+ canResolveFocusedSecondaryAction,
+ hasFocusedItemPressAction,
+ hasFocusedItemHoldAction,
+ hasScreenPressAction,
+ hasScreenHoldAction,
+ };
+}
+
+export function useNavigation() {
+ const { currentFocusId, nodes, regions, layers, debugSnapshot } =
+ useNavigationSnapshot();
+ const actions = useNavigationActions();
+
+ return {
+ currentFocusId,
+ nodes,
+ regions,
+ layers,
+ debugSnapshot,
+ ...actions,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-search.hook.ts b/src/big-picture/src/hooks/use-search.hook.ts
new file mode 100644
index 000000000..2547deb8f
--- /dev/null
+++ b/src/big-picture/src/hooks/use-search.hook.ts
@@ -0,0 +1,49 @@
+import { useMemo, useState, useEffect } from "react";
+import debounce from "lodash-es/debounce";
+
+export function useSearch(items: T[], fieldsToSearch: (keyof T)[]) {
+ const [search, setSearch] = useState("");
+
+ const debouncedSetSearch = useMemo(
+ () => debounce((value: string) => setSearch(value), 100),
+ []
+ );
+
+ useEffect(() => {
+ debouncedSetSearch(search);
+ return () => debouncedSetSearch.cancel();
+ }, [search, debouncedSetSearch]);
+
+ const indexedItems = useMemo(() => {
+ return items.map((item) => {
+ const searchText = fieldsToSearch
+ .map((field) => {
+ const value = item[field];
+ return typeof value === "string" ? value.toLowerCase() : "";
+ })
+ .join(" ");
+ return { item, searchText };
+ });
+ }, [items, fieldsToSearch]);
+
+ const filteredItems = useMemo(() => {
+ if (!search) return items;
+
+ const searchLower = search.toLowerCase();
+ const result: T[] = [];
+
+ for (const { item, searchText } of indexedItems) {
+ if (searchText.includes(searchLower)) {
+ result.push(item);
+ }
+ }
+
+ return result;
+ }, [indexedItems, search, items]);
+
+ return {
+ search,
+ setSearch,
+ filteredItems,
+ };
+}
diff --git a/src/big-picture/src/hooks/use-user-details.hook.ts b/src/big-picture/src/hooks/use-user-details.hook.ts
new file mode 100644
index 000000000..d44684495
--- /dev/null
+++ b/src/big-picture/src/hooks/use-user-details.hook.ts
@@ -0,0 +1,19 @@
+import { useCallback, useEffect, useState } from "react";
+import { IS_DESKTOP } from "../constants";
+import type { UserDetails } from "@types";
+
+export function useUserDetails() {
+ const [userDetails, setUserDetails] = useState(null);
+
+ const fetchUserDetails = useCallback(async () => {
+ if (!IS_DESKTOP) return;
+ const details = await window.electron.getMe();
+ setUserDetails(details);
+ }, []);
+
+ useEffect(() => {
+ fetchUserDetails();
+ }, [fetchUserDetails]);
+
+ return { userDetails, fetchUserDetails };
+}
diff --git a/src/big-picture/src/layout/header/index.tsx b/src/big-picture/src/layout/header/index.tsx
new file mode 100644
index 000000000..b6df6fa6d
--- /dev/null
+++ b/src/big-picture/src/layout/header/index.tsx
@@ -0,0 +1,163 @@
+import { ArrowLeftIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
+
+import cn from "classnames";
+import { AnimatePresence, motion } from "framer-motion";
+import { useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { FocusItem, HorizontalFocusGroup, Typography } from "../../components";
+import { IS_DESKTOP } from "../../constants";
+import type { FocusOverrides } from "../../services";
+import "./styles.scss";
+
+const basePath = IS_DESKTOP ? "/big-picture" : "";
+
+const capitalize = (word: string) =>
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+
+const HEADER_BACK_BUTTON_ID = "header-back-button";
+const HEADER_SEARCH_INPUT_ID = "header-search-input";
+
+const usePageTitle = () => {
+ const { pathname } = useLocation();
+ const { slug } = useParams<{ slug: string }>();
+
+ if (pathname.startsWith("/game/")) {
+ return slug ? slug.split("-").map(capitalize).join(" ") : "Game Details";
+ }
+
+ const relativePath = basePath ? pathname.replace(basePath, "") : pathname;
+ const firstSegment = relativePath.split("/")[1];
+ return firstSegment ? capitalize(firstSegment) : "Home";
+};
+
+function Header() {
+ const navigate = useNavigate();
+ const pageTitle = usePageTitle();
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const inputRef = useRef(null);
+ const searchRef = useRef(null);
+ const [isHovered, setIsHovered] = useState(false);
+ const searchNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: HEADER_BACK_BUTTON_ID,
+ },
+ };
+
+ const handleSearchToggle = () => {
+ setIsSearchOpen((open) => {
+ if (open) {
+ inputRef.current?.blur();
+ return false;
+ }
+ return true;
+ });
+ };
+
+ useEffect(() => {
+ if (isSearchOpen) {
+ inputRef.current?.focus();
+ }
+ }, [isSearchOpen]);
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (!searchRef.current?.contains(e.target as Node)) {
+ setIsSearchOpen(false);
+ inputRef.current?.blur();
+ }
+ };
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isSearchOpen) {
+ setIsSearchOpen(false);
+ inputRef.current?.blur();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleEscape);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [isSearchOpen]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+export { Header };
diff --git a/src/big-picture/src/layout/header/styles.scss b/src/big-picture/src/layout/header/styles.scss
new file mode 100644
index 000000000..d16dfba91
--- /dev/null
+++ b/src/big-picture/src/layout/header/styles.scss
@@ -0,0 +1,160 @@
+.header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 80;
+ min-height: var(--big-picture-header-height);
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--big-picture-header-height);
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--background) 33%, transparent) 0%,
+ transparent 100%
+ );
+ }
+}
+
+.header::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--big-picture-header-height);
+ background: linear-gradient(
+ 180deg,
+ rgba(14, 14, 14, 0.5) 0%,
+ rgba(14, 14, 14, 0) 100%
+ );
+ pointer-events: none;
+}
+
+.header__container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+ width: 100%;
+}
+
+.header__action {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 4);
+ padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 6);
+ border: none;
+ background: transparent;
+ color: inherit;
+ outline: none;
+ transition: opacity 0.2s ease-in-out;
+ cursor: pointer;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:focus-visible,
+ &[data-focus-visible] {
+ outline: 2px solid var(--secondary-border);
+ outline-offset: 2px;
+ }
+
+ .header__title {
+ color: var(--primary);
+ text-align: center;
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ }
+}
+
+.header__search {
+ display: flex;
+ align-items: center;
+ padding: calc(var(--spacing-unit) * 7) calc(var(--spacing-unit) * 6);
+ transition: background-color 0.2s ease-in-out;
+ cursor: pointer;
+ position: relative;
+ flex: 1;
+
+ &-icon {
+ position: absolute;
+ z-index: 1;
+
+ &--left {
+ left: calc(var(--spacing-unit) * 6);
+ }
+
+ &--right {
+ right: calc(var(--spacing-unit) * 6);
+ }
+ }
+
+ &:hover:not(&--open) {
+ background-color: rgba(14, 14, 14, 0.8);
+
+ .header__search-icon {
+ opacity: 0.8;
+ }
+ }
+
+ &--open {
+ background-color: var(--surface);
+ box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.6);
+
+ .header__search-input {
+ width: 100%;
+ opacity: 1;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--secondary-border);
+ outline-offset: 2px;
+ }
+}
+
+.header__search-input {
+ height: 100%;
+ width: 0;
+ border: none;
+ outline: none;
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ padding: 0 calc(var(--spacing-unit) * 6);
+ padding-left: 72px;
+ font-size: 16px;
+ font-weight: 500;
+ background: transparent;
+ color: var(--primary);
+ opacity: 0;
+ transition: all 0.2s ease-in-out;
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+ opacity: 0;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .header__search--open &::placeholder {
+ opacity: 1;
+ }
+
+ .header__search--open.header__search--closing &::placeholder {
+ opacity: 0;
+ transform: translateX(24px);
+ }
+}
diff --git a/src/big-picture/src/layout/index.ts b/src/big-picture/src/layout/index.ts
new file mode 100644
index 000000000..c309cf2c9
--- /dev/null
+++ b/src/big-picture/src/layout/index.ts
@@ -0,0 +1,3 @@
+export * from "./navigation";
+export * from "./sidebar";
+export * from "./header";
diff --git a/src/big-picture/src/layout/navigation.ts b/src/big-picture/src/layout/navigation.ts
new file mode 100644
index 000000000..56dc5de46
--- /dev/null
+++ b/src/big-picture/src/layout/navigation.ts
@@ -0,0 +1,56 @@
+export const BIG_PICTURE_APP_LAYER_ID = "big-picture-app-layer";
+export const BIG_PICTURE_SHELL_REGION_ID = "big-picture-shell";
+export const BIG_PICTURE_SIDEBAR_REGION_ID = "big-picture-sidebar";
+export const BIG_PICTURE_CONTENT_REGION_ID = "big-picture-content";
+
+export const BIG_PICTURE_SIDEBAR_ITEM_IDS = {
+ home: "big-picture-sidebar-home",
+ catalogue: "big-picture-sidebar-catalogue",
+ library: "big-picture-sidebar-library",
+ downloads: "big-picture-sidebar-downloads",
+ settings: "big-picture-sidebar-settings",
+} as const;
+
+export type BigPictureSidebarRouteKey =
+ keyof typeof BIG_PICTURE_SIDEBAR_ITEM_IDS;
+
+function normalizePathname(pathname: string) {
+ let end = pathname.length;
+ while (end > 0 && pathname[end - 1] === "/") end--;
+ const withoutTrailingSlash = end === 0 ? "/" : pathname.slice(0, end);
+
+ if (withoutTrailingSlash === "/big-picture") return "/";
+
+ if (withoutTrailingSlash.startsWith("/big-picture/")) {
+ return withoutTrailingSlash.slice("/big-picture".length) || "/";
+ }
+
+ return withoutTrailingSlash;
+}
+
+export function getBigPictureSidebarItemIdFromPathname(pathname: string) {
+ const normalizedPathname = normalizePathname(pathname);
+ const isDev = import.meta.env.DEV;
+
+ if (normalizedPathname.startsWith("/catalogue")) {
+ return isDev
+ ? BIG_PICTURE_SIDEBAR_ITEM_IDS.catalogue
+ : BIG_PICTURE_SIDEBAR_ITEM_IDS.home;
+ }
+
+ if (normalizedPathname.startsWith("/downloads")) {
+ return isDev
+ ? BIG_PICTURE_SIDEBAR_ITEM_IDS.downloads
+ : BIG_PICTURE_SIDEBAR_ITEM_IDS.home;
+ }
+
+ if (normalizedPathname.startsWith("/settings")) {
+ return BIG_PICTURE_SIDEBAR_ITEM_IDS.settings;
+ }
+
+ if (normalizedPathname.startsWith("/library")) {
+ return BIG_PICTURE_SIDEBAR_ITEM_IDS.library;
+ }
+
+ return BIG_PICTURE_SIDEBAR_ITEM_IDS.home;
+}
diff --git a/src/big-picture/src/layout/sidebar/index.tsx b/src/big-picture/src/layout/sidebar/index.tsx
new file mode 100644
index 000000000..1e50edc03
--- /dev/null
+++ b/src/big-picture/src/layout/sidebar/index.tsx
@@ -0,0 +1,206 @@
+import {
+ BookOpenIcon,
+ DownloadSimpleIcon,
+ GearIcon,
+ HouseIcon,
+ MagnifyingGlassIcon,
+ SquaresFourIcon,
+} from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useLocation } from "react-router-dom";
+import {
+ Divider,
+ Input,
+ RouteAnchor,
+ ScrollArea,
+ VerticalFocusGroup,
+} from "../../components";
+import { IS_DESKTOP } from "../../constants";
+import { useLibrary, useSearch } from "../../hooks";
+import type { FocusOverrides } from "../../services";
+import {
+ BIG_PICTURE_CONTENT_REGION_ID,
+ BIG_PICTURE_SIDEBAR_ITEM_IDS,
+ BIG_PICTURE_SIDEBAR_REGION_ID,
+ type BigPictureSidebarRouteKey,
+ getBigPictureSidebarItemIdFromPathname,
+} from "../navigation";
+import "./styles.scss";
+
+function SidebarRouter() {
+ const basePath = IS_DESKTOP ? "/big-picture" : "";
+ const { pathname } = useLocation();
+ const activeSidebarItemId = getBigPictureSidebarItemIdFromPathname(pathname);
+ const sidebarItemNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "block",
+ },
+ right: {
+ type: "region",
+ regionId: BIG_PICTURE_CONTENT_REGION_ID,
+ entryDirection: "right",
+ },
+ };
+
+ const routes = (
+ [
+ {
+ key: "home",
+ label: "Home",
+ path: basePath,
+ icon: HouseIcon,
+ },
+ {
+ key: "catalogue",
+ label: "Catalogue",
+ path: `${basePath}/catalogue`,
+ icon: SquaresFourIcon,
+ },
+ {
+ key: "library",
+ label: "Library",
+ path: `${basePath}/library`,
+ icon: BookOpenIcon,
+ },
+ {
+ key: "downloads",
+ label: "Download",
+ path: `${basePath}/downloads`,
+ icon: DownloadSimpleIcon,
+ },
+ {
+ key: "settings",
+ label: "Settings",
+ path: `${basePath}/settings`,
+ icon: GearIcon,
+ },
+ ] satisfies Array<{
+ key: BigPictureSidebarRouteKey;
+ label: string;
+ path: string;
+ icon: typeof HouseIcon;
+ }>
+ ).filter((route) => {
+ if (import.meta.env.DEV) return true;
+ return route.key !== "catalogue" && route.key !== "downloads";
+ });
+
+ return (
+
+ {routes.map((route) => {
+ const itemId = BIG_PICTURE_SIDEBAR_ITEM_IDS[route.key];
+
+ return (
+ }
+ active={activeSidebarItemId === itemId}
+ focusId={itemId}
+ focusNavigationOverrides={sidebarItemNavigationOverrides}
+ />
+ );
+ })}
+
+ );
+}
+
+function SidebarLibrary() {
+ const { library } = useLibrary();
+
+ const sortedLibrary = useMemo(() => {
+ return [...library].sort(
+ (a, b) =>
+ (b.playTimeInMilliseconds ?? 0) - (a.playTimeInMilliseconds ?? 0)
+ );
+ }, [library]);
+
+ const { filteredItems, search, setSearch } = useSearch(sortedLibrary, [
+ "title",
+ ]);
+
+ return (
+
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ spellCheck={false}
+ autoComplete="off"
+ />
+
+ {/* */}
+
+
+
+
+
+
+ {filteredItems.map((game) => {
+ const desktopPath = `/big-picture/game/${game.shop}/${game.objectId}`;
+
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function SidebarContainer({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ const handleMouseLeave = () => {
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+ };
+
+ return (
+ <>
+
+ {children}
+
+
+
+ >
+ );
+}
+
+function Sidebar() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export { Sidebar };
diff --git a/src/big-picture/src/layout/sidebar/styles.scss b/src/big-picture/src/layout/sidebar/styles.scss
new file mode 100644
index 000000000..cc3a2735d
--- /dev/null
+++ b/src/big-picture/src/layout/sidebar/styles.scss
@@ -0,0 +1,233 @@
+.sidebar-container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ height: 100%;
+ width: 72px;
+ min-width: 72px;
+ max-width: 72px;
+ box-shadow: 4px 0px 16px 0px rgba(0, 0, 0, 0.6);
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--background);
+ z-index: 100;
+
+ &:hover,
+ &:focus-within {
+ width: 300px;
+ min-width: 300px;
+ max-width: 300px;
+ }
+}
+
+.sidebar-router-container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ align-self: stretch;
+ gap: calc(var(--spacing-unit) / 2);
+ padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 3) 0;
+}
+
+.library-container {
+ flex: 1;
+ overflow-y: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ gap: calc(var(--spacing-unit) * 3);
+ padding: 0 calc(var(--spacing-unit) * 3);
+
+ &__header {
+ width: 100%;
+
+ > button:hover {
+ .library-container__header__icon {
+ color: var(--text-primary);
+ }
+ }
+
+ &__icon {
+ color: var(--text-secondary);
+ }
+ }
+
+ &__list-focus-region {
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+
+ > [data-focus-region-id="sidebar-library-list"] {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ }
+ }
+}
+
+.library-list {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-unit);
+
+ &__item {
+ width: 100%;
+ }
+}
+
+.sidebar-profile {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+}
+
+.sidebar-drawer-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 99;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ transition: opacity 0.2s ease-in-out;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.sidebar-container:hover ~ .sidebar-drawer-overlay,
+.sidebar-container:focus-within ~ .sidebar-drawer-overlay {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.sidebar-spacer {
+ position: relative;
+ width: 72px;
+ min-width: 72px;
+ max-width: 72px;
+ margin-right: calc(var(--spacing-unit) * 4);
+}
+
+.sidebar-container:not(:hover):not(:focus-within) {
+ .state-wrapper,
+ .route-anchor {
+ width: 48px;
+ }
+
+ .route-anchor__label,
+ .route-anchor__favorite {
+ display: none;
+ }
+
+ .tooltip-trigger {
+ &[data-state="closed"] .tooltip-content,
+ &[data-state="delayed-open"] .tooltip-content {
+ display: flex;
+ }
+ }
+
+ .input {
+ padding-left: 0;
+ padding-right: 0;
+ width: 44px;
+ background-color: var(--secondary);
+ border-color: transparent;
+ color: transparent;
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+
+ .library-container__header button {
+ display: none;
+ }
+
+ .user-profile-content__info,
+ .user-profile__actions__notification,
+ .user-profile__actions__friends__count__text {
+ display: none;
+ }
+}
+
+.route-anchor {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 3);
+ padding: calc(var(--spacing-unit) * 2);
+ border-radius: var(--spacing-unit);
+ outline: none;
+ transition:
+ background-color 0.2s ease-in-out,
+ box-shadow 0.2s ease-in-out;
+ width: 100%;
+ color: var(--text);
+ user-select: none;
+
+ &--extra-padding {
+ padding: calc(var(--spacing-unit) * 3);
+ }
+
+ &--active {
+ color: var(--primary);
+ background-color: var(--secondary-hover);
+ }
+
+ &:not(&--active):hover,
+ &:not(&--active)[data-focus-visible="true"] {
+ background-color: var(--secondary);
+ }
+
+ &[data-focus-visible="true"] {
+ box-shadow: inset 0 0 0 1px var(--text-secondary);
+ }
+
+ &__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: calc(var(--spacing-unit) / 2);
+ flex-shrink: 0;
+ overflow: hidden;
+
+ &--small-size {
+ width: 24px;
+ height: 24px;
+ }
+ }
+
+ &__label {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ line-clamp: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.state-wrapper {
+ width: 100%;
+
+ &--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &--active {
+ pointer-events: none;
+ }
+}
+
+.divider-container {
+ padding: 32px calc(var(--spacing-unit) * 3.5);
+}
diff --git a/src/big-picture/src/main.tsx b/src/big-picture/src/main.tsx
new file mode 100644
index 000000000..65f06e6e7
--- /dev/null
+++ b/src/big-picture/src/main.tsx
@@ -0,0 +1,48 @@
+import ReactDOM from "react-dom/client";
+import { StrictMode } from "react";
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+import { BrowserRouter, Route, Routes } from "react-router-dom";
+import resources from "@locales";
+import App from "./app";
+import Catalogue from "./pages/catalogue/catalogue";
+import Downloads from "./pages/downloads/downloads";
+import Game from "./pages/game/game";
+import Home from "./pages/home/home";
+import LibraryPage from "./pages/library/page";
+import Settings from "./pages/settings/settings";
+
+const rootElement = document.getElementById("root");
+
+if (!rootElement) {
+ throw new Error("Big Picture root element was not found.");
+}
+
+void i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: "en",
+ interpolation: {
+ escapeValue: false,
+ },
+ });
+
+ReactDOM.createRoot(rootElement).render(
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+);
diff --git a/src/big-picture/src/pages/catalogue/catalogue.tsx b/src/big-picture/src/pages/catalogue/catalogue.tsx
new file mode 100644
index 000000000..660422c70
--- /dev/null
+++ b/src/big-picture/src/pages/catalogue/catalogue.tsx
@@ -0,0 +1,480 @@
+import { useState, type ReactNode } from "react";
+import {
+ Accordion,
+ Button,
+ Checkbox,
+ Chip,
+ Divider,
+ HorizontalCard,
+ ImageLightbox,
+ Input,
+ ListCard,
+ Modal,
+ RouteAnchor,
+ ScrollArea,
+ SourceAnchor,
+ Tooltip,
+ Typography,
+ UserProfile,
+ VerticalGameCard,
+} from "../../components";
+import {
+ Books,
+ CheckCircle,
+ CloudArrowDown,
+ DownloadSimple,
+ DotsThreeVertical,
+ GameController,
+ House,
+ MagnifyingGlass,
+ Play,
+ PlusCircle,
+ Plus,
+ Star,
+ XCircle,
+} from "@phosphor-icons/react";
+import { formatPlayedTime } from "../../helpers";
+import "./page.scss";
+
+const CARD_IMAGE =
+ "https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&w=700&q=80";
+const ALT_CARD_IMAGE =
+ "https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&w=700&q=80";
+const PROFILE_IMAGE =
+ "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=160&q=80";
+const POSTER_IMAGE =
+ "https://images.unsplash.com/photo-1511512578047-dfb367046420?auto=format&fit=crop&w=900&q=80";
+const HOVER_POSTER_IMAGE =
+ "https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&w=900&q=80";
+
+interface ShowcaseSectionProps {
+ title: string;
+ description: string;
+ children: ReactNode;
+}
+
+function ShowcaseSection({
+ title,
+ description,
+ children,
+}: Readonly) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {children}
+
+ );
+}
+
+export default function Catalogue() {
+ const [checked, setChecked] = useState(true);
+ const [blockChecked, setBlockChecked] = useState(true);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false);
+ const [chips, setChips] = useState([
+ { label: "Library", color: "#8aeb13" },
+ { label: "Cloud", color: "#67e8f9" },
+ { label: "Beta", color: "#f3c611" },
+ ]);
+
+ const restoreChips = () => {
+ setChips([
+ { label: "Library", color: "#8aeb13" },
+ { label: "Cloud", color: "#67e8f9" },
+ { label: "Beta", color: "#f3c611" },
+ ]);
+ };
+
+ return (
+
+
+
+ Big Picture UI Kit
+
+ Component Catalogue
+
+ Estados e variações dos componentes migrados para o Big Picture.
+
+
+
+
+
+
+ Heading 1
+ Heading 2
+ Heading 3
+ Heading 4
+ Heading 5
+
+ Body text keeps longer descriptions readable in dense screens.
+
+ Label text
+
+
+
+
+
+
+
+
+ }>
+ Rounded
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ iconRight={}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing search results for hydra
+
+
+
+
Without background
+
+ {chips.map((chip) => (
+
+ setChips((current) =>
+ current.filter((item) => item.label !== chip.label)
+ )
+ }
+ />
+ ))}
+
+
+
+
+
With background
+
+ {chips.map((chip) => (
+
+ setChips((current) =>
+ current.filter((item) => item.label !== chip.label)
+ )
+ }
+ />
+ ))}
+
+ {chips.length > 0 && (
+
+ )}
+
+ {chips.length === 0 && (
+
+ )}
+
+
+
+
+
+
+
+ {[
+ "library filters",
+ "download options",
+ "cloud sync",
+ "controller input",
+ "visual state",
+ "account actions",
+ ].map((title, index) => (
+
+
+
+ Content area for {title}, using the same surface and expand
+ animation as the migrated component.
+
+
+
+ ))}
+
+
+
+
+
+ }
+ active
+ isFavorite
+ />
+ }
+ />
+ }
+ disabled
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+ Open}
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+ Scroll item {index + 1}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsModalOpen(false)}>
+
+
Modal
+
+ Overlay component with backdrop, outside click and Escape close.
+
+
+
+
+
+
+
+
+
+ {isLightboxOpen && (
+
+ )}
+ {isLightboxOpen && }
+
+
+
+ Components only
+
+
+ );
+}
diff --git a/src/big-picture/src/pages/catalogue/page.scss b/src/big-picture/src/pages/catalogue/page.scss
new file mode 100644
index 000000000..73cd3aca0
--- /dev/null
+++ b/src/big-picture/src/pages/catalogue/page.scss
@@ -0,0 +1,286 @@
+.catalogue-page {
+ width: 100%;
+ height: 100%;
+ overflow-y: auto;
+ padding: calc(var(--spacing-unit) * 10) calc(var(--spacing-unit) * 8);
+ color: var(--text);
+ background: var(--background);
+
+ &__header {
+ max-width: 980px;
+ margin: 0 auto calc(var(--spacing-unit) * 10);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 3);
+
+ > p:not(.catalogue-page__eyebrow) {
+ max-width: 560px;
+ color: var(--text-secondary);
+ }
+ }
+
+ &__eyebrow {
+ color: var(--primary);
+ text-transform: uppercase;
+ }
+
+ &__sections {
+ max-width: 980px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 5);
+ }
+
+ &__section {
+ border: 1px solid var(--secondary-border);
+ border-radius: calc(var(--spacing-unit) * 3);
+ background-color: var(--background);
+ overflow: visible;
+ }
+
+ &__section-header {
+ padding: calc(var(--spacing-unit) * 5);
+ border-bottom: 1px solid var(--secondary-border);
+ background-color: rgba(255, 255, 255, 0.02);
+ display: flex;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 6);
+
+ > p {
+ max-width: 420px;
+ color: var(--text-secondary);
+ text-align: right;
+ }
+ }
+
+ &__section-content {
+ padding: calc(var(--spacing-unit) * 5);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ }
+
+ &__component-row,
+ &__block-row,
+ &__input-row,
+ &__cards {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: calc(var(--spacing-unit) * 3);
+ }
+
+ &__input-row {
+ align-items: flex-start;
+
+ > .input-container {
+ width: min(230px, 100%);
+ }
+ }
+
+ &__block-row {
+ align-items: stretch;
+
+ > .checkbox {
+ width: min(320px, 100%);
+ }
+ }
+
+ &__typography-sample {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+
+ &__filter-preview {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+
+ &__filter-title {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 18px;
+ font-weight: 500;
+ }
+
+ &__filter-list {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: calc(var(--spacing-unit) * 2);
+ }
+
+ &__filter-row {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ padding: calc(var(--spacing-unit) * 3);
+ border: 1px solid var(--secondary-border);
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: rgba(255, 255, 255, 0.02);
+
+ > p {
+ color: var(--text-secondary);
+ }
+
+ &--solid {
+ background-color: rgba(255, 255, 255, 0.035);
+ }
+ }
+
+ &__clear-filters {
+ padding: calc(var(--spacing-unit) * 2);
+ color: var(--text);
+ cursor: pointer;
+ transition: opacity 0.2s ease-in-out;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ p {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+ }
+
+ &__narrow,
+ &__anchor-stack {
+ width: min(460px, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 3);
+ }
+
+ &__accordion-content {
+ > p {
+ color: var(--text-secondary);
+ line-height: 1.5;
+ }
+ }
+
+ &__cards {
+ align-items: stretch;
+
+ > :not(.vertical-game-card) {
+ width: min(420px, 100%);
+ }
+
+ > .user-profile-container {
+ max-width: 420px;
+ }
+ }
+
+ &__scroll-area {
+ width: min(520px, 100%);
+ height: 220px;
+ border: 1px solid var(--secondary-border);
+ border-radius: calc(var(--spacing-unit) * 2);
+ background-color: var(--background);
+ }
+
+ &__scroll-item {
+ min-height: 44px;
+ padding: 0 calc(var(--spacing-unit) * 4);
+ border-bottom: 1px solid var(--secondary-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--text-secondary);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+ }
+
+ &__divider-composed-sample {
+ width: min(360px, 100%);
+ height: 116px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: calc(var(--spacing-unit) * 4);
+ color: var(--text-secondary);
+ }
+
+ &__divider-composed-left {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: center;
+ gap: calc(var(--spacing-unit) * 3);
+ }
+
+ &__modal-content {
+ width: min(420px, calc(100vw - calc(var(--spacing-unit) * 12)));
+ padding: calc(var(--spacing-unit) * 6);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 5);
+
+ > p {
+ color: var(--text-secondary);
+ }
+ }
+
+ &__lightbox-close {
+ position: fixed;
+ top: calc(var(--spacing-unit) * 6);
+ right: calc(var(--spacing-unit) * 6);
+ z-index: 10000;
+ width: 42px;
+ height: 42px;
+ border: 0;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--secondary);
+ color: var(--text);
+ cursor: pointer;
+ }
+
+ &__floating-note {
+ position: fixed;
+ right: calc(var(--spacing-unit) * 5);
+ bottom: calc(var(--spacing-unit) * 5);
+ display: inline-flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 2);
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
+ border: 1px solid var(--secondary-border);
+ border-radius: 999px;
+ background-color: var(--background);
+ color: var(--text-secondary);
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 760px) {
+ .catalogue-page {
+ padding: calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 4);
+
+ &__section-header {
+ flex-direction: column;
+
+ > p {
+ text-align: left;
+ }
+ }
+
+ &__input-row > .input-container,
+ &__block-row > .checkbox,
+ &__cards > * {
+ width: 100%;
+ }
+
+ &__floating-note {
+ display: none;
+ }
+ }
+}
diff --git a/src/big-picture/src/pages/downloads/downloads.tsx b/src/big-picture/src/pages/downloads/downloads.tsx
new file mode 100644
index 000000000..d314aef0f
--- /dev/null
+++ b/src/big-picture/src/pages/downloads/downloads.tsx
@@ -0,0 +1,7 @@
+export default function Downloads() {
+ return (
+
+ );
+}
diff --git a/src/big-picture/src/pages/game/game.scss b/src/big-picture/src/pages/game/game.scss
new file mode 100644
index 000000000..535d3f426
--- /dev/null
+++ b/src/big-picture/src/pages/game/game.scss
@@ -0,0 +1,596 @@
+.game-page {
+ height: 100%;
+ overflow-y: auto;
+
+ &__how-long-to-beat-duration {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ flex: 1;
+ height: 91px;
+ min-height: 91px;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ &__content {
+ padding: calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 24);
+ }
+
+ &__main-layout {
+ display: flex;
+ gap: 16px;
+ padding: 64px 0;
+
+ #game-screenshot-carousel-prev-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-screenshot-carousel-next-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+ }
+
+ &__main-column {
+ flex: 1;
+ }
+
+ &__sidebar-stats {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__sidebar {
+ width: 500px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__how-long-to-beat-title {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-bottom-left-radius: 12px;
+ border-bottom-right-radius: 12px;
+ }
+
+ &__playtime-bar {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #0e0e0e;
+ height: 76px;
+ border-radius: 8px;
+ padding: calc(var(--spacing-unit) * 4);
+ }
+
+ &__hero {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ transform-origin: center;
+ z-index: 0;
+ }
+
+ &__hero-actions {
+ display: flex;
+ gap: 16px;
+
+ & .divider-container {
+ padding: 0;
+ }
+ }
+
+ &__hero-overlay {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-end;
+ height: 100%;
+ background:
+ linear-gradient(
+ 90deg,
+ #080808 0%,
+ rgba(8, 8, 8, 0) 25%,
+ rgba(8, 8, 8, 0) 75%,
+ #080808 99.53%
+ ),
+ linear-gradient(180deg, rgba(8, 8, 8, 0) 0%, #080808 97%);
+ padding: calc(var(--spacing-unit) * 12) calc(var(--spacing-unit) * 24);
+ gap: calc(var(--spacing-unit) * 6);
+ }
+
+ &__achievements-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #0e0e0e;
+ border-radius: 8px;
+ padding: 8px;
+ }
+
+ &__achievement-icon-locked {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background-color: rgba(8, 8, 8, 1);
+ border-radius: 4px;
+ }
+
+ &__achievement-box {
+ display: flex;
+ align-items: center;
+ justify-content: start;
+ gap: 16px;
+
+ &-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ }
+
+ &-link {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ justify-content: start;
+ gap: 4px;
+ }
+
+ span {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 14px;
+ }
+ }
+
+ &__achievement {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing-unit) * 4);
+ }
+
+ &__achievement-icon {
+ width: 56px;
+ height: 56px;
+ border-radius: 4px;
+ box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+ }
+
+ &__achievement-icon--locked {
+ filter: grayscale(100%);
+ }
+
+ &__achievement-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-unit);
+ }
+
+ &__detailed-description {
+ color: rgba(255, 255, 255, 0.8);
+
+ img,
+ video {
+ border-radius: 5px;
+ margin-top: calc(var(--spacing-unit) * 3);
+ margin-bottom: calc(var(--spacing-unit) * 3);
+ max-width: 100%;
+ height: auto;
+ display: block;
+ object-fit: contain;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ a {
+ color: var(--body-color);
+ }
+
+ .bb_tag {
+ margin-top: calc(var(--spacing-unit) * 2);
+ margin-bottom: calc(var(--spacing-unit) * 2);
+ }
+ }
+
+ &__box-group {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 2);
+ width: 100%;
+
+ > * {
+ border-radius: 8px;
+ }
+
+ > *:first-child:not(:last-child) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ > *:last-child:not(:first-child) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ > *:not(:first-child):not(:last-child) {
+ border-radius: 0;
+ }
+
+ #game-stats-title {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-how-long-to-beat-title {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-achievements-title {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-achievements-view-all {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-requirements-to-play-minimum-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fbbf24 !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-requirements-to-play-recommended-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-supported-languages-title {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-supported-languages-last-row {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-reviews-primary-filter-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-reviews-secondary-filter-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ #game-reviews-third-filter-button {
+ &[data-focused="true"] {
+ outline: 2px solid #fff !important;
+ outline-offset: -2px;
+ }
+ }
+
+ [id^="game-review-vote-button-upvote-"][data-focused="true"] {
+ outline: 2px solid #fbbf24 !important;
+ outline-offset: -2px;
+ }
+
+ [id^="game-review-vote-button-downvote-"][data-focused="true"] {
+ outline: 2px solid #f87171 !important;
+ outline-offset: -2px;
+ }
+ }
+
+ &__requirements {
+ ul {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ }
+ }
+
+ &__requirements-to-play-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+ }
+
+ &__requirements-to-play-title {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #0e0e0e;
+ border-top-left-radius: 8px;
+ padding: 8px;
+ }
+
+ &__requirements-to-play-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+
+ .button {
+ height: 100%;
+ border-radius: 0;
+ }
+
+ :last-child .button {
+ border-top-right-radius: 12px;
+ }
+ }
+
+ &__requirements-to-play-content {
+ > strong:first-child,
+ br {
+ display: none;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ }
+
+ > ul strong {
+ color: rgba(255, 255, 255, 0.5);
+ font-weight: normal;
+ }
+
+ li {
+ display: list-item;
+ }
+ }
+
+ &__languages-title {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #0e0e0e;
+ padding: 8px;
+ }
+
+ &__languages-labels {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &__languages-label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 72px;
+ background-color: #0e0e0e;
+ padding: 8px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 12px;
+ }
+
+ &__languages {
+ padding: 0;
+ }
+
+ &__languages-content {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__languages-row {
+ display: flex;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ &__languages-cell {
+ padding: 8px 12px;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ flex: 1;
+
+ &--language {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &--center {
+ justify-content: center;
+ flex: 0 0 72px;
+ }
+ }
+
+ &__languages-cross {
+ opacity: 0.3;
+ }
+
+ &__reviews-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: #0e0e0e;
+ padding: 8px;
+ }
+
+ &__review-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ }
+
+ &__review-user {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__review-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+
+ &--placeholder {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+ }
+
+ &__review-user-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ &__review-display-name {
+ font-weight: 600;
+ font-size: 13px;
+ }
+
+ &__review-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 11px;
+ }
+
+ &__review-score {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #fbbf24;
+ }
+
+ &__review-playtime {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &__review-date {
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 11px;
+ flex-shrink: 0;
+ }
+
+ &__review-content {
+ font-size: 13px;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.8);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+
+ p {
+ margin: 0;
+ }
+ }
+
+ &__reviews-sort {
+ display: flex;
+ gap: 4px;
+ padding: 4px 0;
+ }
+
+ &__review-votes {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__review-vote-button {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 12px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &--active {
+ color: #4ade80;
+ border-color: rgba(74, 222, 128, 0.3);
+ }
+
+ &--active-down {
+ color: #f87171;
+ border-color: rgba(248, 113, 113, 0.3);
+ }
+ }
+
+ &__legal-notice {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 12px;
+ }
+}
diff --git a/src/big-picture/src/pages/game/game.tsx b/src/big-picture/src/pages/game/game.tsx
new file mode 100644
index 000000000..07785ddf6
--- /dev/null
+++ b/src/big-picture/src/pages/game/game.tsx
@@ -0,0 +1,166 @@
+import { formatNumber } from "@renderer/helpers";
+import type { GameShop } from "@types";
+import { useParams } from "react-router-dom";
+import {
+ Divider,
+ FocusItem,
+ HorizontalFocusGroup,
+ SingleLineBox,
+ TitleBox,
+ VerticalFocusGroup,
+} from "../../components";
+import {
+ AchievementsBox,
+ GameReviews,
+ Hero,
+ HowLongToBeatBox,
+ PlaytimeBar,
+ RequirementsToPlay,
+ ScreenshotCarousel,
+ SupportedLanguages,
+} from "../../components/pages/game";
+import {
+ GAME_HERO_TOGGLE_FAVORITE_ID,
+ GAME_HOW_LONG_TO_BEAT_TITLE_ID,
+ GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ GAME_STATS_REGION_ID,
+ GAME_STATS_TITLE_ID,
+} from "../../components/pages/game/navigation";
+import { useGameDetails } from "../../hooks";
+import { FocusOverrides } from "../../services/navigation.service";
+import "./game.scss";
+
+export default function Game() {
+ const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>();
+ const {
+ shopDetails,
+ game,
+ stats,
+ isGameRunning,
+ isLoading,
+ howLongToBeat,
+ achievements,
+ openGame,
+ closeGame,
+ toggleFavorite,
+ } = useGameDetails(objectId!, shop!);
+
+ if (isLoading || !shopDetails) {
+ return (
+
+ );
+ }
+
+ const statsNavigationOverrides: FocusOverrides = {
+ up: {
+ type: "item",
+ itemId: GAME_HERO_TOGGLE_FAVORITE_ID,
+ },
+ left: {
+ type: "item",
+ itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
+ },
+ right: {
+ type: "block",
+ },
+ down: {
+ type: "item",
+ itemId: GAME_HOW_LONG_TO_BEAT_TITLE_ID,
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/pages/home/hero/index.tsx b/src/big-picture/src/pages/home/hero/index.tsx
new file mode 100644
index 000000000..a77864d58
--- /dev/null
+++ b/src/big-picture/src/pages/home/hero/index.tsx
@@ -0,0 +1,251 @@
+import {
+ DownloadSimpleIcon,
+ PlayIcon,
+ PlusCircleIcon,
+} from "@phosphor-icons/react";
+import { useCallback, useEffect, useState } from "react";
+import type { TrendingGame } from "@types";
+import { useNavigate } from "react-router-dom";
+import cn from "classnames";
+import {
+ AnimatedHeroImage,
+ Button,
+ HorizontalFocusGroup,
+} from "../../../components";
+import { useLibraryLaunchGame } from "../../../components/pages/library/use-library-launch-game";
+import { useHeroBackgroundLayers } from "../../../components/pages/library/hero/use-hero-background-layers";
+import { getBigPictureGameDetailsPath } from "../../../helpers";
+import { useDominantColor, useLibraryGameState } from "../../../hooks";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../layout";
+import type { FocusOverrideTarget, FocusOverrides } from "../../../services";
+import {
+ HOME_HERO_ACTIONS_REGION_ID,
+ HOME_HERO_ADD_TO_LIBRARY_ID,
+ HOME_HERO_DOWNLOAD_ID,
+ HOME_HERO_OPEN_GAME_PAGE_ID,
+} from "../navigation";
+
+import "./styles.scss";
+
+interface HomePageHeroProps {
+ featuredGame: TrendingGame | null;
+ downNavigationTarget?: FocusOverrideTarget;
+}
+
+export function HomePageHero({
+ featuredGame,
+ downNavigationTarget,
+}: Readonly) {
+ const navigate = useNavigate();
+ const { updateLibrary, ...gameState } = useLibraryGameState(
+ featuredGame?.shop,
+ featuredGame?.objectId
+ );
+ const launchGame = useLibraryLaunchGame(
+ useCallback(() => {
+ console.log("home-hero download");
+ }, [])
+ );
+ const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
+ const [shouldShowLogoFallback, setShouldShowLogoFallback] = useState(false);
+ const { backgroundLayers, getLayerEventHandlers } = useHeroBackgroundLayers(
+ featuredGame?.libraryHeroImageUrl
+ );
+ const dominantColor = useDominantColor(
+ featuredGame?.libraryHeroImageUrl ?? null
+ );
+ const isInLibrary = gameState.isInLibrary;
+ const secondActionFocusId = isInLibrary
+ ? HOME_HERO_DOWNLOAD_ID
+ : HOME_HERO_ADD_TO_LIBRARY_ID;
+
+ useEffect(() => {
+ updateLibrary();
+ }, [updateLibrary]);
+
+ useEffect(() => {
+ setShouldShowLogoFallback(false);
+ }, [featuredGame?.logoImageUrl]);
+
+ const openGamePage = () => {
+ if (!featuredGame) return;
+ void navigate(
+ getBigPictureGameDetailsPath({
+ shop: featuredGame.shop,
+ objectId: featuredGame.objectId,
+ title: featuredGame.title,
+ })
+ );
+ };
+
+ const handleDownloadOrPlayClick = () => {
+ if (!gameState.libraryGame) {
+ console.log("home-hero download");
+ return;
+ }
+ void launchGame(gameState.libraryGame);
+ };
+
+ const handleAddToLibrary = async () => {
+ if (!featuredGame || isInLibrary || isAddingToLibrary) return;
+
+ setIsAddingToLibrary(true);
+
+ try {
+ await globalThis.window.electron.addGameToLibrary(
+ featuredGame.shop,
+ featuredGame.objectId,
+ featuredGame.title
+ );
+ await updateLibrary();
+ } finally {
+ setIsAddingToLibrary(false);
+ }
+ };
+
+ if (!featuredGame) return null;
+
+ const heroDownNavigationTarget: FocusOverrideTarget =
+ downNavigationTarget ?? {
+ type: "block",
+ };
+
+ const addToLibraryNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: HOME_HERO_OPEN_GAME_PAGE_ID,
+ },
+ right: {
+ type: "block",
+ },
+ down: heroDownNavigationTarget,
+ };
+
+ const downloadOrPlayNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: HOME_HERO_OPEN_GAME_PAGE_ID,
+ },
+ right: {
+ type: "block",
+ },
+ down: heroDownNavigationTarget,
+ };
+
+ const openGamePageNavigationOverrides: FocusOverrides = {
+ left: {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ right: {
+ type: "item",
+ itemId: secondActionFocusId,
+ },
+ down: heroDownNavigationTarget,
+ };
+
+ return (
+
+ {backgroundLayers.map((layer) => {
+ const layerHandlers = getLayerEventHandlers(layer);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {featuredGame.logoImageUrl && !shouldShowLogoFallback ? (
+

setShouldShowLogoFallback(true)}
+ />
+ ) : (
+
+ {featuredGame.title}
+
+ )}
+
+
+ {featuredGame.description && (
+
+ {featuredGame.description}
+
+ )}
+
+
+
+
+ {!isInLibrary ? (
+ }
+ onClick={() => void handleAddToLibrary()}
+ loading={isAddingToLibrary}
+ size="large"
+ variant="secondary"
+ >
+ Add to Library
+
+ ) : gameState.hasExecutable ? (
+ }
+ onClick={handleDownloadOrPlayClick}
+ size="large"
+ variant="primary"
+ >
+ Launch Game
+
+ ) : (
+ }
+ onClick={handleDownloadOrPlayClick}
+ size="large"
+ variant="primary"
+ >
+ Launch Game
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/big-picture/src/pages/home/hero/styles.scss b/src/big-picture/src/pages/home/hero/styles.scss
new file mode 100644
index 000000000..1bd23fd6c
--- /dev/null
+++ b/src/big-picture/src/pages/home/hero/styles.scss
@@ -0,0 +1,148 @@
+.home-page-hero {
+ position: relative;
+ width: 100%;
+ height: 750px;
+ overflow: hidden;
+ border: none;
+ background: var(--background);
+ color: var(--text);
+ box-shadow: 0 0 0 0 transparent;
+}
+
+.home-page-hero__bg-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ transition: opacity 0.3s cubic-bezier(0.33, 1, 0.68, 1);
+ pointer-events: none;
+}
+
+.home-page-hero__bg-layer--base {
+ opacity: 1;
+}
+
+.home-page-hero__bg-layer--incoming {
+ z-index: 1;
+ opacity: 0;
+}
+
+.home-page-hero__bg-layer--visible {
+ opacity: 1;
+}
+
+.home-page-hero__bg {
+ width: 100%;
+ height: 100%;
+}
+
+.home-page-hero__overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ background:
+ linear-gradient(
+ 90deg,
+ var(--background) 0%,
+ transparent 25% 75%,
+ var(--background) 99.53%
+ ),
+ linear-gradient(transparent 0%, var(--background) 97%);
+}
+
+.home-page-hero__content {
+ position: absolute;
+ inset: auto 0 0;
+ z-index: 3;
+ display: flex;
+ align-items: flex-end;
+ flex-direction: row;
+ justify-content: space-between;
+ gap: calc(var(--spacing-unit) * 12);
+ padding: 0 calc(var(--spacing-unit) * 16) calc(var(--spacing-unit) * 16);
+}
+
+.home-page-hero__main {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 10);
+ max-width: 560px;
+ min-width: 0;
+}
+
+.home-page-hero__logo {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ width: 520px;
+ max-width: 100%;
+ height: 260px;
+}
+
+.home-page-hero__logo-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ object-position: left center;
+}
+
+.home-page-hero__logo-fallback {
+ max-width: 100%;
+ color: var(--primary);
+ font-size: 56px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.home-page-hero__description {
+ max-width: 520px;
+ color: var(--text);
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 1.35;
+}
+
+.home-page-hero__actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: calc(var(--spacing-unit) * 4);
+}
+
+@media (max-width: 1100px) {
+ .home-page-hero {
+ height: 820px;
+ }
+
+ .home-page-hero__content {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(var(--spacing-unit) * 8);
+ }
+}
+
+@media (max-width: 720px) {
+ .home-page-hero {
+ height: 900px;
+ }
+
+ .home-page-hero__content {
+ padding: 0 calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 6);
+ }
+
+ .home-page-hero__main {
+ max-width: 100%;
+ width: 100%;
+ }
+
+ .home-page-hero__logo {
+ width: min(100%, 520px);
+ height: auto;
+ aspect-ratio: 520 / 260;
+ }
+
+ .home-page-hero__actions {
+ flex-wrap: wrap;
+ }
+}
diff --git a/src/big-picture/src/pages/home/hero/use-featured-game.ts b/src/big-picture/src/pages/home/hero/use-featured-game.ts
new file mode 100644
index 000000000..308ec4747
--- /dev/null
+++ b/src/big-picture/src/pages/home/hero/use-featured-game.ts
@@ -0,0 +1,55 @@
+import type { TrendingGame } from "@types";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IS_DESKTOP } from "../../../constants";
+import { normalizeTrendingGame } from "../home-data";
+
+export function useFeaturedGame() {
+ const [featuredGame, setFeaturedGame] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const { i18n } = useTranslation();
+
+ useEffect(() => {
+ if (!IS_DESKTOP) {
+ setFeaturedGame(null);
+ return;
+ }
+
+ let isMounted = true;
+ const language = i18n.language.split("-")[0];
+
+ setIsLoading(true);
+
+ globalThis.window.electron.hydraApi
+ .get("/catalogue/featured", {
+ params: { language },
+ needsAuth: false,
+ })
+ .then((response) => {
+ if (!isMounted) return;
+
+ const games = Array.isArray(response) ? response : [];
+
+ setFeaturedGame(normalizeTrendingGame(games[0]));
+ })
+ .catch(() => {
+ if (!isMounted) return;
+
+ setFeaturedGame(null);
+ })
+ .finally(() => {
+ if (!isMounted) return;
+
+ setIsLoading(false);
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [i18n.language]);
+
+ return {
+ featuredGame,
+ isLoading,
+ };
+}
diff --git a/src/big-picture/src/pages/home/home-data.ts b/src/big-picture/src/pages/home/home-data.ts
new file mode 100644
index 000000000..d39b28030
--- /dev/null
+++ b/src/big-picture/src/pages/home/home-data.ts
@@ -0,0 +1,72 @@
+import type { GameShop, ShopAssets, TrendingGame } from "@types";
+
+type UnknownRecord = Record;
+
+const isRecord = (value: unknown): value is UnknownRecord => {
+ return typeof value === "object" && value !== null;
+};
+
+const isGameShop = (value: unknown): value is GameShop => {
+ return value === "steam" || value === "custom";
+};
+
+const readString = (value: unknown): string | null => {
+ return typeof value === "string" && value.length > 0 ? value : null;
+};
+
+const readNullableString = (value: unknown): string | null => {
+ return typeof value === "string" ? value : null;
+};
+
+export function normalizeShopAssets(value: unknown): ShopAssets | null {
+ if (!isRecord(value)) return null;
+
+ const objectId = readString(value.objectId);
+ const title = readString(value.title);
+ const { shop } = value;
+
+ if (!objectId || !title || !isGameShop(shop)) {
+ return null;
+ }
+
+ return {
+ objectId,
+ shop,
+ title,
+ iconUrl: readNullableString(value.iconUrl),
+ libraryHeroImageUrl: readNullableString(value.libraryHeroImageUrl),
+ libraryImageUrl: readNullableString(value.libraryImageUrl),
+ logoImageUrl: readNullableString(value.logoImageUrl),
+ logoPosition: readNullableString(value.logoPosition),
+ coverImageUrl: readNullableString(value.coverImageUrl),
+ downloadSources: Array.isArray(value.downloadSources)
+ ? value.downloadSources.filter(
+ (source): source is string => typeof source === "string"
+ )
+ : [],
+ };
+}
+
+export function normalizeShopAssetsList(value: unknown): ShopAssets[] {
+ if (!Array.isArray(value)) return [];
+
+ return value.flatMap((game) => {
+ const normalizedGame = normalizeShopAssets(game);
+
+ return normalizedGame ? [normalizedGame] : [];
+ });
+}
+
+export function normalizeTrendingGame(value: unknown): TrendingGame | null {
+ if (!isRecord(value)) return null;
+
+ const assets = normalizeShopAssets(value);
+
+ if (!assets) return null;
+
+ return {
+ ...assets,
+ description: readNullableString(value.description),
+ uri: readNullableString(value.uri) ?? "",
+ };
+}
diff --git a/src/big-picture/src/pages/home/home.tsx b/src/big-picture/src/pages/home/home.tsx
new file mode 100644
index 000000000..49b629475
--- /dev/null
+++ b/src/big-picture/src/pages/home/home.tsx
@@ -0,0 +1,121 @@
+import { HomePageHero } from "./hero";
+import { useFeaturedGame } from "./hero/use-featured-game";
+import type { ShopAssets } from "@types";
+import { useMemo } from "react";
+import {
+ getAchievementsGameFocusId,
+ getPopularGameFocusId,
+ getWeeklyGameFocusId,
+ HOME_ACHIEVEMENTS_GAMES_ROW_REGION_ID,
+ HOME_PAGE_REGION_ID,
+ HOME_POPULAR_GAMES_ROW_REGION_ID,
+ HOME_WEEKLY_GAMES_ROW_REGION_ID,
+} from "./navigation";
+import { PopularGames } from "./popular-games";
+import { usePopularGames } from "./use-popular-games";
+import { VerticalFocusGroup } from "../../components";
+import type { FocusOverrideTarget } from "../../services";
+
+import "./page.scss";
+
+interface HomeGamesRow {
+ key: "popular" | "weekly" | "achievements";
+ title: string;
+ games: ShopAssets[];
+ rowId: string;
+ getFocusId: (game: Pick) => string;
+}
+
+export default function Home() {
+ const { featuredGame, isLoading: isFeaturedLoading } = useFeaturedGame();
+ const { popularGames, gamesOfTheWeek, gamesToBeat } = usePopularGames();
+ const visibleRows = useMemo(() => {
+ const rows: HomeGamesRow[] = [
+ {
+ key: "popular",
+ title: "Popular Games",
+ games: popularGames,
+ rowId: HOME_POPULAR_GAMES_ROW_REGION_ID,
+ getFocusId: getPopularGameFocusId,
+ },
+ {
+ key: "weekly",
+ title: "Games of the Week",
+ games: gamesOfTheWeek,
+ rowId: HOME_WEEKLY_GAMES_ROW_REGION_ID,
+ getFocusId: getWeeklyGameFocusId,
+ },
+ {
+ key: "achievements",
+ title: "Games to Beat",
+ games: gamesToBeat,
+ rowId: HOME_ACHIEVEMENTS_GAMES_ROW_REGION_ID,
+ getFocusId: getAchievementsGameFocusId,
+ },
+ ];
+
+ return rows.filter((row) => row.games.length > 0);
+ }, [gamesOfTheWeek, gamesToBeat, popularGames]);
+
+ const getVisibleRowGameIdByIndex = (
+ row: HomeGamesRow | undefined,
+ gameIndex: number
+ ) => {
+ if (!row) return null;
+
+ const game = row.games[Math.min(gameIndex, row.games.length - 1)];
+
+ return game ? row.getFocusId(game) : null;
+ };
+
+ const heroDownNavigationTarget: FocusOverrideTarget | undefined =
+ visibleRows[0]
+ ? {
+ type: "region",
+ regionId: visibleRows[0].rowId,
+ entryDirection: "right",
+ }
+ : undefined;
+
+ return (
+
+
+
+ {!isFeaturedLoading && (
+ <>
+ {visibleRows.map((row, rowIndex) => {
+ const previousRow = visibleRows[rowIndex - 1];
+ const nextRow = visibleRows[rowIndex + 1];
+
+ return (
+
+ getVisibleRowGameIdByIndex(previousRow, gameIndex)
+ : undefined
+ }
+ getDownFocusId={
+ nextRow
+ ? (gameIndex) =>
+ getVisibleRowGameIdByIndex(nextRow, gameIndex)
+ : undefined
+ }
+ canNavigateUpToHero={rowIndex === 0 && Boolean(featuredGame)}
+ />
+ );
+ })}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/big-picture/src/pages/home/navigation.ts b/src/big-picture/src/pages/home/navigation.ts
new file mode 100644
index 000000000..883f84461
--- /dev/null
+++ b/src/big-picture/src/pages/home/navigation.ts
@@ -0,0 +1,29 @@
+import type { ShopAssets } from "@types";
+
+export const HOME_HERO_ADD_TO_LIBRARY_ID = "home-hero-add-to-library";
+export const HOME_HERO_OPEN_GAME_PAGE_ID = "home-hero-open-game-page";
+export const HOME_HERO_DOWNLOAD_ID = "home-hero-download";
+export const HOME_HERO_ACTIONS_REGION_ID = "home-hero-actions";
+export const HOME_PAGE_REGION_ID = "home-page";
+export const HOME_POPULAR_GAMES_ROW_REGION_ID = "home-popular-games-row";
+export const HOME_WEEKLY_GAMES_ROW_REGION_ID = "home-weekly-games-row";
+export const HOME_ACHIEVEMENTS_GAMES_ROW_REGION_ID =
+ "home-achievements-games-row";
+
+export function getPopularGameFocusId(
+ game: Pick
+) {
+ return `home-popular-game-${game.shop}-${game.objectId}`;
+}
+
+export function getWeeklyGameFocusId(
+ game: Pick
+) {
+ return `home-weekly-game-${game.shop}-${game.objectId}`;
+}
+
+export function getAchievementsGameFocusId(
+ game: Pick
+) {
+ return `home-achievements-game-${game.shop}-${game.objectId}`;
+}
diff --git a/src/big-picture/src/pages/home/page.scss b/src/big-picture/src/pages/home/page.scss
new file mode 100644
index 000000000..bbe30c29a
--- /dev/null
+++ b/src/big-picture/src/pages/home/page.scss
@@ -0,0 +1,59 @@
+.home-page {
+ margin-top: calc(-1 * var(--big-picture-header-height));
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ min-height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+ background: var(--background);
+ color: var(--text);
+}
+
+.home-page__popular-games {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 6);
+ min-width: 0;
+ padding: 0 calc(var(--spacing-unit) * 16) calc(var(--spacing-unit) * 16);
+}
+
+.home-page__popular-games-title {
+ margin: 0;
+ color: var(--text);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: normal;
+}
+
+.home-page__popular-games-row {
+ width: calc(100% + 12px);
+ min-width: 0;
+ margin: -6px;
+ padding: 6px 6px calc(var(--spacing-unit) * 2);
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > [data-focus-wrapper] {
+ flex: 0 0 350px;
+ }
+}
+
+@media (max-width: 720px) {
+ .home-page__popular-games {
+ padding: 0 calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 8);
+ }
+
+ .home-page__popular-games-title {
+ font-size: 24px;
+ }
+
+ .home-page__popular-games-row > [data-focus-wrapper] {
+ flex-basis: min(320px, 82vw);
+ }
+}
diff --git a/src/big-picture/src/pages/home/popular-games.tsx b/src/big-picture/src/pages/home/popular-games.tsx
new file mode 100644
index 000000000..60de5d31a
--- /dev/null
+++ b/src/big-picture/src/pages/home/popular-games.tsx
@@ -0,0 +1,103 @@
+import type { ShopAssets } from "@types";
+import { useNavigate } from "react-router-dom";
+import {
+ FocusItem,
+ HorizontalFocusGroup,
+ VerticalStoreGameCard,
+} from "../../components";
+import { getBigPictureGameDetailsPath } from "../../helpers";
+import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../layout";
+import type { FocusOverrideTarget, FocusOverrides } from "../../services";
+import { HOME_HERO_ACTIONS_REGION_ID } from "./navigation";
+
+function getGameCover(game: ShopAssets) {
+ return game.coverImageUrl ?? game.libraryImageUrl ?? game.iconUrl;
+}
+
+interface PopularGamesProps {
+ title: string;
+ games: ShopAssets[];
+ rowId: string;
+ getFocusId: (game: Pick) => string;
+ getUpFocusId?: (gameIndex: number) => string | null;
+ getDownFocusId?: (gameIndex: number) => string | null;
+ canNavigateUpToHero?: boolean;
+}
+
+export function PopularGames({
+ title,
+ games,
+ rowId,
+ getFocusId,
+ getUpFocusId,
+ getDownFocusId,
+ canNavigateUpToHero = true,
+}: Readonly) {
+ const navigate = useNavigate();
+
+ if (!games.length) return null;
+
+ return (
+
+ {title}
+
+
+ {games.map((game, index) => {
+ const previousGame = games[index - 1];
+ const nextGame = games[index + 1];
+ const upFromParent = getUpFocusId?.(index);
+ const upTarget: FocusOverrideTarget =
+ getUpFocusId === undefined
+ ? canNavigateUpToHero
+ ? {
+ type: "region",
+ regionId: HOME_HERO_ACTIONS_REGION_ID,
+ entryDirection: "right",
+ }
+ : { type: "block" }
+ : upFromParent
+ ? { type: "item", itemId: upFromParent }
+ : { type: "block" };
+ const downFocusId = getDownFocusId?.(index) ?? null;
+ const gameDetailsPath = getBigPictureGameDetailsPath(game);
+ const navigationOverrides: FocusOverrides = {
+ left: previousGame
+ ? { type: "item", itemId: getFocusId(previousGame) }
+ : {
+ type: "item",
+ itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
+ },
+ right: nextGame
+ ? { type: "item", itemId: getFocusId(nextGame) }
+ : { type: "block" },
+ up: upTarget,
+ down: downFocusId
+ ? { type: "item", itemId: downFocusId }
+ : { type: "block" },
+ };
+
+ return (
+ navigate(gameDetailsPath),
+ }}
+ >
+ navigate(gameDetailsPath)}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/big-picture/src/pages/home/use-popular-games.ts b/src/big-picture/src/pages/home/use-popular-games.ts
new file mode 100644
index 000000000..de8f586e6
--- /dev/null
+++ b/src/big-picture/src/pages/home/use-popular-games.ts
@@ -0,0 +1,74 @@
+import type { DownloadSource, ShopAssets } from "@types";
+import { CatalogueCategory } from "@shared";
+import { useEffect, useState } from "react";
+import { normalizeShopAssetsList } from "./home-data";
+
+interface HomeGamesRows {
+ popularGames: ShopAssets[];
+ gamesOfTheWeek: ShopAssets[];
+ gamesToBeat: ShopAssets[];
+}
+
+const EMPTY_HOME_GAMES_ROWS: HomeGamesRows = {
+ popularGames: [],
+ gamesOfTheWeek: [],
+ gamesToBeat: [],
+};
+
+async function getCatalogueGames(
+ category: CatalogueCategory,
+ downloadSourceIds: string[]
+) {
+ const response = await globalThis.window.electron.hydraApi.get(
+ `/catalogue/${category}`,
+ {
+ params: {
+ take: 12,
+ skip: 0,
+ downloadSourceIds,
+ },
+ needsAuth: false,
+ }
+ );
+
+ return normalizeShopAssetsList(response);
+}
+
+export function usePopularGames() {
+ const [games, setGames] = useState(EMPTY_HOME_GAMES_ROWS);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ async function loadPopularGames() {
+ const sources = (await globalThis.window.electron.leveldb.values(
+ "downloadSources"
+ )) as DownloadSource[];
+
+ const downloadSources = [...sources].sort(
+ (first, second) =>
+ new Date(second.createdAt).getTime() -
+ new Date(first.createdAt).getTime()
+ );
+
+ const downloadSourceIds = downloadSources.map((source) => source.id);
+ const [popularGames, gamesOfTheWeek, gamesToBeat] = await Promise.all([
+ getCatalogueGames(CatalogueCategory.Hot, downloadSourceIds),
+ getCatalogueGames(CatalogueCategory.Weekly, downloadSourceIds),
+ getCatalogueGames(CatalogueCategory.Achievements, downloadSourceIds),
+ ]);
+
+ if (isMounted) setGames({ popularGames, gamesOfTheWeek, gamesToBeat });
+ }
+
+ void loadPopularGames().catch(() => {
+ if (isMounted) setGames(EMPTY_HOME_GAMES_ROWS);
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return games;
+}
diff --git a/src/big-picture/src/pages/library/page.scss b/src/big-picture/src/pages/library/page.scss
new file mode 100644
index 000000000..2e1e8545a
--- /dev/null
+++ b/src/big-picture/src/pages/library/page.scss
@@ -0,0 +1,12 @@
+.library-page {
+ margin-top: calc(-1 * var(--big-picture-header-height));
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ overflow-y: auto;
+ background: var(--background);
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 6);
+ padding-bottom: calc(var(--spacing-unit) * 8);
+}
diff --git a/src/big-picture/src/pages/library/page.tsx b/src/big-picture/src/pages/library/page.tsx
new file mode 100644
index 000000000..175e3f5f3
--- /dev/null
+++ b/src/big-picture/src/pages/library/page.tsx
@@ -0,0 +1,88 @@
+import type { LibraryGame } from "@types";
+import { useEffect, useState } from "react";
+import { IS_DESKTOP } from "../../constants";
+import { useLibrary } from "../../hooks";
+import {
+ LibraryFocusGrid,
+ LibraryFilters,
+ GameSettingsModal,
+ LibraryHero,
+ VerticalFocusGroup,
+ type LibraryFilterTab,
+ useLibraryFavorite,
+ useLibraryPageData,
+} from "../../components";
+
+import "./page.scss";
+
+export default function LibraryPage() {
+ const { library, updateLibrary } = useLibrary();
+ const [selectedFilterTab, setSelectedFilterTab] =
+ useState("all");
+ const [search, setSearch] = useState("");
+ const [settingsGame, setSettingsGame] = useState(null);
+ const { favoriteLoadingGameId, toggleFavorite } =
+ useLibraryFavorite(updateLibrary);
+ const { filteredLibrary, filterCounts, firstGridItemId, lastPlayedGames } =
+ useLibraryPageData(library, selectedFilterTab, search);
+
+ useEffect(() => {
+ updateLibrary();
+
+ if (!IS_DESKTOP) return;
+
+ const unsubscribe = globalThis.window.electron.onLibraryBatchComplete(
+ () => {
+ updateLibrary();
+ }
+ );
+
+ return () => {
+ unsubscribe();
+ };
+ }, [updateLibrary]);
+
+ if (library.length === 0 && lastPlayedGames.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {
+ console.log("Library hero options clicked", game);
+ }}
+ onToggleFavorite={toggleFavorite}
+ favoriteLoadingGameId={favoriteLoadingGameId}
+ />
+
+
+
+
+
+
+ setSettingsGame(null)}
+ onGameUpdated={(updatedGame) => {
+ setSettingsGame(updatedGame);
+ updateLibrary();
+ }}
+ />
+
+ );
+}
diff --git a/src/big-picture/src/pages/settings/settings.tsx b/src/big-picture/src/pages/settings/settings.tsx
new file mode 100644
index 000000000..3956e4afa
--- /dev/null
+++ b/src/big-picture/src/pages/settings/settings.tsx
@@ -0,0 +1,29 @@
+import { Button } from "../../components";
+import { IS_DESKTOP } from "../../constants";
+import { useNavigate } from "react-router-dom";
+
+export default function Settings() {
+ const navigate = useNavigate();
+
+ const handleCloseBigPicture = () => {
+ if (IS_DESKTOP) {
+ globalThis.close();
+ } else {
+ navigate("/");
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/big-picture/src/services/gamepad.service.ts b/src/big-picture/src/services/gamepad.service.ts
new file mode 100644
index 000000000..4d873db85
--- /dev/null
+++ b/src/big-picture/src/services/gamepad.service.ts
@@ -0,0 +1,1265 @@
+import { GamepadLayout, getGamepadLayout } from "../helpers";
+import type {
+ GamepadAxisButtonMapping,
+ GamepadAxisTriggerMapping,
+ GamepadInputMapping,
+ GamepadPhysicalAxisMapping,
+} from "../helpers";
+import {
+ GamepadAxisType,
+ GamepadButtonType,
+ GamepadAxisDirection,
+ GamepadStickSide,
+ GamepadButtonPressEvent,
+ GamepadStickMoveEvent,
+ GamepadInputEventMeta,
+} from "../types";
+
+export interface ButtonRawState {
+ pressed: boolean;
+ value: number;
+ lastUpdated: number;
+}
+
+export interface AxisRawState {
+ value: number;
+ lastUpdated: number;
+}
+
+export interface GamepadRawState {
+ name: string;
+ layout: string;
+ buttons: Map;
+ axes: Map;
+}
+
+interface GamepadStickState {
+ position: Vector2D;
+ direction: GamepadAxisDirection | null;
+ repeatTimer: number | null;
+ lastMoveTime: number;
+}
+
+type GamepadStickStateSet = Record;
+type GamepadRegistry = Map;
+type ButtonPressCallback = (event: GamepadButtonPressEvent) => void;
+type StickMoveCallback = (event: GamepadStickMoveEvent) => void;
+type ButtonPressCallbacks = Map>;
+type StickMoveCallbacks = Map<
+ GamepadStickSide,
+ Map>
+>;
+type GamepadInputDescriptor =
+ | {
+ kind: "button";
+ button: GamepadButtonType;
+ }
+ | {
+ kind: "stick";
+ side: GamepadStickSide;
+ direction: GamepadAxisDirection;
+ };
+type GamepadEchoRecord = {
+ gamepadIndex: number;
+ hardwareKey: string;
+ signatureKey: string;
+ acceptedAt: number;
+};
+type DpadRepeatTimers = Map;
+type StickPosition = { x: number; y: number };
+type StickPositions = Record;
+
+export class GamepadService {
+ private static instance: GamepadService;
+
+ private readonly sticksDeadzone = 0.1;
+ private readonly sticksDirectionThreshold = 0.5;
+ private readonly sticksInitialRepeatDelay = 400;
+ private readonly repeatWarmupInterval = 200;
+ private readonly repeatWarmupTicks = 3;
+ private readonly repeatAcceleratedStartInterval = 135;
+ private readonly repeatMinInterval = 75;
+ private readonly repeatAccelerationStep = 15;
+ private readonly gamepadSwitchDuplicateWindow = 250;
+ private readonly buttonEchoSuppressionWindow = 220;
+ private readonly stickEchoSuppressionWindow = 320;
+ private readonly axisButtonThreshold = 0.5;
+ private readonly axisTriggerThreshold = 0.5;
+
+ private isPolling = false;
+ private animationFrameId: number | null = null;
+ private activeGamepadIndex: number | null = null;
+ private lastActiveGamepadSwitchTime = 0;
+ private hasPendingActiveGamepadChange = false;
+
+ private readonly gamepads: GamepadRegistry = new Map();
+ private readonly gamepadStates = new Map();
+ private readonly buttonPressCallbacks: ButtonPressCallbacks = new Map();
+ private readonly stickMoveCallbacks: StickMoveCallbacks = new Map();
+ private readonly layoutCache = new Map();
+ private readonly stateChangeCallbacks = new Set<() => void>();
+ private readonly stickStatesByGamepad = new Map<
+ number,
+ GamepadStickStateSet
+ >();
+ private readonly dpadRepeatTimersByGamepad = new Map<
+ number,
+ DpadRepeatTimers
+ >();
+ private recentAcceptedInputs: GamepadEchoRecord[] = [];
+
+ public static getInstance(): GamepadService {
+ if (!GamepadService.instance) {
+ GamepadService.instance = new GamepadService();
+ }
+
+ return GamepadService.instance;
+ }
+
+ constructor() {
+ if (!this.isWindowAvailable()) return;
+
+ this.setupListeners();
+ }
+
+ private isWindowAvailable() {
+ return globalThis.window !== undefined;
+ }
+
+ private setupListeners() {
+ globalThis.window.addEventListener(
+ "gamepadconnected",
+ this.handleNewGamepadConnection
+ );
+
+ globalThis.window.addEventListener(
+ "gamepaddisconnected",
+ this.handleGamepadDisconnection
+ );
+ }
+
+ private createInitialStickState(): GamepadStickState {
+ return {
+ position: new Vector2D(0, 0),
+ direction: null,
+ repeatTimer: null,
+ lastMoveTime: Date.now(),
+ };
+ }
+
+ private createInitialStickStateSet(): GamepadStickStateSet {
+ return {
+ left: this.createInitialStickState(),
+ right: this.createInitialStickState(),
+ };
+ }
+
+ private getStickState(
+ gamepadIndex: number,
+ side: GamepadStickSide
+ ): GamepadStickState {
+ let stickStateSet = this.stickStatesByGamepad.get(gamepadIndex);
+
+ if (!stickStateSet) {
+ stickStateSet = this.createInitialStickStateSet();
+ this.stickStatesByGamepad.set(gamepadIndex, stickStateSet);
+ }
+
+ return stickStateSet[side];
+ }
+
+ private readonly handleNewGamepadConnection = (event: GamepadEvent) => {
+ const gamepad = event.gamepad;
+
+ this.gamepads.set(gamepad.index, gamepad);
+
+ if (!this.isPolling) {
+ this.startPolling();
+ }
+
+ this.notifyStateChange();
+ };
+
+ private readonly handleGamepadDisconnection = (event: GamepadEvent) => {
+ const gamepad = event.gamepad;
+
+ this.gamepads.delete(gamepad.index);
+ this.gamepadStates.delete(gamepad.index);
+
+ if (this.activeGamepadIndex === gamepad.index) {
+ this.activeGamepadIndex = null;
+ }
+
+ this.clearTimersForGamepad(gamepad.index);
+ this.stickStatesByGamepad.delete(gamepad.index);
+ this.recentAcceptedInputs = this.recentAcceptedInputs.filter(
+ (input) => input.gamepadIndex !== gamepad.index
+ );
+
+ if (this.gamepads.size === 0) {
+ this.stopPolling();
+ }
+
+ this.notifyStateChange();
+ };
+
+ private pollGamepads() {
+ const gamepads = globalThis.navigator.getGamepads();
+
+ for (const gamepad of gamepads) {
+ if (!gamepad) continue;
+
+ this.gamepads.set(gamepad.index, gamepad);
+ this.updateGamepadState(gamepad.index, gamepad);
+ }
+
+ this.animationFrameId = globalThis.requestAnimationFrame(() =>
+ this.pollGamepads()
+ );
+ }
+
+ private startPolling() {
+ if (this.isPolling) return;
+
+ this.isPolling = true;
+ this.pollGamepads();
+ }
+
+ private stopPolling() {
+ if (!this.isPolling) return;
+
+ if (this.animationFrameId !== null) {
+ globalThis.cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
+
+ this.isPolling = false;
+ this.clearAllTimers();
+ }
+
+ private updateButtonState(
+ gamepadState: GamepadRawState,
+ type: GamepadButtonType,
+ buttonState: Pick,
+ index: number,
+ now: number
+ ) {
+ const prevState = gamepadState.buttons.get(type);
+
+ if (
+ prevState?.pressed === buttonState.pressed &&
+ prevState?.value === buttonState.value
+ )
+ return;
+
+ gamepadState.buttons.set(type, {
+ pressed: buttonState.pressed,
+ value: buttonState.value,
+ lastUpdated: now,
+ });
+
+ if (buttonState.pressed && !prevState?.pressed) {
+ const inputMeta = this.resolveGamepadInput(index, {
+ allowSwitch: true,
+ input: {
+ kind: "button",
+ button: type,
+ },
+ now,
+ });
+
+ this.triggerButtonPressCallbacks(index, type, inputMeta);
+
+ if (inputMeta.accepted) {
+ this.setupDpadRepeat(index, type);
+ }
+ }
+
+ if (!buttonState.pressed && prevState?.pressed) {
+ this.clearDpadRepeatTimer(index, type);
+ }
+ }
+
+ private isDpadButton(type: GamepadButtonType): boolean {
+ return (
+ type === GamepadButtonType.DPAD_UP ||
+ type === GamepadButtonType.DPAD_DOWN ||
+ type === GamepadButtonType.DPAD_LEFT ||
+ type === GamepadButtonType.DPAD_RIGHT
+ );
+ }
+
+ private isDpadPressed(
+ gamepadIndex: number,
+ type: GamepadButtonType
+ ): boolean {
+ return (
+ this.gamepadStates.get(gamepadIndex)?.buttons.get(type)?.pressed === true
+ );
+ }
+
+ private getDpadRepeatTimers(gamepadIndex: number): DpadRepeatTimers {
+ let timers = this.dpadRepeatTimersByGamepad.get(gamepadIndex);
+
+ if (!timers) {
+ timers = new Map();
+ this.dpadRepeatTimersByGamepad.set(gamepadIndex, timers);
+ }
+
+ return timers;
+ }
+
+ private getAcceleratedRepeatInterval(repeatCount: number): number {
+ if (repeatCount < this.repeatWarmupTicks) {
+ return this.repeatWarmupInterval;
+ }
+
+ const accelerationTick = repeatCount - this.repeatWarmupTicks;
+ const interval =
+ this.repeatAcceleratedStartInterval -
+ accelerationTick * this.repeatAccelerationStep;
+
+ return Math.max(this.repeatMinInterval, interval);
+ }
+
+ private setupDpadRepeat(gamepadIndex: number, type: GamepadButtonType): void {
+ if (!this.isDpadButton(type)) return;
+
+ this.clearDpadRepeatTimer(gamepadIndex, type);
+
+ const timers = this.getDpadRepeatTimers(gamepadIndex);
+ const timer = globalThis.window.setTimeout(() => {
+ if (!this.isDpadPressed(gamepadIndex, type)) {
+ this.clearDpadRepeatTimer(gamepadIndex, type);
+ return;
+ }
+
+ const inputMeta = this.resolveGamepadInput(gamepadIndex, {
+ allowSwitch: false,
+ input: {
+ kind: "button",
+ button: type,
+ },
+ now: Date.now(),
+ });
+
+ this.triggerButtonPressCallbacks(gamepadIndex, type, inputMeta);
+
+ if (inputMeta.accepted) {
+ this.repeatDpadCallback(gamepadIndex, type);
+ } else {
+ this.clearDpadRepeatTimer(gamepadIndex, type);
+ }
+ }, this.sticksInitialRepeatDelay);
+
+ timers.set(type, timer);
+ }
+
+ private repeatDpadCallback(
+ gamepadIndex: number,
+ type: GamepadButtonType
+ ): void {
+ const timers = this.getDpadRepeatTimers(gamepadIndex);
+ let repeatCount = 0;
+
+ const scheduleRepeat = (callback: () => void) => {
+ const timer = globalThis.window.setTimeout(
+ callback,
+ this.getAcceleratedRepeatInterval(repeatCount)
+ );
+
+ repeatCount += 1;
+ timers.set(type, timer);
+ };
+
+ const repeat = () => {
+ if (!this.isDpadPressed(gamepadIndex, type)) {
+ this.clearDpadRepeatTimer(gamepadIndex, type);
+ return;
+ }
+
+ const inputMeta = this.resolveGamepadInput(gamepadIndex, {
+ allowSwitch: false,
+ input: {
+ kind: "button",
+ button: type,
+ },
+ now: Date.now(),
+ });
+
+ this.triggerButtonPressCallbacks(gamepadIndex, type, inputMeta);
+
+ if (!inputMeta.accepted) {
+ this.clearDpadRepeatTimer(gamepadIndex, type);
+ return;
+ }
+
+ scheduleRepeat(repeat);
+ };
+
+ scheduleRepeat(repeat);
+ }
+
+ private normalizeAxisValue(value: number, min: number, max: number): number {
+ if (max === min) return 0;
+
+ const normalized = (value - min) / (max - min);
+ return Math.max(0, Math.min(1, normalized));
+ }
+
+ private getAxisButtonState(
+ gamepad: Gamepad,
+ mapping: GamepadAxisButtonMapping
+ ): Pick | null {
+ const axisState = gamepad.axes[mapping.axis];
+
+ if (axisState === undefined) return null;
+
+ const threshold = mapping.threshold ?? this.axisButtonThreshold;
+ const directionalValue =
+ mapping.direction === "negative" ? -axisState : axisState;
+ const value = Math.max(0, Math.min(1, directionalValue));
+
+ return {
+ pressed: directionalValue >= threshold,
+ value,
+ };
+ }
+
+ private getAxisTriggerState(
+ gamepad: Gamepad,
+ mapping: GamepadAxisTriggerMapping
+ ): Pick | null {
+ const axisState = gamepad.axes[mapping.axis];
+
+ if (axisState === undefined) return null;
+
+ const min = mapping.min ?? -1;
+ const max = mapping.max ?? 1;
+ const threshold = mapping.threshold ?? this.axisTriggerThreshold;
+ const value = this.normalizeAxisValue(axisState, min, max);
+
+ return {
+ pressed: value >= threshold,
+ value,
+ };
+ }
+
+ private getPhysicalAxisValue(
+ gamepad: Gamepad,
+ mapping: GamepadPhysicalAxisMapping
+ ): number | null {
+ let axisState = gamepad.axes[mapping.index];
+
+ if (axisState === undefined) return null;
+
+ if (mapping.invert) {
+ axisState = -axisState;
+ }
+
+ if (Math.abs(axisState) < this.sticksDeadzone) {
+ axisState = 0;
+ }
+
+ return axisState;
+ }
+
+ private triggerButtonPressCallbacks(
+ gamepadIndex: number,
+ type: GamepadButtonType,
+ meta: GamepadInputEventMeta
+ ): void {
+ const callbacks = this.buttonPressCallbacks.get(type);
+ if (!callbacks) return;
+
+ callbacks.forEach((callback) => {
+ try {
+ callback({
+ gamepadIndex,
+ button: type,
+ ...meta,
+ });
+ } catch (error) {
+ console.error(`Error in button press callback for ${type}:`, error);
+ }
+ });
+ }
+
+ public onButtonPress(
+ type: GamepadButtonType,
+ callback: ButtonPressCallback
+ ): () => void {
+ if (!this.buttonPressCallbacks.has(type)) {
+ this.buttonPressCallbacks.set(type, new Set());
+ }
+
+ const callbacks = this.buttonPressCallbacks.get(type) ?? new Set();
+ this.buttonPressCallbacks.set(type, callbacks);
+ callbacks.add(callback);
+
+ return () => {
+ const callbackSet = this.buttonPressCallbacks.get(type);
+ if (!callbackSet) return;
+
+ callbackSet.delete(callback);
+ if (callbackSet.size === 0) {
+ this.buttonPressCallbacks.delete(type);
+ }
+ };
+ }
+
+ private notifyStateChange(): void {
+ this.stateChangeCallbacks.forEach((callback) => {
+ try {
+ callback();
+ } catch (error) {
+ console.error("Error in state change callback:", error);
+ }
+ });
+ }
+
+ private updateMappedButtonState(
+ gamepadState: GamepadRawState,
+ type: GamepadButtonType,
+ buttonState: Pick,
+ gamepadIndex: number,
+ now: number
+ ): boolean {
+ const prevState = gamepadState.buttons.get(type);
+
+ if (
+ prevState?.pressed === buttonState.pressed &&
+ prevState?.value === buttonState.value
+ ) {
+ return false;
+ }
+
+ this.updateButtonState(gamepadState, type, buttonState, gamepadIndex, now);
+ return true;
+ }
+
+ private updateMappedAxisState(
+ gamepadState: GamepadRawState,
+ type: GamepadAxisType,
+ axisState: number,
+ now: number
+ ): boolean {
+ const prevState = gamepadState.axes.get(type);
+
+ if (prevState && Math.abs(prevState.value - axisState) <= 0.01) {
+ return false;
+ }
+
+ gamepadState.axes.set(type, {
+ value: axisState,
+ lastUpdated: now,
+ });
+
+ return true;
+ }
+
+ private setStickPositionAxis(
+ stickPositions: StickPositions,
+ type: GamepadAxisType,
+ axisState: number
+ ): void {
+ switch (type) {
+ case GamepadAxisType.LEFT_STICK_X:
+ stickPositions.left.x = axisState;
+ break;
+ case GamepadAxisType.LEFT_STICK_Y:
+ stickPositions.left.y = axisState;
+ break;
+ case GamepadAxisType.RIGHT_STICK_X:
+ stickPositions.right.x = axisState;
+ break;
+ case GamepadAxisType.RIGHT_STICK_Y:
+ stickPositions.right.y = axisState;
+ break;
+ }
+ }
+
+ private applyGamepadMapping(
+ gamepadState: GamepadRawState,
+ mapping: GamepadInputMapping,
+ gamepad: Gamepad,
+ gamepadIndex: number,
+ now: number,
+ stickPositions: StickPositions
+ ): boolean {
+ switch (mapping.source) {
+ case "button": {
+ const buttonState = gamepad.buttons[mapping.index];
+ if (!buttonState) return false;
+
+ return this.updateMappedButtonState(
+ gamepadState,
+ mapping.type,
+ buttonState,
+ gamepadIndex,
+ now
+ );
+ }
+ case "axis-button": {
+ const buttonState = this.getAxisButtonState(gamepad, mapping);
+ if (!buttonState) return false;
+
+ return this.updateMappedButtonState(
+ gamepadState,
+ mapping.type,
+ buttonState,
+ gamepadIndex,
+ now
+ );
+ }
+ case "axis-trigger": {
+ const buttonState = this.getAxisTriggerState(gamepad, mapping);
+ if (!buttonState) return false;
+
+ return this.updateMappedButtonState(
+ gamepadState,
+ mapping.type,
+ buttonState,
+ gamepadIndex,
+ now
+ );
+ }
+ case "axis": {
+ const axisState = this.getPhysicalAxisValue(gamepad, mapping);
+ if (axisState === null) return false;
+
+ this.setStickPositionAxis(stickPositions, mapping.type, axisState);
+ return this.updateMappedAxisState(
+ gamepadState,
+ mapping.type,
+ axisState,
+ now
+ );
+ }
+ }
+ }
+
+ private updateGamepadState(index: number, gamepad: Gamepad) {
+ const layout = this.getNewLayoutOrCached(gamepad);
+ const now = Date.now();
+
+ if (!this.gamepadStates.has(index)) {
+ this.gamepadStates.set(index, {
+ name: gamepad.id,
+ layout: layout.name,
+ buttons: new Map(),
+ axes: new Map(),
+ });
+
+ this.notifyStateChange();
+ }
+
+ const gamepadState = this.gamepadStates.get(index);
+ if (!gamepadState) return;
+
+ const gamepadIndex = index;
+
+ let hasStateChanged = false;
+
+ const stickPositions: StickPositions = {
+ left: { x: 0, y: 0 },
+ right: { x: 0, y: 0 },
+ };
+
+ for (const mapping of layout.mappings) {
+ hasStateChanged =
+ this.applyGamepadMapping(
+ gamepadState,
+ mapping,
+ gamepad,
+ gamepadIndex,
+ now,
+ stickPositions
+ ) || hasStateChanged;
+ }
+
+ this.updateStickState(
+ gamepadIndex,
+ "left",
+ new Vector2D(stickPositions.left.x, stickPositions.left.y),
+ now
+ );
+ this.updateStickState(
+ gamepadIndex,
+ "right",
+ new Vector2D(stickPositions.right.x, stickPositions.right.y),
+ now
+ );
+
+ if (hasStateChanged || this.hasPendingActiveGamepadChange) {
+ this.hasPendingActiveGamepadChange = false;
+ this.notifyStateChange();
+ }
+ }
+
+ private updateStickState(
+ gamepadIndex: number,
+ side: GamepadStickSide,
+ position: Vector2D,
+ now: number
+ ) {
+ const stickState = this.getStickState(gamepadIndex, side);
+
+ const prevDirection = stickState.direction;
+
+ stickState.position = position;
+ stickState.lastMoveTime = now;
+
+ const magnitude = position.magnitude();
+
+ const newDirection =
+ magnitude > this.sticksDirectionThreshold
+ ? position.dominantDirection()
+ : null;
+
+ if (newDirection === prevDirection) return;
+
+ if (stickState.repeatTimer !== null) {
+ globalThis.window.clearTimeout(stickState.repeatTimer);
+ stickState.repeatTimer = null;
+ }
+
+ stickState.direction = newDirection;
+
+ if (newDirection) {
+ const inputMeta = this.resolveGamepadInput(gamepadIndex, {
+ allowSwitch: true,
+ input: {
+ kind: "stick",
+ side,
+ direction: newDirection,
+ },
+ now,
+ });
+
+ this.triggerStickCallbacks(gamepadIndex, side, newDirection, inputMeta);
+
+ if (inputMeta.accepted) {
+ this.setupStickRepeat(gamepadIndex, side, newDirection);
+ }
+ }
+ }
+
+ private setupStickRepeat(
+ gamepadIndex: number,
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection
+ ) {
+ const stickState = this.getStickState(gamepadIndex, side);
+
+ stickState.repeatTimer = globalThis.window.setTimeout(() => {
+ if (stickState.direction === direction) {
+ const inputMeta = this.resolveGamepadInput(gamepadIndex, {
+ allowSwitch: false,
+ input: {
+ kind: "stick",
+ side,
+ direction,
+ },
+ now: Date.now(),
+ });
+
+ this.triggerStickCallbacks(gamepadIndex, side, direction, inputMeta);
+
+ if (inputMeta.accepted) {
+ this.repeatStickCallback(gamepadIndex, side, direction);
+ } else {
+ stickState.repeatTimer = null;
+ }
+ } else {
+ stickState.repeatTimer = null;
+ }
+ }, this.sticksInitialRepeatDelay);
+ }
+
+ private repeatStickCallback(
+ gamepadIndex: number,
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection
+ ) {
+ const stickState = this.getStickState(gamepadIndex, side);
+ let repeatCount = 0;
+
+ const scheduleRepeat = (callback: () => void) => {
+ stickState.repeatTimer = globalThis.window.setTimeout(
+ callback,
+ this.getAcceleratedRepeatInterval(repeatCount)
+ );
+
+ repeatCount += 1;
+ };
+
+ const repeat = () => {
+ if (stickState.direction !== direction) {
+ stickState.repeatTimer = null;
+ return;
+ }
+
+ const inputMeta = this.resolveGamepadInput(gamepadIndex, {
+ allowSwitch: false,
+ input: {
+ kind: "stick",
+ side,
+ direction,
+ },
+ now: Date.now(),
+ });
+
+ this.triggerStickCallbacks(gamepadIndex, side, direction, inputMeta);
+
+ if (!inputMeta.accepted) {
+ stickState.repeatTimer = null;
+ return;
+ }
+
+ scheduleRepeat(repeat);
+ };
+
+ scheduleRepeat(repeat);
+ }
+
+ private clearStickTimer(stickState: GamepadStickState) {
+ if (stickState.repeatTimer !== null) {
+ globalThis.window.clearTimeout(stickState.repeatTimer);
+ stickState.repeatTimer = null;
+ }
+ }
+
+ private clearDpadRepeatTimer(
+ gamepadIndex: number,
+ type: GamepadButtonType
+ ): void {
+ const timers = this.dpadRepeatTimersByGamepad.get(gamepadIndex);
+
+ if (!timers) return;
+
+ const timer = timers.get(type);
+
+ if (timer === undefined) return;
+
+ globalThis.window.clearTimeout(timer);
+ timers.delete(type);
+
+ if (timers.size === 0) {
+ this.dpadRepeatTimersByGamepad.delete(gamepadIndex);
+ }
+ }
+
+ private clearDpadRepeatTimersForGamepad(gamepadIndex: number): void {
+ const timers = this.dpadRepeatTimersByGamepad.get(gamepadIndex);
+
+ if (!timers) return;
+
+ timers.forEach((timer) => globalThis.window.clearTimeout(timer));
+ this.dpadRepeatTimersByGamepad.delete(gamepadIndex);
+ }
+
+ private clearAllDpadRepeatTimers(): void {
+ this.dpadRepeatTimersByGamepad.forEach((timers) => {
+ timers.forEach((timer) => globalThis.window.clearTimeout(timer));
+ });
+ this.dpadRepeatTimersByGamepad.clear();
+ }
+
+ private clearAllTimers() {
+ this.stickStatesByGamepad.forEach((stickStateSet) => {
+ this.clearStickTimer(stickStateSet.left);
+ this.clearStickTimer(stickStateSet.right);
+ });
+ this.stickStatesByGamepad.clear();
+ this.clearAllDpadRepeatTimers();
+ }
+
+ private clearTimersForGamepad(gamepadIndex: number) {
+ const stickStateSet = this.stickStatesByGamepad.get(gamepadIndex);
+
+ if (stickStateSet) {
+ this.clearStickTimer(stickStateSet.left);
+ this.clearStickTimer(stickStateSet.right);
+ }
+
+ this.clearDpadRepeatTimersForGamepad(gamepadIndex);
+ }
+
+ private triggerStickCallbacks(
+ gamepadIndex: number,
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection,
+ meta: GamepadInputEventMeta
+ ) {
+ const sideCallbacks = this.stickMoveCallbacks.get(side);
+ if (!sideCallbacks) return;
+
+ const callbacks = sideCallbacks.get(direction);
+ if (!callbacks) return;
+
+ callbacks.forEach((callback) => {
+ try {
+ callback({
+ gamepadIndex,
+ side,
+ direction,
+ ...meta,
+ });
+ } catch (error) {
+ console.error(
+ `Error in stick move callback for ${side} ${direction}:`,
+ error
+ );
+ }
+ });
+ }
+
+ public getCurrentState(
+ index?: number
+ ): GamepadRawState | Map | null {
+ if (index !== undefined) {
+ const state = this.gamepadStates.get(index);
+ if (!state) return null;
+
+ return {
+ name: state.name,
+ layout: state.layout,
+ buttons: new Map(state.buttons),
+ axes: new Map(state.axes),
+ };
+ }
+
+ const states = new Map();
+ this.gamepadStates.forEach((state, idx) => {
+ states.set(idx, {
+ name: state.name,
+ layout: state.layout,
+ buttons: new Map(state.buttons),
+ axes: new Map(state.axes),
+ });
+ });
+
+ return states;
+ }
+
+ public getActiveGamepadIndex(): number | null {
+ return this.activeGamepadIndex;
+ }
+
+ public getLastActiveGamepad(): number | null {
+ return this.getActiveGamepadIndex();
+ }
+
+ public setActiveGamepadIndex(index: number | null): void {
+ if (this.activeGamepadIndex === index) return;
+
+ this.activeGamepadIndex = index;
+ this.lastActiveGamepadSwitchTime = Date.now();
+ this.hasPendingActiveGamepadChange = false;
+ this.notifyStateChange();
+ }
+
+ public onStateChange(callback: () => void): () => void {
+ this.stateChangeCallbacks.add(callback);
+
+ return () => {
+ this.stateChangeCallbacks.delete(callback);
+ };
+ }
+
+ public onStickMove(
+ side: GamepadStickSide,
+ direction: GamepadAxisDirection,
+ callback: StickMoveCallback
+ ): () => void {
+ if (!this.stickMoveCallbacks.has(side)) {
+ this.stickMoveCallbacks.set(side, new Map());
+ }
+
+ const sideCallbacks = this.stickMoveCallbacks.get(side) ?? new Map();
+ this.stickMoveCallbacks.set(side, sideCallbacks);
+
+ if (!sideCallbacks.has(direction)) {
+ sideCallbacks.set(direction, new Set());
+ }
+
+ const callbacks = sideCallbacks.get(direction) ?? new Set();
+ sideCallbacks.set(direction, callbacks);
+ callbacks.add(callback);
+
+ return () => {
+ const sideMap = this.stickMoveCallbacks.get(side);
+ if (sideMap) {
+ const callbackSet = sideMap.get(direction);
+ if (callbackSet) {
+ callbackSet.delete(callback);
+ if (callbackSet.size === 0) {
+ sideMap.delete(direction);
+ if (sideMap.size === 0) {
+ this.stickMoveCallbacks.delete(side);
+ }
+ }
+ }
+ }
+ };
+ }
+
+ public vibrate(
+ duration: number,
+ weakMagnitude: number,
+ strongMagnitude: number,
+ gamepadIndex: number
+ ): void {
+ const activeGamepad = this.gamepads.get(gamepadIndex);
+
+ if (!activeGamepad?.vibrationActuator) return;
+
+ try {
+ activeGamepad.vibrationActuator.playEffect("dual-rumble", {
+ startDelay: 0,
+ duration: duration,
+ weakMagnitude: Math.max(0, Math.min(1, weakMagnitude)),
+ strongMagnitude: Math.max(0, Math.min(1, strongMagnitude)),
+ });
+ } catch (error) {
+ console.error("Error ao tentar vibrar o controle:", error);
+ }
+ }
+
+ public dispose(): void {
+ this.stopPolling();
+ globalThis.window.removeEventListener(
+ "gamepadconnected",
+ this.handleNewGamepadConnection
+ );
+ globalThis.window.removeEventListener(
+ "gamepaddisconnected",
+ this.handleGamepadDisconnection
+ );
+ this.gamepads.clear();
+ this.gamepadStates.clear();
+ this.buttonPressCallbacks.clear();
+ this.stickMoveCallbacks.clear();
+ this.stateChangeCallbacks.clear();
+ this.activeGamepadIndex = null;
+ this.lastActiveGamepadSwitchTime = 0;
+ this.hasPendingActiveGamepadChange = false;
+ this.recentAcceptedInputs = [];
+ this.clearAllTimers();
+ }
+
+ private getNewLayoutOrCached(gamepad: Gamepad) {
+ if (!this.layoutCache.has(gamepad.id)) {
+ const layout = getGamepadLayout(gamepad);
+ this.layoutCache.set(gamepad.id, layout);
+ return layout;
+ }
+
+ return this.layoutCache.get(gamepad.id) ?? getGamepadLayout(gamepad);
+ }
+
+ private getGamepadHardwareKey(gamepadIndex: number): string | null {
+ const id =
+ this.gamepads.get(gamepadIndex)?.id ??
+ this.gamepadStates.get(gamepadIndex)?.name;
+ const match = /Vendor:\s*([0-9a-f]{4})\s+Product:\s*([0-9a-f]{4})/i.exec(
+ id ?? ""
+ );
+
+ if (!match) return null;
+
+ return `${match[1].toLowerCase()}:${match[2].toLowerCase()}`;
+ }
+
+ private getInputSignatureKey(input: GamepadInputDescriptor): string {
+ if (input.kind === "button") {
+ return `button:${input.button}`;
+ }
+
+ return `stick:${input.side}:${input.direction}`;
+ }
+
+ private getEchoSuppressionWindow(input: GamepadInputDescriptor): number {
+ return input.kind === "button"
+ ? this.buttonEchoSuppressionWindow
+ : this.stickEchoSuppressionWindow;
+ }
+
+ private pruneRecentAcceptedInputs(now: number): void {
+ const maxWindow = Math.max(
+ this.buttonEchoSuppressionWindow,
+ this.stickEchoSuppressionWindow
+ );
+
+ this.recentAcceptedInputs = this.recentAcceptedInputs.filter(
+ (input) => now - input.acceptedAt <= maxWindow
+ );
+ }
+
+ private recordAcceptedInput(
+ gamepadIndex: number,
+ input: GamepadInputDescriptor,
+ now: number
+ ): void {
+ const hardwareKey = this.getGamepadHardwareKey(gamepadIndex);
+ if (!hardwareKey) return;
+
+ this.pruneRecentAcceptedInputs(now);
+ this.recentAcceptedInputs.push({
+ gamepadIndex,
+ hardwareKey,
+ signatureKey: this.getInputSignatureKey(input),
+ acceptedAt: now,
+ });
+ }
+
+ private findEchoInput(
+ gamepadIndex: number,
+ input: GamepadInputDescriptor,
+ now: number
+ ): { gamepadIndex: number; elapsedMs: number } | null {
+ const hardwareKey = this.getGamepadHardwareKey(gamepadIndex);
+ if (!hardwareKey) return null;
+
+ this.pruneRecentAcceptedInputs(now);
+
+ const signatureKey = this.getInputSignatureKey(input);
+ const suppressionWindow = this.getEchoSuppressionWindow(input);
+
+ for (let i = this.recentAcceptedInputs.length - 1; i >= 0; i -= 1) {
+ const recentInput = this.recentAcceptedInputs[i];
+ const elapsedMs = now - recentInput.acceptedAt;
+
+ if (
+ recentInput.gamepadIndex !== gamepadIndex &&
+ recentInput.hardwareKey === hardwareKey &&
+ recentInput.signatureKey === signatureKey &&
+ elapsedMs <= suppressionWindow
+ ) {
+ return {
+ gamepadIndex: recentInput.gamepadIndex,
+ elapsedMs,
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private resolveGamepadInput(
+ gamepadIndex: number,
+ options: {
+ allowSwitch: boolean;
+ input: GamepadInputDescriptor;
+ now: number;
+ }
+ ): GamepadInputEventMeta {
+ const previousActiveGamepadIndex = this.activeGamepadIndex;
+
+ if (previousActiveGamepadIndex === gamepadIndex) {
+ this.recordAcceptedInput(gamepadIndex, options.input, options.now);
+
+ return {
+ status: "accepted",
+ accepted: true,
+ activeGamepadIndex: this.activeGamepadIndex,
+ previousActiveGamepadIndex,
+ };
+ }
+
+ if (!options.allowSwitch) {
+ return {
+ status: "ignored-inactive",
+ accepted: false,
+ activeGamepadIndex: this.activeGamepadIndex,
+ previousActiveGamepadIndex,
+ };
+ }
+
+ const isWithinDuplicateWindow =
+ previousActiveGamepadIndex !== null &&
+ options.now - this.lastActiveGamepadSwitchTime <
+ this.gamepadSwitchDuplicateWindow;
+
+ if (isWithinDuplicateWindow) {
+ return {
+ status: "ignored-duplicate-window",
+ accepted: false,
+ activeGamepadIndex: this.activeGamepadIndex,
+ previousActiveGamepadIndex,
+ };
+ }
+
+ const echoInput =
+ previousActiveGamepadIndex === null
+ ? null
+ : this.findEchoInput(gamepadIndex, options.input, options.now);
+
+ if (echoInput) {
+ return {
+ status: "ignored-echo",
+ accepted: false,
+ activeGamepadIndex: this.activeGamepadIndex,
+ previousActiveGamepadIndex,
+ echoOfGamepadIndex: echoInput.gamepadIndex,
+ echoSuppressionMs: echoInput.elapsedMs,
+ };
+ }
+
+ this.activeGamepadIndex = gamepadIndex;
+ this.lastActiveGamepadSwitchTime = options.now;
+ this.hasPendingActiveGamepadChange = true;
+ this.recordAcceptedInput(gamepadIndex, options.input, options.now);
+
+ return {
+ status: "accepted",
+ accepted: true,
+ activeGamepadIndex: this.activeGamepadIndex,
+ previousActiveGamepadIndex,
+ };
+ }
+}
+
+class Vector2D {
+ private readonly deadzone = 0.1;
+
+ constructor(
+ public x: number,
+ public y: number
+ ) {}
+
+ magnitude(): number {
+ return Math.hypot(this.x, this.y);
+ }
+
+ normalize(): Vector2D {
+ const magnitude = this.magnitude();
+ if (magnitude === 0) return new Vector2D(0, 0);
+
+ return new Vector2D(this.x / magnitude, this.y / magnitude);
+ }
+
+ dominantDirection(): GamepadAxisDirection | null {
+ const magnitude = this.magnitude();
+
+ if (magnitude < this.deadzone) return null;
+
+ const normalized = this.normalize();
+
+ const projections = {
+ [GamepadAxisDirection.UP]: -normalized.y,
+ [GamepadAxisDirection.DOWN]: normalized.y,
+ [GamepadAxisDirection.LEFT]: -normalized.x,
+ [GamepadAxisDirection.RIGHT]: normalized.x,
+ };
+
+ return Object.entries(projections).reduce(
+ (max, [direction, projection]) =>
+ projection > max.projection
+ ? { direction: direction as GamepadAxisDirection, projection }
+ : max,
+ { direction: null as GamepadAxisDirection | null, projection: -Infinity }
+ ).direction;
+ }
+}
diff --git a/src/big-picture/src/services/index.ts b/src/big-picture/src/services/index.ts
new file mode 100644
index 000000000..1ee1363cc
--- /dev/null
+++ b/src/big-picture/src/services/index.ts
@@ -0,0 +1,5 @@
+export * from "./gamepad.service";
+export * from "./navigation.service";
+export * from "./navigation-focus-bridge.service";
+export * from "./navigation-item-actions.service";
+export * from "./navigation-screen-actions.service";
diff --git a/src/big-picture/src/services/navigation-focus-bridge.service.ts b/src/big-picture/src/services/navigation-focus-bridge.service.ts
new file mode 100644
index 000000000..0fc2a865e
--- /dev/null
+++ b/src/big-picture/src/services/navigation-focus-bridge.service.ts
@@ -0,0 +1,37 @@
+type FocusBridgeCallback = () => void;
+
+export class NavigationFocusBridgeService {
+ private static instance: NavigationFocusBridgeService;
+
+ private readonly callbacks = new Map();
+
+ public static getInstance() {
+ if (!NavigationFocusBridgeService.instance) {
+ NavigationFocusBridgeService.instance =
+ new NavigationFocusBridgeService();
+ }
+
+ return NavigationFocusBridgeService.instance;
+ }
+
+ public register(itemId: string, callback: FocusBridgeCallback) {
+ this.callbacks.set(itemId, callback);
+
+ return () => {
+ const registeredCallback = this.callbacks.get(itemId);
+
+ if (registeredCallback !== callback) return;
+
+ this.callbacks.delete(itemId);
+ };
+ }
+
+ public focus(itemId: string) {
+ const callback = this.callbacks.get(itemId);
+
+ if (!callback) return false;
+
+ callback();
+ return true;
+ }
+}
diff --git a/src/big-picture/src/services/navigation-item-actions.service.ts b/src/big-picture/src/services/navigation-item-actions.service.ts
new file mode 100644
index 000000000..2ef4a48f8
--- /dev/null
+++ b/src/big-picture/src/services/navigation-item-actions.service.ts
@@ -0,0 +1,340 @@
+import type {
+ FocusItemActions,
+ FocusItemHoldButton,
+ FocusItemPressButton,
+ NavigationActionContext,
+} from "../types";
+import { NavigationService } from "./navigation.service";
+
+interface RegisteredFocusItemActions {
+ itemId: string;
+ actions: FocusItemActions;
+ getElement: () => HTMLElement | null;
+}
+
+type PrimaryResolution =
+ | {
+ kind: "explicit-action";
+ item: RegisteredFocusItemActions;
+ context: NavigationActionContext;
+ action: Exclude;
+ }
+ | {
+ kind: "auto-click";
+ item: RegisteredFocusItemActions;
+ context: NavigationActionContext;
+ target: HTMLElement;
+ }
+ | {
+ kind: "unresolved";
+ item: RegisteredFocusItemActions;
+ reason:
+ | "primary-off"
+ | "missing-item"
+ | "inactive-item"
+ | "no-clickable-target";
+ };
+
+const PRIMARY_CLICKABLE_MARKER_SELECTOR = "[data-navigation-primary]";
+const CLICKABLE_TARGET_SELECTOR = [
+ "button",
+ "a[href]",
+ '[role="button"]',
+ '[role="link"]',
+ "[data-navigation-click]",
+].join(", ");
+
+function isDisabledClickableTarget(target: Element) {
+ return target instanceof HTMLButtonElement
+ ? target.disabled
+ : target.getAttribute("aria-disabled") === "true";
+}
+
+export class NavigationItemActionsService {
+ private static instance: NavigationItemActionsService;
+
+ private readonly navigation = NavigationService.getInstance();
+ private readonly items = new Map();
+
+ public static getInstance() {
+ if (!NavigationItemActionsService.instance) {
+ NavigationItemActionsService.instance =
+ new NavigationItemActionsService();
+ }
+
+ return NavigationItemActionsService.instance;
+ }
+
+ public registerItemActions(item: RegisteredFocusItemActions) {
+ if (this.items.has(item.itemId)) {
+ throw new Error(
+ `Focus item actions for "${item.itemId}" are already registered.`
+ );
+ }
+
+ this.items.set(item.itemId, item);
+
+ return () => {
+ this.items.delete(item.itemId);
+ };
+ }
+
+ public triggerPrimaryForFocusedItem(originalEvent: Event | null = null) {
+ const focusedItemId = this.navigation.getCurrentFocusId();
+
+ if (!focusedItemId) {
+ return false;
+ }
+
+ return this.triggerPrimaryForItem(focusedItemId, originalEvent);
+ }
+
+ public canResolvePrimaryForFocusedItem() {
+ const resolution = this.resolvePrimaryForFocusedItem();
+
+ return (
+ resolution?.kind === "explicit-action" ||
+ resolution?.kind === "auto-click"
+ );
+ }
+
+ public triggerSecondaryForFocusedItem(originalEvent: Event | null = null) {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ const secondaryAction = registeredItem.actions.secondary;
+
+ if (typeof secondaryAction !== "function") return false;
+
+ secondaryAction(
+ this.createActionContext(registeredItem.itemId, originalEvent)
+ );
+
+ return true;
+ }
+
+ public hasSecondaryActionForFocusedItem() {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ return typeof registeredItem.actions.secondary === "function";
+ }
+
+ public triggerPressActionForFocusedItem(
+ button: FocusItemPressButton,
+ originalEvent: Event | null = null
+ ) {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ const action = registeredItem.actions.press?.[button];
+
+ if (!action) return false;
+
+ action(this.createActionContext(registeredItem.itemId, originalEvent));
+
+ return true;
+ }
+
+ public hasPressActionForFocusedItem(button: FocusItemPressButton) {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ return Boolean(registeredItem.actions.press?.[button]);
+ }
+
+ public triggerHoldActionForFocusedItem(
+ button: FocusItemHoldButton,
+ originalEvent: Event | null = null
+ ) {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ const action = registeredItem.actions.hold?.[button];
+
+ if (!action) return false;
+
+ action(this.createActionContext(registeredItem.itemId, originalEvent));
+
+ return true;
+ }
+
+ public hasHoldActionForFocusedItem(button: FocusItemHoldButton) {
+ const registeredItem = this.getFocusedRegisteredItem();
+
+ if (!registeredItem) return false;
+
+ return Boolean(registeredItem.actions.hold?.[button]);
+ }
+
+ private getFocusedRegisteredItem() {
+ const focusedItemId = this.navigation.getCurrentFocusId();
+
+ if (!focusedItemId) return null;
+
+ if (!this.navigation.isNodeActive(focusedItemId)) {
+ return null;
+ }
+
+ return this.items.get(focusedItemId) ?? null;
+ }
+
+ private triggerPrimaryForItem(itemId: string, originalEvent: Event | null) {
+ const resolution = this.resolvePrimaryForItem(itemId, originalEvent);
+
+ if (!resolution) return false;
+
+ if (resolution.kind === "explicit-action") {
+ resolution.action(resolution.context);
+ return true;
+ }
+
+ if (resolution.kind === "auto-click") {
+ resolution.target.click();
+ return true;
+ }
+
+ if (
+ resolution.kind === "unresolved" &&
+ resolution.reason === "no-clickable-target" &&
+ process.env.NODE_ENV !== "production"
+ ) {
+ console.warn(
+ `Navigation primary action could not be resolved for focused item "${resolution.item.itemId}". "primary: \\"auto\\"" did not find a valid clickable target. Add an explicit primary action, render a clickable element or \`data-navigation-click\`, or mark the intended target with \`data-navigation-primary\`.`
+ );
+ }
+
+ return false;
+ }
+
+ private resolvePrimaryForFocusedItem(originalEvent: Event | null = null) {
+ const focusedItemId = this.navigation.getCurrentFocusId();
+
+ if (!focusedItemId) return null;
+
+ return this.resolvePrimaryForItem(focusedItemId, originalEvent);
+ }
+
+ private resolvePrimaryForItem(
+ itemId: string,
+ originalEvent: Event | null
+ ): PrimaryResolution | null {
+ const registeredItem = this.items.get(itemId);
+
+ if (!registeredItem) {
+ return {
+ kind: "unresolved",
+ item: {
+ itemId,
+ actions: {
+ primary: "auto",
+ },
+ getElement: () => null,
+ },
+ reason: "missing-item",
+ };
+ }
+
+ if (!this.navigation.isNodeActive(itemId)) {
+ return {
+ kind: "unresolved",
+ item: registeredItem,
+ reason: "inactive-item",
+ };
+ }
+
+ const context = this.createActionContext(itemId, originalEvent);
+ const primaryAction = registeredItem.actions.primary;
+
+ if (typeof primaryAction === "function") {
+ return {
+ kind: "explicit-action",
+ item: registeredItem,
+ context,
+ action: primaryAction,
+ };
+ }
+
+ if (primaryAction === "off" || primaryAction === null) {
+ return {
+ kind: "unresolved",
+ item: registeredItem,
+ reason: "primary-off",
+ };
+ }
+
+ if (context.clickableTarget) {
+ return {
+ kind: "auto-click",
+ item: registeredItem,
+ context,
+ target: context.clickableTarget,
+ };
+ }
+
+ return {
+ kind: "unresolved",
+ item: registeredItem,
+ reason: "no-clickable-target",
+ };
+ }
+
+ private createActionContext(
+ itemId: string,
+ originalEvent: Event | null
+ ): NavigationActionContext & { clickableTarget: HTMLElement | null } {
+ const clickableTarget = this.getClickableTarget(itemId);
+
+ return {
+ itemId,
+ clickableTarget,
+ click: () => {
+ clickableTarget?.click();
+ },
+ hasClickableTarget: clickableTarget !== null,
+ originalEvent,
+ };
+ }
+
+ private getClickableTarget(itemId: string) {
+ const item = this.items.get(itemId);
+ const itemElement = item?.getElement();
+
+ if (!itemElement) return null;
+
+ const explicitPrimaryTarget = Array.from(
+ itemElement.querySelectorAll(PRIMARY_CLICKABLE_MARKER_SELECTOR)
+ ).find((target): target is HTMLElement => {
+ return this.isValidClickableTarget(target);
+ });
+
+ if (explicitPrimaryTarget) {
+ return explicitPrimaryTarget;
+ }
+
+ if (this.isValidClickableTarget(itemElement)) {
+ return itemElement;
+ }
+
+ const descendantClickableTarget = Array.from(
+ itemElement.querySelectorAll(CLICKABLE_TARGET_SELECTOR)
+ ).find((target): target is HTMLElement => {
+ return this.isValidClickableTarget(target);
+ });
+
+ return descendantClickableTarget ?? null;
+ }
+
+ private isValidClickableTarget(target: Element) {
+ return (
+ target instanceof HTMLElement &&
+ target.matches(CLICKABLE_TARGET_SELECTOR) &&
+ !isDisabledClickableTarget(target)
+ );
+ }
+}
diff --git a/src/big-picture/src/services/navigation-screen-actions.service.ts b/src/big-picture/src/services/navigation-screen-actions.service.ts
new file mode 100644
index 000000000..6e8047802
--- /dev/null
+++ b/src/big-picture/src/services/navigation-screen-actions.service.ts
@@ -0,0 +1,158 @@
+import type {
+ NavigationActionButton,
+ NavigationActionMode,
+ NavigationScreenActionTarget,
+ NavigationScreenActionContext,
+ ScreenActions,
+} from "../types";
+import { NavigationService } from "./navigation.service";
+
+interface RegisteredScreenActions {
+ id: number;
+ actions: ScreenActions;
+}
+
+export class NavigationScreenActionsService {
+ private static instance: NavigationScreenActionsService;
+
+ private readonly navigation = NavigationService.getInstance();
+ private readonly stack: RegisteredScreenActions[] = [];
+ private nextId = 0;
+
+ public static getInstance() {
+ if (!NavigationScreenActionsService.instance) {
+ NavigationScreenActionsService.instance =
+ new NavigationScreenActionsService();
+ }
+
+ return NavigationScreenActionsService.instance;
+ }
+
+ public registerActions(actions: ScreenActions) {
+ const entry: RegisteredScreenActions = {
+ id: this.nextId++,
+ actions,
+ };
+
+ this.stack.push(entry);
+
+ return () => {
+ const index = this.stack.findIndex(
+ (candidate) => candidate.id === entry.id
+ );
+
+ if (index === -1) return;
+
+ this.stack.splice(index, 1);
+ };
+ }
+
+ public updateActions(id: number, actions: ScreenActions) {
+ const entry = this.stack.find((candidate) => candidate.id === id);
+
+ if (!entry) return;
+
+ entry.actions = actions;
+ }
+
+ public createRegistration(actions: ScreenActions) {
+ const id = this.nextId++;
+
+ this.stack.push({
+ id,
+ actions,
+ });
+
+ return {
+ id,
+ unregister: () => {
+ const index = this.stack.findIndex((candidate) => candidate.id === id);
+
+ if (index === -1) return;
+
+ this.stack.splice(index, 1);
+ },
+ };
+ }
+
+ public triggerAction(
+ mode: NavigationActionMode,
+ button: NavigationActionButton,
+ originalEvent: Event | null = null
+ ) {
+ for (let index = this.stack.length - 1; index >= 0; index -= 1) {
+ const action = this.stack[index]?.actions[mode]?.[button];
+
+ if (!action) continue;
+
+ const context: NavigationScreenActionContext = {
+ currentFocusId: this.navigation.getCurrentFocusId(),
+ originalEvent,
+ };
+
+ if (typeof action === "function") {
+ action(context);
+ return true;
+ }
+
+ return this.resolveTargetAction(action, context);
+ }
+
+ return false;
+ }
+
+ public hasAction(mode: NavigationActionMode, button: NavigationActionButton) {
+ for (let index = this.stack.length - 1; index >= 0; index -= 1) {
+ if (this.stack[index]?.actions[mode]?.[button]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private resolveTargetAction(
+ action: NavigationScreenActionTarget,
+ context: NavigationScreenActionContext
+ ) {
+ const resolvedFocusId =
+ action.type === "item"
+ ? this.navigation.setFocus(action.itemId)
+ : this.navigation.setFocusRegion(
+ action.regionId,
+ action.entryDirection ?? "right"
+ );
+
+ if (!resolvedFocusId) {
+ if (process.env.NODE_ENV !== "production") {
+ this.warnUnresolvedTarget(action, context);
+ }
+
+ return false;
+ }
+
+ action.onFocused?.();
+ return true;
+ }
+
+ private warnUnresolvedTarget(
+ action: NavigationScreenActionTarget,
+ context: NavigationScreenActionContext
+ ) {
+ const targetDescription =
+ action.type === "item"
+ ? `item "${action.itemId}"`
+ : `region "${action.regionId}"`;
+ const onFocusedMessage = action.onFocused
+ ? " The optional onFocused callback was skipped because the target could not be resolved."
+ : "";
+
+ console.warn(
+ `Navigation screen action could not resolve ${targetDescription} while handling the active shell shortcut. Focus was left unchanged.${onFocusedMessage}`,
+ {
+ action,
+ currentFocusId: context.currentFocusId,
+ }
+ );
+ }
+}
diff --git a/src/big-picture/src/services/navigation.service.ts b/src/big-picture/src/services/navigation.service.ts
new file mode 100644
index 000000000..9c3e9f36a
--- /dev/null
+++ b/src/big-picture/src/services/navigation.service.ts
@@ -0,0 +1,1797 @@
+export type FocusDirection = "up" | "down" | "left" | "right";
+export type FocusOrientation = "vertical" | "horizontal" | "grid";
+export type FocusAutoScrollMode = "item" | "region" | "row" | "auto";
+export type NavigationNodeState = "active" | "disabled" | "hidden";
+export type FocusElementGetter = () => HTMLElement | null;
+export type FocusOverrideTarget =
+ | {
+ type: "item";
+ itemId: string;
+ }
+ | {
+ type: "region";
+ regionId: string;
+ entryDirection?: FocusDirection;
+ }
+ | {
+ type: "block";
+ };
+export type FocusOverrides = Partial<
+ Record
+>;
+
+export const ROOT_NAVIGATION_LAYER_ID = "navigation-root-layer";
+
+export interface FocusNode {
+ id: string;
+ regionId: string;
+ layerId: string;
+ navigationState: NavigationNodeState;
+ navigationOverrides?: FocusOverrides;
+ getElement: FocusElementGetter;
+}
+
+export interface FocusRegion {
+ id: string;
+ parentRegionId: string | null;
+ orientation: FocusOrientation;
+ layerId: string;
+ navigationOverrides?: FocusOverrides;
+ autoScrollMode?: FocusAutoScrollMode;
+ getElement: FocusElementGetter;
+ getScrollAnchor?: FocusElementGetter;
+}
+
+export interface FocusLayer {
+ id: string;
+ rootRegionId: string | null;
+ openerFocusId: string | null;
+ openerRegionId: string | null;
+}
+
+interface FocusRegionRecord extends FocusRegion {
+ isPersistent: boolean;
+}
+
+interface FocusLayerRecord extends FocusLayer {
+ isPersistent: boolean;
+ explicitRootRegionId: string | null;
+ hasWarnedAboutMultipleRoots: boolean;
+}
+
+type Listener = () => void;
+type FocusTarget = { type: "node" | "region"; id: string };
+type FocusBoundary = "first" | "last";
+type PendingInitialFocusRequest = {
+ layerId: string;
+ initialFocusId?: string;
+ initialFocusRegionId?: string;
+};
+
+const NAVIGATION_DEBUG_STORAGE_KEY = "hydra:big-picture:navigation-debug";
+
+function isNavigationDebugEnabled() {
+ const env = import.meta.env as unknown as Record<
+ string,
+ string | boolean | undefined
+ >;
+
+ if (
+ env.RENDERER_VITE_BIG_PICTURE_NAV_DEBUG === "true" ||
+ env.RENDERER_VITE_BIG_PICTURE_NAV_DEBUG === true ||
+ env.VITE_BIG_PICTURE_NAV_DEBUG === "true" ||
+ env.VITE_BIG_PICTURE_NAV_DEBUG === true
+ ) {
+ return true;
+ }
+
+ if (typeof globalThis.window === "undefined") {
+ return false;
+ }
+
+ try {
+ const storageValue = globalThis.window.localStorage.getItem(
+ NAVIGATION_DEBUG_STORAGE_KEY
+ );
+ const locationValue = `${globalThis.window.location.search}${globalThis.window.location.hash}`;
+
+ return (
+ storageValue === "1" ||
+ storageValue === "true" ||
+ locationValue.includes("navigationDebug=1") ||
+ locationValue.includes("navigationDebug=true")
+ );
+ } catch {
+ return false;
+ }
+}
+
+export interface NavigationDebugSnapshot {
+ currentFocusId: string | null;
+ activeLayerId: string | null;
+ nodeCount: number;
+ nodeIds: string[];
+ regionCount: number;
+ regionIds: string[];
+ layerCount: number;
+ layerIds: string[];
+ listenerCount: number;
+ lastFocusedByRegionId: Record;
+ openerFocusByLayerId: Record;
+ openerRegionByLayerId: Record;
+}
+
+export class NavigationService {
+ private static instance: NavigationService;
+
+ private readonly nodes = new Map();
+ private readonly regions = new Map();
+ private readonly layers = new Map();
+ private layerStack: string[] = [ROOT_NAVIGATION_LAYER_ID];
+ private readonly regionChildren = new Map();
+ private readonly regionChildOrder = new Map>();
+ private readonly regionChildOrderCounter = new Map();
+ private currentFocusId: string | null = null;
+ private readonly listeners = new Set();
+ private readonly lastFocusedByRegionId = new Map();
+ private readonly pendingInitialFocusByLayerId = new Map<
+ string,
+ PendingInitialFocusRequest
+ >();
+
+ public constructor() {
+ this.layers.set(ROOT_NAVIGATION_LAYER_ID, {
+ id: ROOT_NAVIGATION_LAYER_ID,
+ rootRegionId: null,
+ openerFocusId: null,
+ openerRegionId: null,
+ isPersistent: true,
+ explicitRootRegionId: null,
+ hasWarnedAboutMultipleRoots: false,
+ });
+ }
+
+ public static getInstance(): NavigationService {
+ if (!NavigationService.instance) {
+ NavigationService.instance = new NavigationService();
+ }
+
+ return NavigationService.instance;
+ }
+
+ public registerLayer(layer: {
+ id: string;
+ rootRegionId?: string | null;
+ isPersistent?: boolean;
+ }) {
+ if (layer.id === ROOT_NAVIGATION_LAYER_ID) {
+ throw new Error(
+ `Focus layer "${ROOT_NAVIGATION_LAYER_ID}" is reserved for the implicit root layer.`
+ );
+ }
+
+ const existingLayer = this.layers.get(layer.id);
+
+ if (existingLayer) {
+ throw new Error(`Focus layer "${layer.id}" is already registered.`);
+ }
+
+ const openerFocusId =
+ this.currentFocusId && this.nodes.has(this.currentFocusId)
+ ? this.currentFocusId
+ : null;
+
+ const openerRegionId = openerFocusId
+ ? (this.nodes.get(openerFocusId)?.regionId ?? null)
+ : null;
+
+ this.layers.set(layer.id, {
+ id: layer.id,
+ rootRegionId: layer.rootRegionId ?? null,
+ openerFocusId,
+ openerRegionId,
+ isPersistent: Boolean(layer.isPersistent),
+ explicitRootRegionId: layer.rootRegionId ?? null,
+ hasWarnedAboutMultipleRoots: false,
+ });
+
+ this.layerStack.push(layer.id);
+ this.reconcileLayerRootRegion(layer.id);
+ this.currentFocusId = null;
+ this.notify();
+
+ return () => {
+ const registeredLayer = this.layers.get(layer.id);
+
+ if (!registeredLayer) return;
+
+ const wasActiveLayer = this.getActiveLayerId() === layer.id;
+
+ this.layerStack = this.layerStack.filter(
+ (stackLayerId) => stackLayerId !== layer.id
+ );
+
+ this.pendingInitialFocusByLayerId.delete(layer.id);
+ this.layers.delete(layer.id);
+
+ if (wasActiveLayer) {
+ this.currentFocusId = this.restoreFocusForLayer(registeredLayer);
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+ } else if (
+ this.currentFocusId &&
+ !this.isNodeInActiveLayer(this.currentFocusId)
+ ) {
+ this.currentFocusId = this.resolveFirstAvailableFocus();
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+ }
+
+ this.notify();
+ };
+ }
+
+ public focusInitialInLayer(options: {
+ layerId: string;
+ initialFocusId?: string;
+ initialFocusRegionId?: string;
+ }) {
+ const layer = this.layers.get(options.layerId);
+
+ if (!layer) return null;
+
+ const request: PendingInitialFocusRequest = {
+ layerId: options.layerId,
+ initialFocusId: options.initialFocusId,
+ initialFocusRegionId: options.initialFocusRegionId,
+ };
+
+ const initialFocusId = this.resolvePendingInitialFocusRequest(request);
+
+ if (!initialFocusId) {
+ this.pendingInitialFocusByLayerId.set(options.layerId, request);
+ return null;
+ }
+
+ this.pendingInitialFocusByLayerId.delete(options.layerId);
+ this.setFocus(initialFocusId);
+ return initialFocusId;
+ }
+
+ public registerRegion(
+ region: Omit & {
+ layerId?: string;
+ isPersistent?: boolean;
+ }
+ ) {
+ const existingRegion = this.regions.get(region.id);
+
+ if (existingRegion) {
+ throw new Error(`Focus region "${region.id}" is already registered.`);
+ }
+
+ const layerId = region.layerId ?? ROOT_NAVIGATION_LAYER_ID;
+
+ if (region.parentRegionId) {
+ const parentRegion = this.regions.get(region.parentRegionId);
+
+ if (parentRegion && parentRegion.layerId !== layerId) {
+ throw new Error(
+ `Focus region "${region.id}" must share the same layer as its parent region "${region.parentRegionId}".`
+ );
+ }
+ }
+
+ const regionRecord: FocusRegionRecord = {
+ ...region,
+ parentRegionId: region.parentRegionId ?? null,
+ layerId,
+ navigationOverrides: region.navigationOverrides,
+ isPersistent: Boolean(region.isPersistent),
+ };
+
+ this.regions.set(region.id, regionRecord);
+ this.ensureRegionChildren(region.id);
+
+ if (regionRecord.parentRegionId) {
+ this.appendChildTarget(regionRecord.parentRegionId, {
+ type: "region",
+ id: regionRecord.id,
+ });
+ }
+
+ this.reconcileLayerRootRegion(layerId);
+ this.tryResolvePendingInitialFocus(layerId);
+
+ if (
+ this.currentFocusId &&
+ this.isNodeWithinRegion(this.currentFocusId, region.id)
+ ) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+
+ this.notify();
+
+ return () => {
+ const registeredRegion = this.regions.get(region.id);
+
+ if (!registeredRegion) return;
+ const shouldRecoverFocus =
+ this.currentFocusId !== null &&
+ this.isNodeWithinRegion(this.currentFocusId, region.id);
+
+ if (registeredRegion.parentRegionId) {
+ this.removeChildTarget(registeredRegion.parentRegionId, {
+ type: "region",
+ id: region.id,
+ });
+ }
+
+ this.regions.delete(region.id);
+ this.regionChildren.delete(region.id);
+ this.reconcileLayerRootRegion(registeredRegion.layerId);
+ this.tryResolvePendingInitialFocus(registeredRegion.layerId);
+
+ if (!registeredRegion.isPersistent) {
+ this.regionChildOrder.delete(region.id);
+ this.regionChildOrderCounter.delete(region.id);
+ }
+
+ if (!registeredRegion.isPersistent) {
+ this.lastFocusedByRegionId.delete(region.id);
+ }
+
+ if (shouldRecoverFocus) {
+ this.currentFocusId =
+ (registeredRegion.parentRegionId
+ ? this.resolveRecoveryFocus(registeredRegion.parentRegionId)
+ : null) ?? this.resolveFirstAvailableFocus();
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+ }
+
+ this.notify();
+ };
+ }
+
+ public updateRegion(
+ regionId: string,
+ updates: Partial<
+ Pick<
+ FocusRegion,
+ | "navigationOverrides"
+ | "getElement"
+ | "autoScrollMode"
+ | "getScrollAnchor"
+ >
+ >
+ ) {
+ const registeredRegion = this.regions.get(regionId);
+
+ if (!registeredRegion) return;
+
+ const nextNavigationOverrides =
+ updates.navigationOverrides ?? registeredRegion.navigationOverrides;
+
+ const nextGetElement = updates.getElement ?? registeredRegion.getElement;
+ const nextAutoScrollMode =
+ updates.autoScrollMode ?? registeredRegion.autoScrollMode;
+ const nextGetScrollAnchor =
+ updates.getScrollAnchor ?? registeredRegion.getScrollAnchor;
+
+ if (
+ this.areFocusOverridesEqual(
+ registeredRegion.navigationOverrides,
+ nextNavigationOverrides
+ ) &&
+ nextGetElement === registeredRegion.getElement &&
+ nextAutoScrollMode === registeredRegion.autoScrollMode &&
+ nextGetScrollAnchor === registeredRegion.getScrollAnchor
+ ) {
+ return;
+ }
+
+ this.regions.set(regionId, {
+ ...registeredRegion,
+ navigationOverrides: nextNavigationOverrides,
+ autoScrollMode: nextAutoScrollMode,
+ getElement: nextGetElement,
+ getScrollAnchor: nextGetScrollAnchor,
+ });
+
+ this.notify();
+ }
+
+ public registerNavigationNode(
+ node: Omit<
+ FocusNode,
+ "layerId" | "navigationState" | "navigationOverrides"
+ > & {
+ layerId?: string;
+ navigationState?: NavigationNodeState;
+ navigationOverrides?: FocusOverrides;
+ }
+ ) {
+ const existingNode = this.nodes.get(node.id);
+
+ if (existingNode) {
+ throw new Error(`Focus node "${node.id}" is already registered.`);
+ }
+
+ const region = this.regions.get(node.regionId);
+ const layerId = node.layerId ?? region?.layerId ?? ROOT_NAVIGATION_LAYER_ID;
+
+ if (region && layerId !== region.layerId) {
+ throw new Error(
+ `Focus node "${node.id}" must share the same layer as its region "${node.regionId}".`
+ );
+ }
+
+ this.nodes.set(node.id, {
+ id: node.id,
+ regionId: node.regionId,
+ layerId,
+ navigationState: node.navigationState ?? "active",
+ navigationOverrides: node.navigationOverrides,
+ getElement: node.getElement,
+ });
+ this.ensureRegionChildren(node.regionId);
+ this.appendChildTarget(node.regionId, {
+ type: "node",
+ id: node.id,
+ });
+
+ const resolvedPendingInitialFocus =
+ this.tryResolvePendingInitialFocus(layerId);
+
+ if (
+ !resolvedPendingInitialFocus &&
+ layerId === this.getActiveLayerId() &&
+ this.isNodeActive(node.id) &&
+ !this.hasValidCurrentFocus() &&
+ !this.hasPendingInitialFocus(layerId)
+ ) {
+ const nextFocusId = this.resolveRecoveryFocus(node.regionId) ?? node.id;
+
+ this.currentFocusId = nextFocusId;
+ this.updateLastFocusedForNode(nextFocusId);
+ }
+
+ this.notify();
+
+ return () => {
+ const registeredNode = this.nodes.get(node.id);
+
+ if (!registeredNode) return;
+
+ this.nodes.delete(node.id);
+ this.removeChildTarget(registeredNode.regionId, {
+ type: "node",
+ id: node.id,
+ });
+
+ if (this.currentFocusId === node.id) {
+ this.currentFocusId =
+ this.resolveRecoveryFocus(registeredNode.regionId) ??
+ this.resolveFirstAvailableFocus();
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+ }
+
+ this.notify();
+ };
+ }
+
+ public updateNavigationNode(
+ nodeId: string,
+ updates: Partial>
+ ) {
+ const registeredNode = this.nodes.get(nodeId);
+
+ if (!registeredNode) return;
+
+ const nextNavigationState =
+ updates.navigationState ?? registeredNode.navigationState;
+ const nextNavigationOverrides =
+ updates.navigationOverrides ?? registeredNode.navigationOverrides;
+
+ if (
+ nextNavigationState === registeredNode.navigationState &&
+ this.areFocusOverridesEqual(
+ registeredNode.navigationOverrides,
+ nextNavigationOverrides
+ )
+ ) {
+ return;
+ }
+
+ this.nodes.set(nodeId, {
+ ...registeredNode,
+ navigationState: nextNavigationState,
+ navigationOverrides: nextNavigationOverrides,
+ });
+
+ const resolvedPendingInitialFocus = this.tryResolvePendingInitialFocus(
+ registeredNode.layerId
+ );
+
+ if (this.currentFocusId === nodeId && !this.isNodeActive(nodeId)) {
+ this.currentFocusId =
+ this.resolveRecoveryFocus(registeredNode.regionId) ??
+ this.resolveFirstAvailableFocus();
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ }
+ } else if (
+ !resolvedPendingInitialFocus &&
+ this.isNodeActive(nodeId) &&
+ registeredNode.layerId === this.getActiveLayerId() &&
+ !this.hasValidCurrentFocus() &&
+ !this.hasPendingInitialFocus(registeredNode.layerId)
+ ) {
+ const nextFocusId =
+ this.resolveRecoveryFocus(registeredNode.regionId) ?? nodeId;
+
+ this.currentFocusId = nextFocusId;
+ this.updateLastFocusedForNode(nextFocusId);
+ }
+
+ this.notify();
+ }
+
+ public getCurrentFocusId(): string | null {
+ return this.currentFocusId;
+ }
+
+ public getNode(id: string): FocusNode | null {
+ return this.nodes.get(id) ?? null;
+ }
+
+ public isNodeActive(nodeId: string) {
+ return this.nodes.get(nodeId)?.navigationState === "active";
+ }
+
+ public getActiveLayerId(): string | null {
+ return this.layerStack.at(-1) ?? null;
+ }
+
+ public getNodes(): FocusNode[] {
+ return Array.from(this.nodes.values());
+ }
+
+ public getRegions(): FocusRegion[] {
+ return Array.from(this.regions.values()).map((region) => ({
+ id: region.id,
+ parentRegionId: region.parentRegionId,
+ orientation: region.orientation,
+ layerId: region.layerId,
+ navigationOverrides: region.navigationOverrides,
+ autoScrollMode: region.autoScrollMode,
+ getElement: region.getElement,
+ getScrollAnchor: region.getScrollAnchor,
+ }));
+ }
+
+ public getLayers(): FocusLayer[] {
+ return this.layerStack
+ .map((layerId) => this.layers.get(layerId))
+ .filter((layer): layer is FocusLayerRecord => Boolean(layer))
+ .map((layer) => ({
+ id: layer.id,
+ rootRegionId: layer.rootRegionId,
+ openerFocusId: layer.openerFocusId,
+ openerRegionId: layer.openerRegionId,
+ }));
+ }
+
+ public setFocus(id: string) {
+ const node = this.nodes.get(id);
+
+ if (!node) return null;
+ if (!this.isNodeActive(id)) return null;
+ if (!this.isNodeInActiveLayer(id)) return null;
+ if (this.currentFocusId === id) return id;
+
+ this.currentFocusId = node.id;
+ this.updateLastFocusedForNode(node.id);
+ this.notify();
+ return node.id;
+ }
+
+ public setFocusRegion(
+ regionId: string,
+ entryDirection: FocusDirection = "right"
+ ) {
+ const region = this.regions.get(regionId);
+
+ if (!region) return null;
+
+ if (!this.isRegionInActiveLayer(regionId)) {
+ return null;
+ }
+
+ if (
+ this.currentFocusId !== null &&
+ this.isNodeWithinRegion(this.currentFocusId, regionId)
+ ) {
+ return this.currentFocusId;
+ }
+
+ const nextNodeId = this.getEntryNodeForRegion(regionId, entryDirection);
+
+ if (!nextNodeId) return null;
+
+ return this.setFocus(nextNodeId);
+ }
+
+ public moveFocus(direction: FocusDirection) {
+ if (!this.ensureCurrentFocus()) return null;
+
+ const currentNodeId = this.currentFocusId;
+
+ if (!currentNodeId) return null;
+
+ const currentNode = this.nodes.get(currentNodeId);
+
+ if (!currentNode) return null;
+
+ const nodeOverride = currentNode.navigationOverrides?.[direction];
+
+ if (nodeOverride) {
+ if (nodeOverride.type === "block") {
+ return currentNodeId;
+ }
+
+ const overrideNodeId = this.resolveOverrideTargetToNode(
+ nodeOverride,
+ direction
+ );
+
+ this.logMoveFocusOverrideResolution({
+ sourceType: "item",
+ sourceId: currentNodeId,
+ direction,
+ target: nodeOverride,
+ resolvedNodeId: overrideNodeId,
+ });
+
+ if (overrideNodeId) {
+ return this.setFocus(overrideNodeId);
+ }
+
+ this.warnInvalidOverride({
+ sourceType: "item",
+ sourceId: currentNodeId,
+ direction,
+ target: nodeOverride,
+ });
+
+ return this.moveFocusByTree(currentNodeId, direction);
+ }
+
+ let currentRegionId: string | null = currentNode.regionId;
+
+ while (currentRegionId) {
+ const currentRegion = this.regions.get(currentRegionId);
+
+ if (currentRegion?.layerId !== this.getActiveLayerId()) {
+ break;
+ }
+ const regionOverride = currentRegion.navigationOverrides?.[direction];
+
+ if (regionOverride) {
+ if (regionOverride.type === "block") {
+ return currentNodeId;
+ }
+
+ const overrideNodeId = this.resolveOverrideTargetToNode(
+ regionOverride,
+ direction
+ );
+
+ this.logMoveFocusOverrideResolution({
+ sourceType: "region",
+ sourceId: currentRegionId,
+ direction,
+ target: regionOverride,
+ resolvedNodeId: overrideNodeId,
+ });
+
+ if (overrideNodeId) {
+ return this.setFocus(overrideNodeId);
+ }
+
+ this.warnInvalidOverride({
+ sourceType: "region",
+ sourceId: currentRegionId,
+ direction,
+ target: regionOverride,
+ });
+
+ return this.moveFocusByTree(currentNodeId, direction);
+ }
+
+ currentRegionId = currentRegion.parentRegionId ?? null;
+ }
+
+ return this.moveFocusByTree(currentNodeId, direction);
+ }
+
+ public getDebugSnapshot(): NavigationDebugSnapshot {
+ const layers = this.getLayers();
+
+ return {
+ currentFocusId: this.currentFocusId,
+ activeLayerId: this.getActiveLayerId(),
+ nodeCount: this.nodes.size,
+ nodeIds: Array.from(this.nodes.keys()),
+ regionCount: this.regions.size,
+ regionIds: Array.from(this.regions.keys()),
+ layerCount: layers.length,
+ layerIds: layers.map((layer) => layer.id),
+ listenerCount: this.listeners.size,
+ lastFocusedByRegionId: Object.fromEntries(this.lastFocusedByRegionId),
+ openerFocusByLayerId: Object.fromEntries(
+ layers.map((layer) => [layer.id, layer.openerFocusId])
+ ),
+ openerRegionByLayerId: Object.fromEntries(
+ layers.map((layer) => [layer.id, layer.openerRegionId])
+ ),
+ };
+ }
+
+ public subscribe(listener: Listener): () => void {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ private resolveInitialFocusInLayer(options: {
+ layerId: string;
+ rootRegionId: string | null;
+ initialFocusId?: string;
+ initialFocusRegionId?: string;
+ }) {
+ if (
+ options.initialFocusId &&
+ this.nodes.has(options.initialFocusId) &&
+ this.isNodeActive(options.initialFocusId) &&
+ this.isNodeInLayer(options.initialFocusId, options.layerId)
+ ) {
+ return options.initialFocusId;
+ }
+
+ if (
+ options.initialFocusRegionId &&
+ this.regions.has(options.initialFocusRegionId) &&
+ this.isRegionInLayer(options.initialFocusRegionId, options.layerId)
+ ) {
+ return this.getEntryNodeForRegion(options.initialFocusRegionId, "right");
+ }
+
+ if (
+ options.rootRegionId &&
+ this.regions.has(options.rootRegionId) &&
+ this.isRegionInLayer(options.rootRegionId, options.layerId)
+ ) {
+ return this.getBoundaryNodeInRegion(options.rootRegionId, "first");
+ }
+
+ return null;
+ }
+
+ private resolvePendingInitialFocusRequest(
+ request: PendingInitialFocusRequest
+ ) {
+ const layer = this.layers.get(request.layerId);
+
+ if (!layer) return null;
+
+ return this.resolveInitialFocusInLayer({
+ layerId: request.layerId,
+ rootRegionId: layer.rootRegionId,
+ initialFocusId: request.initialFocusId,
+ initialFocusRegionId: request.initialFocusRegionId,
+ });
+ }
+
+ private restoreFocusForLayer(layer: FocusLayerRecord) {
+ const nextActiveLayerId = this.getActiveLayerId();
+
+ if (
+ layer.openerFocusId &&
+ this.nodes.has(layer.openerFocusId) &&
+ this.isNodeActive(layer.openerFocusId) &&
+ this.isNodeInActiveLayer(layer.openerFocusId)
+ ) {
+ return layer.openerFocusId;
+ }
+
+ if (
+ layer.openerRegionId &&
+ this.regions.has(layer.openerRegionId) &&
+ nextActiveLayerId !== null &&
+ this.isRegionInLayer(layer.openerRegionId, nextActiveLayerId)
+ ) {
+ return this.getEntryNodeForRegion(layer.openerRegionId, "right");
+ }
+
+ return this.resolveFirstAvailableFocus();
+ }
+
+ private ensureRegionChildren(regionId: string) {
+ if (!this.regionChildren.has(regionId)) {
+ this.regionChildren.set(regionId, []);
+ }
+
+ if (!this.regionChildOrder.has(regionId)) {
+ this.regionChildOrder.set(regionId, new Map());
+ }
+
+ if (!this.regionChildOrderCounter.has(regionId)) {
+ this.regionChildOrderCounter.set(regionId, 0);
+ }
+ }
+
+ private appendChildTarget(regionId: string, target: FocusTarget) {
+ this.ensureRegionChildren(regionId);
+
+ const children = this.regionChildren.get(regionId);
+ const childOrder = this.regionChildOrder.get(regionId);
+
+ if (!children || !childOrder) return;
+
+ const alreadyRegistered = children.some(
+ (child) => child.type === target.type && child.id === target.id
+ );
+
+ if (alreadyRegistered) return;
+
+ const targetKey = this.getTargetKey(target);
+
+ if (!childOrder.has(targetKey)) {
+ const nextOrder = this.regionChildOrderCounter.get(regionId) ?? 0;
+
+ childOrder.set(targetKey, nextOrder);
+ this.regionChildOrderCounter.set(regionId, nextOrder + 1);
+ }
+
+ const targetOrder = childOrder.get(targetKey) ?? 0;
+ const insertIndex = children.findIndex((child) => {
+ const childKey = this.getTargetKey(child);
+ const childTargetOrder = childOrder.get(childKey) ?? 0;
+
+ return childTargetOrder > targetOrder;
+ });
+
+ if (insertIndex === -1) {
+ children.push(target);
+ return;
+ }
+
+ children.splice(insertIndex, 0, target);
+ }
+
+ private removeChildTarget(regionId: string, target: FocusTarget) {
+ const children = this.regionChildren.get(regionId);
+
+ if (!children) return;
+
+ const nextChildren = children.filter(
+ (child) => !(child.type === target.type && child.id === target.id)
+ );
+
+ this.regionChildren.set(regionId, nextChildren);
+ }
+
+ private getTargetKey(target: FocusTarget) {
+ return `${target.type}:${target.id}`;
+ }
+
+ private hasPendingInitialFocus(layerId: string) {
+ return this.pendingInitialFocusByLayerId.has(layerId);
+ }
+
+ private reconcileLayerRootRegion(layerId: string) {
+ if (layerId === ROOT_NAVIGATION_LAYER_ID) {
+ return;
+ }
+
+ const layer = this.layers.get(layerId);
+
+ if (!layer) return;
+
+ const nextRootRegionId = this.getEffectiveRootRegionIdForLayer(layer);
+ const topLevelRegionIds = this.getTopLevelRegionIdsForLayer(layerId);
+ const shouldWarnAboutMultipleRoots =
+ layer.explicitRootRegionId === null && topLevelRegionIds.length > 1;
+
+ if (
+ shouldWarnAboutMultipleRoots &&
+ !layer.hasWarnedAboutMultipleRoots &&
+ process.env.NODE_ENV !== "production"
+ ) {
+ console.warn(
+ `Navigation layer "${layerId}" registered multiple root regions without an explicit rootRegionId. Using "${topLevelRegionIds[0]}" as the effective root region. Prefer a single root region or pass rootRegionId explicitly to avoid ambiguous layer structure.`,
+ {
+ layerId,
+ topLevelRegionIds,
+ effectiveRootRegionId: topLevelRegionIds[0] ?? null,
+ }
+ );
+ }
+
+ if (
+ layer.rootRegionId === nextRootRegionId &&
+ layer.hasWarnedAboutMultipleRoots === shouldWarnAboutMultipleRoots
+ ) {
+ return;
+ }
+
+ this.layers.set(layerId, {
+ ...layer,
+ rootRegionId: nextRootRegionId,
+ hasWarnedAboutMultipleRoots: shouldWarnAboutMultipleRoots,
+ });
+ }
+
+ private tryResolvePendingInitialFocus(layerId: string) {
+ if (layerId !== this.getActiveLayerId()) {
+ return false;
+ }
+
+ const request = this.pendingInitialFocusByLayerId.get(layerId);
+
+ if (!request) return false;
+
+ const nextFocusId = this.resolvePendingInitialFocusRequest(request);
+
+ if (!nextFocusId) return false;
+
+ this.pendingInitialFocusByLayerId.delete(layerId);
+ this.setFocus(nextFocusId);
+ return true;
+ }
+
+ private getTopLevelRegionIdsForLayer(layerId: string) {
+ return Array.from(this.regions.values())
+ .filter(
+ (region) => region.layerId === layerId && region.parentRegionId === null
+ )
+ .map((region) => region.id);
+ }
+
+ private getEffectiveRootRegionIdForLayer(layer: FocusLayerRecord) {
+ if (
+ layer.explicitRootRegionId !== null &&
+ this.regions.has(layer.explicitRootRegionId)
+ ) {
+ return layer.explicitRootRegionId;
+ }
+
+ if (layer.explicitRootRegionId !== null) {
+ return null;
+ }
+
+ return this.getTopLevelRegionIdsForLayer(layer.id)[0] ?? null;
+ }
+
+ private hasValidCurrentFocus() {
+ return (
+ this.currentFocusId !== null &&
+ this.nodes.has(this.currentFocusId) &&
+ this.isNodeActive(this.currentFocusId) &&
+ this.isNodeInActiveLayer(this.currentFocusId)
+ );
+ }
+
+ private ensureCurrentFocus() {
+ if (this.hasValidCurrentFocus()) return true;
+
+ this.currentFocusId = this.resolveFirstAvailableFocus();
+
+ if (this.currentFocusId) {
+ this.updateLastFocusedForNode(this.currentFocusId);
+ this.notify();
+ return true;
+ }
+
+ return false;
+ }
+
+ private updateLastFocusedForNode(nodeId: string) {
+ const node = this.nodes.get(nodeId);
+
+ if (!node) return;
+
+ let regionId: string | null = node.regionId;
+
+ while (regionId) {
+ this.lastFocusedByRegionId.set(regionId, nodeId);
+ regionId = this.regions.get(regionId)?.parentRegionId ?? null;
+ }
+ }
+
+ private resolveRecoveryFocus(regionId: string): string | null {
+ let currentRegionId: string | null = regionId;
+
+ while (currentRegionId) {
+ const region = this.regions.get(currentRegionId);
+
+ if (region?.layerId !== this.getActiveLayerId()) {
+ return null;
+ }
+
+ const recoveryNodeId = this.getBoundaryNodeInRegion(
+ currentRegionId,
+ "first"
+ );
+
+ if (recoveryNodeId) {
+ return recoveryNodeId;
+ }
+
+ currentRegionId = region.parentRegionId ?? null;
+ }
+
+ return null;
+ }
+
+ private resolveFirstAvailableFocus(): string | null {
+ const activeLayerId = this.getActiveLayerId();
+ const activeLayer = activeLayerId ? this.layers.get(activeLayerId) : null;
+
+ if (
+ activeLayer?.rootRegionId &&
+ this.regions.has(activeLayer.rootRegionId)
+ ) {
+ const nodeId = this.getBoundaryNodeInRegion(
+ activeLayer.rootRegionId,
+ "first"
+ );
+
+ if (nodeId) return nodeId;
+ }
+
+ for (const region of this.regions.values()) {
+ if (region.layerId !== activeLayerId || region.parentRegionId !== null) {
+ continue;
+ }
+
+ const nodeId = this.getBoundaryNodeInRegion(region.id, "first");
+
+ if (nodeId) return nodeId;
+ }
+
+ for (const node of this.nodes.values()) {
+ if (node.layerId === activeLayerId && this.isNodeActive(node.id)) {
+ return node.id;
+ }
+ }
+
+ return null;
+ }
+
+ private moveFocusByTree(
+ currentNodeId: string,
+ direction: FocusDirection
+ ): string | null {
+ const currentNode = this.nodes.get(currentNodeId);
+
+ if (!currentNode) return null;
+
+ let currentRegionId: string | null = currentNode.regionId;
+
+ while (currentRegionId) {
+ const currentRegion = this.regions.get(currentRegionId);
+
+ if (currentRegion?.layerId !== this.getActiveLayerId()) {
+ break;
+ }
+
+ const nextNodeId = this.getNextNodeInRegion(
+ currentRegionId,
+ currentNodeId,
+ direction
+ );
+
+ if (nextNodeId) {
+ return this.setFocus(nextNodeId);
+ }
+
+ currentRegionId = currentRegion.parentRegionId ?? null;
+ }
+
+ return null;
+ }
+
+ private getNextNodeInRegion(
+ regionId: string,
+ currentNodeId: string,
+ direction: FocusDirection
+ ): string | null {
+ const region = this.regions.get(regionId);
+
+ if (!region) return null;
+ if (region.layerId !== this.getActiveLayerId()) return null;
+
+ if (region.orientation === "grid") {
+ return this.getNextNodeInGridRegion(regionId, currentNodeId, direction);
+ }
+
+ if (!this.doesDirectionMatchOrientation(direction, region.orientation)) {
+ return null;
+ }
+
+ const children = this.regionChildren.get(regionId) ?? [];
+ const currentTarget = this.getDirectChildTargetForNode(
+ regionId,
+ currentNodeId
+ );
+
+ if (!currentTarget) return null;
+
+ const currentIndex = children.findIndex(
+ (child) =>
+ child.type === currentTarget.type && child.id === currentTarget.id
+ );
+
+ if (currentIndex === -1) return null;
+
+ const step = this.getDirectionStep(direction);
+
+ for (
+ let index = currentIndex + step;
+ index >= 0 && index < children.length;
+ index += step
+ ) {
+ const candidateNodeId = this.resolveTargetToNode(
+ children[index],
+ direction
+ );
+
+ if (candidateNodeId) {
+ return candidateNodeId;
+ }
+ }
+
+ return null;
+ }
+
+ private getNextNodeInGridRegion(
+ regionId: string,
+ currentNodeId: string,
+ direction: FocusDirection
+ ): string | null {
+ const children = this.regionChildren.get(regionId) ?? [];
+ const currentTarget = this.getDirectChildTargetForNode(
+ regionId,
+ currentNodeId
+ );
+
+ if (!currentTarget) return null;
+
+ const currentRect = this.getTargetRect(currentTarget);
+
+ if (!currentRect) return null;
+
+ const candidates = children
+ .filter(
+ (child) =>
+ !(child.type === currentTarget.type && child.id === currentTarget.id)
+ )
+ .map((child) => {
+ const nodeId = this.resolveTargetToNode(child, direction);
+ const rect = this.getTargetRect(child);
+
+ if (!nodeId || !rect) {
+ return null;
+ }
+
+ return {
+ nodeId,
+ rect,
+ score: this.getGridCandidateScore(currentRect, rect, direction),
+ };
+ })
+ .filter(
+ (
+ candidate
+ ): candidate is {
+ nodeId: string;
+ rect: DOMRect;
+ score: { overlap: number; primary: number; cross: number };
+ } => candidate !== null
+ )
+ .sort((a, b) => {
+ if (a.score.overlap !== b.score.overlap) {
+ return b.score.overlap - a.score.overlap;
+ }
+
+ if (a.score.primary !== b.score.primary) {
+ return a.score.primary - b.score.primary;
+ }
+
+ return a.score.cross - b.score.cross;
+ });
+
+ return candidates[0]?.nodeId ?? null;
+ }
+
+ private getTargetRect(target: FocusTarget): DOMRect | null {
+ const element = this.getTargetElement(target);
+
+ return element?.getBoundingClientRect() ?? null;
+ }
+
+ private getTargetElement(target: FocusTarget): HTMLElement | null {
+ if (target.type === "node") {
+ return this.nodes.get(target.id)?.getElement?.() ?? null;
+ }
+
+ const regionElement = this.regions.get(target.id)?.getElement?.() ?? null;
+
+ if (regionElement) {
+ return regionElement;
+ }
+
+ const fallbackNodeId = this.getBoundaryNodeInRegion(target.id, "first");
+
+ return fallbackNodeId
+ ? (this.nodes.get(fallbackNodeId)?.getElement?.() ?? null)
+ : null;
+ }
+
+ private getGridCandidateScore(
+ currentRect: DOMRect,
+ candidateRect: DOMRect,
+ direction: FocusDirection
+ ) {
+ switch (direction) {
+ case "left": {
+ const primary = currentRect.left - candidateRect.right;
+
+ if (primary < 0) return null;
+
+ return {
+ overlap: this.getAxisOverlap(
+ currentRect.top,
+ currentRect.bottom,
+ candidateRect.top,
+ candidateRect.bottom
+ ),
+ primary,
+ cross: Math.abs(
+ this.getRectCenter(currentRect).y -
+ this.getRectCenter(candidateRect).y
+ ),
+ };
+ }
+ case "right": {
+ const primary = candidateRect.left - currentRect.right;
+
+ if (primary < 0) return null;
+
+ return {
+ overlap: this.getAxisOverlap(
+ currentRect.top,
+ currentRect.bottom,
+ candidateRect.top,
+ candidateRect.bottom
+ ),
+ primary,
+ cross: Math.abs(
+ this.getRectCenter(currentRect).y -
+ this.getRectCenter(candidateRect).y
+ ),
+ };
+ }
+ case "up": {
+ const primary = currentRect.top - candidateRect.bottom;
+
+ if (primary < 0) return null;
+
+ return {
+ overlap: this.getAxisOverlap(
+ currentRect.left,
+ currentRect.right,
+ candidateRect.left,
+ candidateRect.right
+ ),
+ primary,
+ cross: Math.abs(
+ this.getRectCenter(currentRect).x -
+ this.getRectCenter(candidateRect).x
+ ),
+ };
+ }
+ case "down": {
+ const primary = candidateRect.top - currentRect.bottom;
+
+ if (primary < 0) return null;
+
+ return {
+ overlap: this.getAxisOverlap(
+ currentRect.left,
+ currentRect.right,
+ candidateRect.left,
+ candidateRect.right
+ ),
+ primary,
+ cross: Math.abs(
+ this.getRectCenter(currentRect).x -
+ this.getRectCenter(candidateRect).x
+ ),
+ };
+ }
+ }
+ }
+
+ private getAxisOverlap(
+ startA: number,
+ endA: number,
+ startB: number,
+ endB: number
+ ) {
+ return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB));
+ }
+
+ private getRectCenter(rect: DOMRect) {
+ return {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ };
+ }
+
+ private resolveTargetToNode(
+ target: FocusTarget,
+ direction: FocusDirection
+ ): string | null {
+ if (target.type === "node") {
+ return this.isNodeInActiveLayer(target.id) && this.isNodeActive(target.id)
+ ? target.id
+ : null;
+ }
+
+ return this.getEntryNodeForRegion(target.id, direction);
+ }
+
+ private resolveOverrideTargetToNode(
+ target: FocusOverrideTarget,
+ direction: FocusDirection
+ ): string | null {
+ if (target.type === "item") {
+ return this.resolveItemOverrideTarget(target.itemId);
+ }
+
+ if (target.type === "block") {
+ return null;
+ }
+
+ return this.resolveRegionOverrideTarget(
+ target.regionId,
+ target.entryDirection ?? direction
+ );
+ }
+
+ private resolveItemOverrideTarget(itemId: string): string | null {
+ if (!this.nodes.has(itemId)) {
+ return null;
+ }
+
+ if (!this.isNodeInActiveLayer(itemId)) {
+ return null;
+ }
+
+ if (!this.isNodeActive(itemId)) {
+ return null;
+ }
+
+ return itemId;
+ }
+
+ private resolveRegionOverrideTarget(
+ regionId: string,
+ direction: FocusDirection
+ ): string | null {
+ if (!this.regions.has(regionId)) {
+ return null;
+ }
+
+ if (!this.isRegionInActiveLayer(regionId)) {
+ return null;
+ }
+
+ return this.getEntryNodeForRegion(regionId, direction);
+ }
+
+ private getEntryNodeForRegion(
+ regionId: string,
+ direction: FocusDirection
+ ): string | null {
+ if (!this.isRegionInActiveLayer(regionId)) {
+ return null;
+ }
+
+ const rememberedNodeId = this.lastFocusedByRegionId.get(regionId);
+
+ if (
+ rememberedNodeId &&
+ this.nodes.has(rememberedNodeId) &&
+ this.isNodeActive(rememberedNodeId) &&
+ this.isNodeWithinRegion(rememberedNodeId, regionId) &&
+ this.isNodeInActiveLayer(rememberedNodeId)
+ ) {
+ return rememberedNodeId;
+ }
+
+ return this.getBoundaryNodeInRegion(
+ regionId,
+ this.getBoundaryForEntryDirection(direction)
+ );
+ }
+
+ private getBoundaryNodeInRegion(
+ regionId: string,
+ boundary: FocusBoundary
+ ): string | null {
+ const region = this.regions.get(regionId);
+
+ if (region?.layerId !== this.getActiveLayerId()) {
+ return null;
+ }
+
+ const children = this.regionChildren.get(regionId) ?? [];
+ const orderedChildren =
+ boundary === "first" ? children : [...children].reverse();
+
+ for (const child of orderedChildren) {
+ if (
+ child.type === "node" &&
+ this.isNodeInActiveLayer(child.id) &&
+ this.isNodeActive(child.id)
+ ) {
+ return child.id;
+ }
+
+ if (child.type === "region") {
+ const descendantNodeId = this.getBoundaryNodeInRegion(
+ child.id,
+ boundary
+ );
+
+ if (descendantNodeId) {
+ return descendantNodeId;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private getDirectChildTargetForNode(
+ regionId: string,
+ nodeId: string
+ ): FocusTarget | null {
+ const node = this.nodes.get(nodeId);
+
+ if (!node) return null;
+
+ if (node.regionId === regionId) {
+ return {
+ type: "node",
+ id: nodeId,
+ };
+ }
+
+ let currentRegionId: string | null = node.regionId;
+
+ while (currentRegionId) {
+ const currentRegion = this.regions.get(currentRegionId);
+
+ if (!currentRegion) return null;
+
+ if (currentRegion.parentRegionId === regionId) {
+ return {
+ type: "region",
+ id: currentRegionId,
+ };
+ }
+
+ currentRegionId = currentRegion.parentRegionId;
+ }
+
+ return null;
+ }
+
+ private isNodeWithinRegion(nodeId: string, regionId: string): boolean {
+ const node = this.nodes.get(nodeId);
+
+ if (!node) return false;
+
+ let currentRegionId: string | null = node.regionId;
+
+ while (currentRegionId) {
+ if (currentRegionId === regionId) {
+ return true;
+ }
+
+ currentRegionId =
+ this.regions.get(currentRegionId)?.parentRegionId ?? null;
+ }
+
+ return false;
+ }
+
+ private isNodeInLayer(nodeId: string, layerId: string) {
+ return this.nodes.get(nodeId)?.layerId === layerId;
+ }
+
+ private isNodeInActiveLayer(nodeId: string) {
+ const activeLayerId = this.getActiveLayerId();
+
+ return activeLayerId ? this.isNodeInLayer(nodeId, activeLayerId) : false;
+ }
+
+ private isRegionInLayer(regionId: string, layerId: string) {
+ return this.regions.get(regionId)?.layerId === layerId;
+ }
+
+ private isRegionInActiveLayer(regionId: string) {
+ const activeLayerId = this.getActiveLayerId();
+
+ return activeLayerId
+ ? this.isRegionInLayer(regionId, activeLayerId)
+ : false;
+ }
+
+ private doesDirectionMatchOrientation(
+ direction: FocusDirection,
+ orientation: FocusOrientation
+ ) {
+ const isVerticalDirection = direction === "up" || direction === "down";
+
+ return isVerticalDirection
+ ? orientation === "vertical"
+ : orientation === "horizontal";
+ }
+
+ private getDirectionStep(direction: FocusDirection) {
+ return direction === "up" || direction === "left" ? -1 : 1;
+ }
+
+ private getBoundaryForEntryDirection(
+ direction: FocusDirection
+ ): FocusBoundary {
+ return direction === "right" || direction === "down" ? "first" : "last";
+ }
+
+ private areFocusOverridesEqual(
+ left?: FocusOverrides,
+ right?: FocusOverrides
+ ) {
+ const directions: FocusDirection[] = ["up", "down", "left", "right"];
+
+ return directions.every((direction) =>
+ this.areFocusOverrideTargetsEqual(left?.[direction], right?.[direction])
+ );
+ }
+
+ private areFocusOverrideTargetsEqual(
+ left?: FocusOverrideTarget,
+ right?: FocusOverrideTarget
+ ) {
+ if (!left && !right) {
+ return true;
+ }
+
+ if (!left || !right) {
+ return false;
+ }
+
+ if (left.type !== right.type) {
+ return false;
+ }
+
+ if (left.type === "item" && right.type === "item") {
+ return left.itemId === right.itemId;
+ }
+
+ if (left.type === "region" && right.type === "region") {
+ return (
+ left.regionId === right.regionId &&
+ left.entryDirection === right.entryDirection
+ );
+ }
+
+ if (left.type === "block" && right.type === "block") {
+ return true;
+ }
+
+ return false;
+ }
+
+ private getActiveNodeIdsWithinRegion(regionId: string) {
+ return Array.from(this.nodes.values())
+ .filter(
+ (node) =>
+ this.isNodeActive(node.id) &&
+ this.isNodeInActiveLayer(node.id) &&
+ this.isNodeWithinRegion(node.id, regionId)
+ )
+ .map((node) => node.id);
+ }
+
+ private getOverrideTargetDebugInfo(
+ target: FocusOverrideTarget,
+ direction: FocusDirection,
+ resolvedNodeId: string | null
+ ) {
+ if (target.type === "item") {
+ return {
+ type: "item",
+ itemId: target.itemId,
+ itemExists: this.nodes.has(target.itemId),
+ itemActive: this.isNodeActive(target.itemId),
+ itemInActiveLayer: this.isNodeInActiveLayer(target.itemId),
+ resolvedNodeId,
+ failureReason: resolvedNodeId
+ ? null
+ : this.getOverrideFailureReason(target, direction),
+ };
+ }
+
+ if (target.type === "region") {
+ const entryDirection = target.entryDirection ?? direction;
+
+ return {
+ type: "region",
+ regionId: target.regionId,
+ entryDirection,
+ regionExists: this.regions.has(target.regionId),
+ regionInActiveLayer: this.isRegionInActiveLayer(target.regionId),
+ rememberedNodeId:
+ this.lastFocusedByRegionId.get(target.regionId) ?? null,
+ activeNodeIds: this.getActiveNodeIdsWithinRegion(target.regionId),
+ resolvedNodeId,
+ failureReason: resolvedNodeId
+ ? null
+ : this.getOverrideFailureReason(target, direction),
+ };
+ }
+
+ return {
+ type: "block",
+ resolvedNodeId,
+ failureReason: this.getOverrideFailureReason(target, direction),
+ };
+ }
+
+ private logMoveFocusOverrideResolution(options: {
+ sourceType: "item" | "region";
+ sourceId: string;
+ direction: FocusDirection;
+ target: FocusOverrideTarget;
+ resolvedNodeId: string | null;
+ }) {
+ if (options.direction !== "up" || !isNavigationDebugEnabled()) {
+ return;
+ }
+
+ const currentFocusId = this.currentFocusId;
+ const currentNode = currentFocusId
+ ? (this.nodes.get(currentFocusId) ?? null)
+ : null;
+
+ console.info("[BigPictureNavigation] moveFocus override", {
+ direction: options.direction,
+ sourceType: options.sourceType,
+ sourceId: options.sourceId,
+ currentFocusId,
+ currentNode: currentNode
+ ? {
+ id: currentNode.id,
+ regionId: currentNode.regionId,
+ layerId: currentNode.layerId,
+ navigationState: currentNode.navigationState,
+ override: currentNode.navigationOverrides?.[options.direction],
+ }
+ : null,
+ activeLayerId: this.getActiveLayerId(),
+ target: this.getOverrideTargetDebugInfo(
+ options.target,
+ options.direction,
+ options.resolvedNodeId
+ ),
+ regionIds: Array.from(this.regions.keys()),
+ nodeIds: Array.from(this.nodes.keys()),
+ });
+ }
+
+ private warnInvalidOverride(options: {
+ sourceType: "item" | "region";
+ sourceId: string;
+ direction: FocusDirection;
+ target: FocusOverrideTarget;
+ }) {
+ if (process.env.NODE_ENV === "production") {
+ return;
+ }
+
+ const reason = this.getOverrideFailureReason(
+ options.target,
+ options.direction
+ );
+
+ console.warn(
+ `Navigation override could not resolve for ${options.sourceType} "${options.sourceId}" on "${options.direction}". Falling back to tree navigation because ${reason}.`,
+ {
+ sourceType: options.sourceType,
+ sourceId: options.sourceId,
+ direction: options.direction,
+ target: options.target,
+ activeLayerId: this.getActiveLayerId(),
+ currentFocusId: this.currentFocusId,
+ }
+ );
+ }
+
+ private getOverrideFailureReason(
+ target: FocusOverrideTarget,
+ direction: FocusDirection
+ ) {
+ if (target.type === "block") {
+ return "direction is explicitly blocked";
+ }
+
+ if (target.type === "item") {
+ if (!this.nodes.has(target.itemId)) {
+ return `item target "${target.itemId}" is not registered`;
+ }
+
+ if (!this.isNodeInActiveLayer(target.itemId)) {
+ return `item target "${target.itemId}" is outside the active layer`;
+ }
+
+ if (!this.isNodeActive(target.itemId)) {
+ return `item target "${target.itemId}" is not active`;
+ }
+
+ return `item target "${target.itemId}" could not be focused`;
+ }
+
+ if (!this.regions.has(target.regionId)) {
+ return `region target "${target.regionId}" is not registered`;
+ }
+
+ if (!this.isRegionInActiveLayer(target.regionId)) {
+ return `region target "${target.regionId}" is outside the active layer`;
+ }
+
+ const resolvedNodeId = this.getEntryNodeForRegion(
+ target.regionId,
+ target.entryDirection ?? direction
+ );
+
+ if (!resolvedNodeId) {
+ return `region target "${target.regionId}" has no active entry node`;
+ }
+
+ return `region target "${target.regionId}" could not be focused`;
+ }
+
+ private notify() {
+ this.listeners.forEach((listener) => listener());
+ }
+}
diff --git a/src/big-picture/src/stores/gamepad.store.ts b/src/big-picture/src/stores/gamepad.store.ts
new file mode 100644
index 000000000..a70cfdd33
--- /dev/null
+++ b/src/big-picture/src/stores/gamepad.store.ts
@@ -0,0 +1,93 @@
+import { create } from "zustand";
+import { GamepadRawState, GamepadService } from "../services";
+
+type GamepadStateMap = Map;
+
+interface GamepadInfo {
+ index: number;
+ name: string;
+ layout: string;
+}
+
+export interface GamepadState {
+ states: Map;
+ connectedGamepads: GamepadInfo[];
+ hasGamepadConnected: boolean;
+ activeGamepadIndex: number | null;
+
+ sync: () => void;
+ getActiveGamepad: () => GamepadInfo | null;
+ getService: () => GamepadService;
+}
+
+export const useGamepadStore = create((set, get) => {
+ let cachedConnectedGamepads: GamepadInfo[] = [];
+ let lastConnectedCount = 0;
+ let isInitialized = false;
+
+ const ensureInitialized = () => {
+ if (isInitialized) return;
+
+ isInitialized = true;
+
+ const service = GamepadService.getInstance();
+ service.onStateChange(() => {
+ get().sync();
+ });
+ };
+
+ return {
+ states: new Map(),
+ connectedGamepads: [],
+ hasGamepadConnected: false,
+ activeGamepadIndex: null,
+
+ sync: () => {
+ ensureInitialized();
+
+ const service = GamepadService.getInstance();
+ const rawStates = service.getCurrentState() as GamepadStateMap;
+ const hasGamepadConnected = rawStates.size > 0;
+ const activeGamepadIndex = service.getActiveGamepadIndex();
+
+ let connectedGamepads = cachedConnectedGamepads;
+
+ if (rawStates.size !== lastConnectedCount) {
+ connectedGamepads = Array.from(rawStates.entries()).map(
+ ([idx, state]) => ({
+ index: idx,
+ name: state.name,
+ layout: state.layout,
+ })
+ );
+
+ cachedConnectedGamepads = connectedGamepads;
+ lastConnectedCount = rawStates.size;
+ }
+
+ set({
+ states: rawStates,
+ hasGamepadConnected,
+ connectedGamepads,
+ activeGamepadIndex,
+ });
+ },
+
+ getActiveGamepad: () => {
+ const activeGamepadIndex = get().activeGamepadIndex;
+
+ if (activeGamepadIndex === null) return null;
+
+ const state = get().states.get(activeGamepadIndex);
+ if (!state) return null;
+
+ return {
+ index: activeGamepadIndex,
+ name: state.name,
+ layout: state.layout,
+ };
+ },
+
+ getService: () => GamepadService.getInstance(),
+ };
+});
diff --git a/src/big-picture/src/stores/index.ts b/src/big-picture/src/stores/index.ts
new file mode 100644
index 000000000..5230b9842
--- /dev/null
+++ b/src/big-picture/src/stores/index.ts
@@ -0,0 +1,2 @@
+export * from "./gamepad.store";
+export * from "./navigation.store";
diff --git a/src/big-picture/src/stores/navigation.store.ts b/src/big-picture/src/stores/navigation.store.ts
new file mode 100644
index 000000000..99fae07f6
--- /dev/null
+++ b/src/big-picture/src/stores/navigation.store.ts
@@ -0,0 +1,69 @@
+import { create } from "zustand";
+import {
+ type FocusLayer,
+ NavigationService,
+ type FocusNode,
+ type FocusRegion,
+ type NavigationDebugSnapshot,
+} from "../services";
+
+interface NavigationStoreSnapshot {
+ currentFocusId: string | null;
+ nodes: FocusNode[];
+ regions: FocusRegion[];
+ layers: FocusLayer[];
+ debugSnapshot: NavigationDebugSnapshot;
+}
+
+interface NavigationStoreState extends NavigationStoreSnapshot {
+ syncFromService: (navigation?: NavigationService) => void;
+ syncFromSnapshot: (snapshot: NavigationStoreSnapshot) => void;
+}
+
+const navigation = NavigationService.getInstance();
+
+const getSnapshotFromService = (
+ source: NavigationService = navigation
+): NavigationStoreSnapshot => ({
+ currentFocusId: source.getCurrentFocusId(),
+ nodes: source.getNodes(),
+ regions: source.getRegions(),
+ layers: source.getLayers(),
+ debugSnapshot: source.getDebugSnapshot(),
+});
+
+export const useNavigationStore = create((set) => ({
+ ...getSnapshotFromService(),
+
+ syncFromService: (source = navigation) => {
+ set(getSnapshotFromService(source));
+ },
+
+ syncFromSnapshot: (snapshot) => {
+ set(snapshot);
+ },
+}));
+
+export function useNavigationIsFocused(id: string) {
+ return useNavigationStore((state) => state.currentFocusId === id);
+}
+
+export function useNavigationDebugState() {
+ const currentFocusId = useNavigationStore((state) => state.currentFocusId);
+ const nodes = useNavigationStore((state) => state.nodes);
+ const regions = useNavigationStore((state) => state.regions);
+ const layers = useNavigationStore((state) => state.layers);
+ const debugSnapshot = useNavigationStore((state) => state.debugSnapshot);
+
+ return {
+ currentFocusId,
+ nodes,
+ regions,
+ layers,
+ debugSnapshot,
+ };
+}
+
+export function useNavigationSnapshot() {
+ return useNavigationDebugState();
+}
diff --git a/src/big-picture/src/styles/globals.scss b/src/big-picture/src/styles/globals.scss
new file mode 100644
index 000000000..f9e86bd3d
--- /dev/null
+++ b/src/big-picture/src/styles/globals.scss
@@ -0,0 +1,140 @@
+@import "@fontsource/space-grotesk/300.css";
+@import "@fontsource/space-grotesk/400.css";
+@import "@fontsource/space-grotesk/500.css";
+@import "@fontsource/space-grotesk/600.css";
+@import "@fontsource/space-grotesk/700.css";
+
+:root {
+ --font-space-grotesk: "Space Grotesk";
+ --primary: rgba(255, 255, 255, 1);
+ --secondary: rgba(20, 20, 20, 1);
+ --tertiary: rgba(185, 2, 2, 1);
+ --success: rgba(138, 235, 19, 1);
+ --alert: rgba(243, 198, 17, 1);
+ --error: rgba(225, 29, 72, 1);
+ --sucess-secondary: rgba(138, 235, 19, 0.5);
+ --alert-secondary: rgba(243, 198, 17, 0.5);
+ --error-secondary: rgba(225, 29, 72, 0.5);
+ --sucess-background: rgba(138, 235, 19, 0.05);
+ --alert-background: rgba(243, 198, 17, 0.05);
+ --error-background: rgba(225, 29, 72, 0.05);
+ --background: rgba(8, 8, 8, 1);
+ --surface: rgba(14, 14, 14, 1);
+ --text: rgba(206, 206, 206, 1);
+ --text-secondary: rgba(131, 131, 131, 1);
+ --text-error: rgba(225, 29, 72, 1);
+ --border: rgba(8, 8, 8, 0.1);
+ --secondary-border: rgba(255, 255, 255, 0.1);
+ --tertiary-border: rgba(185, 2, 2, 0.1);
+ --error-border: rgba(225, 29, 72, 0.1);
+ --primary-hover: rgba(255, 255, 255, 0.8);
+ --secondary-hover: rgba(255, 255, 255, 0.15);
+ --tertiary-hover: rgba(185, 2, 2, 0.5);
+ --error-hover: rgba(225, 29, 72, 0.2);
+ --spacing-unit: 0.25rem;
+ --big-picture-header-height: 56px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+::-webkit-scrollbar {
+ width: 9px;
+ background-color: rgba(0, 0, 0, 0.2);
+}
+
+::-webkit-scrollbar-track {
+ background-color: rgba(0, 0, 0, 0.2);
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, 0.15);
+ border-radius: 24px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.25);
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ overflow: hidden;
+}
+
+#big-picture {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ position: relative;
+ font-family: var(--font-space-grotesk), sans-serif;
+}
+
+.big-picture__layout {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ width: auto;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ overflow: visible;
+}
+
+.big-picture__content {
+ flex: 1 1 0;
+ width: 100%;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ padding-top: var(--big-picture-header-height);
+ box-sizing: border-box;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p {
+ margin: 0;
+}
+
+button {
+ padding: 0;
+ background-color: transparent;
+ border: none;
+ font-family: inherit;
+ color: inherit;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+input {
+ font-family: inherit;
+ color: inherit;
+}
+
+ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+li {
+ margin: 0;
+ padding: 0;
+}
diff --git a/src/big-picture/src/types/focus-item-actions.types.ts b/src/big-picture/src/types/focus-item-actions.types.ts
new file mode 100644
index 000000000..765ee1eb4
--- /dev/null
+++ b/src/big-picture/src/types/focus-item-actions.types.ts
@@ -0,0 +1,112 @@
+export type NavigationActionButton = "a" | "b" | "x" | "y" | "start" | "select";
+
+export type NavigationActionMode = "press" | "hold";
+export type FocusItemPressButton = "x" | "y";
+export type FocusItemHoldButton = "a" | "b" | "x";
+export type NavigationTargetType = "item" | "region";
+
+export interface NavigationActionContext {
+ itemId: string;
+ click: () => void;
+ hasClickableTarget: boolean;
+ originalEvent: Event | null;
+}
+
+export interface NavigationScreenActionContext {
+ currentFocusId: string | null;
+ originalEvent: Event | null;
+}
+
+export type ActionHandler = (ctx: NavigationActionContext) => void;
+export type ScreenActionHandler = (ctx: NavigationScreenActionContext) => void;
+export type NavigationTargetFocusedHandler = () => void;
+
+export interface NavigationItemTarget {
+ type: "item";
+ itemId: string;
+ onFocused?: NavigationTargetFocusedHandler;
+}
+
+export interface NavigationRegionTarget {
+ type: "region";
+ regionId: string;
+ entryDirection?: import("../services").FocusDirection;
+ onFocused?: NavigationTargetFocusedHandler;
+}
+
+export type NavigationScreenActionTarget =
+ | NavigationItemTarget
+ | NavigationRegionTarget;
+
+export type ScreenActionDefinition =
+ | ScreenActionHandler
+ | NavigationScreenActionTarget;
+
+export type FocusItemPrimaryAction = "auto" | "off" | null | ActionHandler;
+export type FocusItemSecondaryAction = "off" | null | ActionHandler;
+
+export interface FocusItemActions {
+ primary: FocusItemPrimaryAction;
+ secondary?: FocusItemSecondaryAction;
+ press?: {
+ x?: ActionHandler;
+ y?: ActionHandler;
+ };
+ hold?: {
+ a?: ActionHandler;
+ b?: ActionHandler;
+ x?: ActionHandler;
+ };
+}
+
+export interface ScreenActions {
+ press?: Partial>;
+ hold?: Partial>;
+}
+
+export interface FocusItemActionsMeta {
+ hasPrimary: boolean;
+ hasSecondary: boolean;
+ hasPressX: boolean;
+ hasPressY: boolean;
+ hasHoldA: boolean;
+ hasHoldB: boolean;
+ hasHoldX: boolean;
+}
+
+export const DEFAULT_FOCUS_ITEM_ACTIONS: FocusItemActions = {
+ primary: "auto",
+};
+
+export function resolveFocusItemActions(
+ actions?: FocusItemActions
+): FocusItemActions {
+ if (!actions) {
+ return DEFAULT_FOCUS_ITEM_ACTIONS;
+ }
+
+ return {
+ ...actions,
+ primary: actions.primary ?? "auto",
+ };
+}
+
+export function getFocusItemActionsMeta(
+ actions?: FocusItemActions
+): FocusItemActionsMeta {
+ const resolvedActions = resolveFocusItemActions(actions);
+
+ return {
+ hasPrimary:
+ resolvedActions.primary !== "off" && resolvedActions.primary !== null,
+ hasSecondary:
+ resolvedActions.secondary !== "off" &&
+ resolvedActions.secondary !== null &&
+ resolvedActions.secondary !== undefined,
+ hasPressX: Boolean(resolvedActions.press?.x),
+ hasPressY: Boolean(resolvedActions.press?.y),
+ hasHoldA: Boolean(resolvedActions.hold?.a),
+ hasHoldB: Boolean(resolvedActions.hold?.b),
+ hasHoldX: Boolean(resolvedActions.hold?.x),
+ };
+}
diff --git a/src/big-picture/src/types/gamepad.types.ts b/src/big-picture/src/types/gamepad.types.ts
new file mode 100644
index 000000000..d7f33ee24
--- /dev/null
+++ b/src/big-picture/src/types/gamepad.types.ts
@@ -0,0 +1,70 @@
+export enum GamepadButtonType {
+ BUTTON_A = "buttonA",
+ BUTTON_B = "buttonB",
+ BUTTON_X = "buttonX",
+ BUTTON_Y = "buttonY",
+ LEFT_BUMPER = "leftBumper",
+ RIGHT_BUMPER = "rightBumper",
+ LEFT_TRIGGER = "leftTrigger",
+ RIGHT_TRIGGER = "rightTrigger",
+ DPAD_UP = "dpadUp",
+ DPAD_DOWN = "dpadDown",
+ DPAD_LEFT = "dpadLeft",
+ DPAD_RIGHT = "dpadRight",
+ LEFT_STICK_PRESS = "leftStickPress",
+ RIGHT_STICK_PRESS = "rightStickPress",
+ BACK = "back",
+ START = "start",
+ HOME = "home",
+ TRACKPAD = "trackpad",
+}
+
+export enum GamepadAxisType {
+ LEFT_STICK_X = "leftStickX",
+ LEFT_STICK_Y = "leftStickY",
+ RIGHT_STICK_X = "rightStickX",
+ RIGHT_STICK_Y = "rightStickY",
+}
+
+export enum GamepadAxisDirection {
+ LEFT = "left",
+ RIGHT = "right",
+ UP = "up",
+ DOWN = "down",
+ NONE = "none",
+}
+
+export type GamepadStickSide = "left" | "right";
+
+export type GamepadInputStatus =
+ | "accepted"
+ | "ignored-duplicate-window"
+ | "ignored-echo"
+ | "ignored-inactive";
+
+export interface GamepadInputEventMeta {
+ status: GamepadInputStatus;
+ accepted: boolean;
+ activeGamepadIndex: number | null;
+ previousActiveGamepadIndex: number | null;
+ echoOfGamepadIndex?: number | null;
+ echoSuppressionMs?: number | null;
+}
+
+export interface GamepadButtonPressEvent extends GamepadInputEventMeta {
+ gamepadIndex: number;
+ button: GamepadButtonType;
+}
+
+export interface GamepadStickMoveEvent extends GamepadInputEventMeta {
+ gamepadIndex: number;
+ side: GamepadStickSide;
+ direction: GamepadAxisDirection;
+}
+
+export interface GamepadVibrationOptions {
+ duration?: number;
+ weakMagnitude?: number;
+ strongMagnitude?: number;
+ gamepadIndex?: number;
+}
diff --git a/src/big-picture/src/types/index.ts b/src/big-picture/src/types/index.ts
new file mode 100644
index 000000000..53799d668
--- /dev/null
+++ b/src/big-picture/src/types/index.ts
@@ -0,0 +1,3 @@
+export * from "./focus-item-actions.types";
+export * from "./gamepad.types";
+export * from "./steam.types";
diff --git a/src/big-picture/src/types/steam.types.ts b/src/big-picture/src/types/steam.types.ts
new file mode 100644
index 000000000..44e3ac9d3
--- /dev/null
+++ b/src/big-picture/src/types/steam.types.ts
@@ -0,0 +1,61 @@
+export interface SteamGenre {
+ id: string;
+ description: string;
+}
+
+export interface SteamScreenshot {
+ id: number;
+ path_thumbnail: string;
+ path_full: string;
+}
+
+export interface SteamVideoSource {
+ max: string;
+ "480": string;
+}
+
+export interface SteamMovies {
+ id: number;
+ mp4: SteamVideoSource;
+ webm: SteamVideoSource;
+ thumbnail: string;
+ name: string;
+ highlight: boolean;
+}
+
+export interface SteamAppDetails {
+ name: string;
+ steam_appid: number;
+ detailed_description: string;
+ about_the_game: string;
+ short_description: string;
+ legal_notice: string;
+ developers: string[];
+ publishers: string[];
+ genres: SteamGenre[];
+ movies?: SteamMovies[];
+ screenshots?: SteamScreenshot[];
+ categories: {
+ id: number;
+ description: string;
+ }[];
+ pc_requirements: {
+ minimum: string;
+ recommended: string;
+ };
+ mac_requirements: {
+ minimum: string;
+ recommended: string;
+ };
+ linux_requirements: {
+ minimum: string;
+ recommended: string;
+ };
+ release_date: {
+ coming_soon: boolean;
+ date: string;
+ };
+ content_descriptors: {
+ ids: number[];
+ };
+}
diff --git a/src/big-picture/vite-scope-big-picture-css.ts b/src/big-picture/vite-scope-big-picture-css.ts
new file mode 100644
index 000000000..2876c619d
--- /dev/null
+++ b/src/big-picture/vite-scope-big-picture-css.ts
@@ -0,0 +1,85 @@
+import type { Plugin, Rule } from "postcss";
+
+const BIG_PICTURE_ROOT_SELECTOR = "#big-picture";
+const BIG_PICTURE_PATH_FRAGMENT = "/src/big-picture/";
+const RENDERER_PATH_FRAGMENT = "/src/renderer/";
+const ROOT_SELECTOR_ALIASES = new Set([":root", "html", "body", "#root"]);
+
+const isBigPictureStyle = (filePath?: string): boolean => {
+ if (!filePath) return false;
+
+ return filePath.replaceAll("\\", "/").includes(BIG_PICTURE_PATH_FRAGMENT);
+};
+
+const isRendererStyle = (filePath?: string): boolean => {
+ if (!filePath) return false;
+
+ return filePath.replaceAll("\\", "/").includes(RENDERER_PATH_FRAGMENT);
+};
+
+const shouldSkipRule = (rule: Rule): boolean => {
+ const parent = rule.parent;
+
+ return (
+ parent?.type === "atrule" &&
+ "name" in parent &&
+ parent.name.toLowerCase().endsWith("keyframes")
+ );
+};
+
+const shouldSkipExclusion = (selector: string): boolean => {
+ const trimmed = selector.trim();
+
+ if (!trimmed) return true;
+ if (trimmed === "*") return true;
+ if (ROOT_SELECTOR_ALIASES.has(trimmed)) return true;
+ if (trimmed.includes("::")) return true;
+
+ return false;
+};
+
+const scopeSelector = (selector: string): string => {
+ const trimmedSelector = selector.trim();
+
+ if (!trimmedSelector) return selector;
+
+ if (
+ trimmedSelector === BIG_PICTURE_ROOT_SELECTOR ||
+ trimmedSelector.startsWith(`${BIG_PICTURE_ROOT_SELECTOR} `) ||
+ trimmedSelector.startsWith(`${BIG_PICTURE_ROOT_SELECTOR}:`)
+ ) {
+ return selector;
+ }
+
+ if (ROOT_SELECTOR_ALIASES.has(trimmedSelector)) {
+ return BIG_PICTURE_ROOT_SELECTOR;
+ }
+
+ return `${BIG_PICTURE_ROOT_SELECTOR} ${selector}`;
+};
+
+const excludeFromBigPicture = (selector: string): string => {
+ if (shouldSkipExclusion(selector)) {
+ return selector;
+ }
+
+ return `${selector}:where(:not(${BIG_PICTURE_ROOT_SELECTOR} *))`;
+};
+
+export const scopeBigPictureCss = (): Plugin => ({
+ postcssPlugin: "scope-big-picture-css",
+ Rule(rule) {
+ if (shouldSkipRule(rule)) {
+ return;
+ }
+
+ if (isBigPictureStyle(rule.source?.input.file)) {
+ rule.selectors = rule.selectors.map(scopeSelector);
+ return;
+ }
+
+ if (isRendererStyle(rule.source?.input.file)) {
+ rule.selectors = rule.selectors.map(excludeFromBigPicture);
+ }
+ },
+});
diff --git a/src/big-picture/vite.config.ts b/src/big-picture/vite.config.ts
new file mode 100644
index 000000000..7719768dc
--- /dev/null
+++ b/src/big-picture/vite.config.ts
@@ -0,0 +1,24 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { scopeBigPictureCss } from "./vite-scope-big-picture-css";
+
+export default defineConfig({
+ root: resolve(__dirname),
+ build: {
+ outDir: resolve(__dirname, "../../dist/big-picture"),
+ emptyOutDir: true,
+ },
+ css: {
+ postcss: {
+ plugins: [scopeBigPictureCss()],
+ },
+ },
+ resolve: {
+ alias: {
+ "@shared": resolve(__dirname, "../../src/shared"),
+ "@locales": resolve(__dirname, "../../src/locales"),
+ },
+ },
+ plugins: [react()],
+});
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index d71404f53..b23a71237 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -19,6 +19,7 @@
"my_library": "My library",
"collections": "Collections",
"games": "Games",
+ "big_picture": "Big Picture (beta)",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
@@ -717,6 +718,7 @@
"enable_achievement_notifications": "When an achievement is unlocked",
"launch_minimized": "Launch Hydra minimized",
"launch_hydra_in_library_page": "Launch Hydra in the Library page",
+ "launch_hydra_in_big_picture": "Launch Hydra in Big Picture mode (experimental)",
"disable_nsfw_alert": "Disable NSFW alert",
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievement descriptions before unlocking them",
diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json
index 23ae15d4f..70dc8005a 100755
--- a/src/locales/pt-BR/translation.json
+++ b/src/locales/pt-BR/translation.json
@@ -17,6 +17,7 @@
"downloads": "Downloads",
"settings": "Ajustes",
"my_library": "Biblioteca",
+ "big_picture": "Big Picture (beta)",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)",
@@ -626,6 +627,7 @@
"hydra_cloud": "Hydra Cloud",
"launch_minimized": "Iniciar o Hydra minimizado",
"launch_hydra_in_library_page": "Iniciar o Hydra na página da Biblioteca",
+ "launch_hydra_in_big_picture": "Iniciar o Hydra no modo Big Picture (experimental)",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download",
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de desbloqueá-las",
diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json
index 3876a8305..2842570d4 100644
--- a/src/locales/pt-PT/translation.json
+++ b/src/locales/pt-PT/translation.json
@@ -603,6 +603,7 @@
"enable_friend_request_notifications": "Quando um pedido de amizade é recebido",
"launch_minimized": "Iniciar Hydra minimizado",
"launch_hydra_in_library_page": "Iniciar o Hydra na página da Biblioteca",
+ "launch_hydra_in_big_picture": "Iniciar o Hydra no modo Big Picture (experimental)",
"disable_nsfw_alert": "Desativar alertas NSFW",
"seed_after_download_complete": "Semear após concluir o download",
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de as desbloquear",
diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json
index c62e31aae..2607e68e9 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -19,6 +19,7 @@
"my_library": "Библиотека",
"collections": "Коллекции",
"games": "Игры",
+ "big_picture": "Big Picture (beta)",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"paused": "{{title}} (Приостановлено)",
"downloading": "{{title}} ({{percentage}} - Загрузка…)",
@@ -712,6 +713,7 @@
"enable_achievement_notifications": "Когда достижение разблокировано",
"launch_minimized": "Запускать Hydra в свернутом виде",
"launch_hydra_in_library_page": "Запускать Hydra на странице библиотеки",
+ "launch_hydra_in_big_picture": "Запускать Hydra в режиме Big Picture (экспериментальная функция)",
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
@@ -831,8 +833,7 @@
"proton_version_auto": "Авто (глобальное значение по умолчанию или значение umu по умолчанию)",
"proton_source_umu_default": "выбор umu по умолчанию",
"proton_source_steam": "Установлено через Steam",
- "proton_source_compatibility_tools": "Установлено в Steam compatibilitytools.d",
- "resume_seeding": "Возобновить раздачу"
+ "proton_source_compatibility_tools": "Установлено в Steam compatibilitytools.d"
},
"notifications": {
"download_complete": "Загрузка завершена",
diff --git a/src/main/events/big-picture/index.ts b/src/main/events/big-picture/index.ts
new file mode 100644
index 000000000..499a7ae5a
--- /dev/null
+++ b/src/main/events/big-picture/index.ts
@@ -0,0 +1 @@
+export * from "./open-big-picture-window";
diff --git a/src/main/events/big-picture/open-big-picture-window.ts b/src/main/events/big-picture/open-big-picture-window.ts
new file mode 100644
index 000000000..b49772fa3
--- /dev/null
+++ b/src/main/events/big-picture/open-big-picture-window.ts
@@ -0,0 +1,6 @@
+import { WindowManager } from "@main/services";
+import { ipcMain } from "electron";
+
+ipcMain.handle("openBigPictureWindow", () => {
+ WindowManager.openBigPictureWindow();
+});
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index a03b453e7..dc6b77238 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -3,6 +3,7 @@ import { ipcMain } from "electron";
import "./auth";
import "./autoupdater";
+import "./big-picture";
import "./catalogue";
import "./cloud-save";
import "./download-sources";
diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts
index 05ec0d169..0bb08cbd6 100644
--- a/src/main/services/window-manager.ts
+++ b/src/main/services/window-manager.ts
@@ -1,3 +1,14 @@
+import { is } from "@electron-toolkit/utils";
+import { isStaging } from "@main/constants";
+import { db, gamesSublevel, levelKeys } from "@main/level";
+import icon from "@resources/icon.png?asset";
+import trayIcon from "@resources/tray-icon.png?asset";
+import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
+import type {
+ AchievementCustomNotificationPosition,
+ ScreenState,
+ UserPreferences,
+} from "@types";
import {
BrowserWindow,
Menu,
@@ -9,28 +20,19 @@ import {
screen,
shell,
} from "electron";
-import { is } from "@electron-toolkit/utils";
import { t } from "i18next";
-import path from "node:path";
-import icon from "@resources/icon.png?asset";
-import trayIcon from "@resources/tray-icon.png?asset";
-import { HydraApi } from "./hydra-api";
-import UserAgent from "user-agents";
-import { db, gamesSublevel, levelKeys } from "@main/level";
import { orderBy, slice } from "lodash-es";
-import type {
- AchievementCustomNotificationPosition,
- ScreenState,
- UserPreferences,
-} from "@types";
-import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
-import { isStaging } from "@main/constants";
+import path from "node:path";
+import UserAgent from "user-agents";
+import { HydraApi } from "./hydra-api";
import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
public static gameLauncherWindow: Electron.BrowserWindow | null = null;
+ private static bigPicture: Electron.BrowserWindow | null = null;
+ private static deferredMainMaximize = false;
private static readonly editorWindows: Map = new Map();
@@ -122,6 +124,12 @@ export class WindowManager {
public static async createMainWindow() {
if (this.mainWindow) return;
+ const userPreferences = await db
+ .get(levelKeys.userPreferences, {
+ valueEncoding: "json",
+ })
+ .catch(() => null);
+
const { isMaximized = false, ...configWithoutMaximized } =
await this.loadScreenConfig();
@@ -131,7 +139,15 @@ export class WindowManager {
this.initialConfigInitializationMainWindow
);
- if (isMaximized) {
+ this.deferredMainMaximize = false;
+
+ if (userPreferences?.launchInBigPicture) {
+ this.mainWindow.setOpacity(0);
+ this.mainWindow.setSkipTaskbar(true);
+ if (isMaximized) {
+ this.deferredMainMaximize = true;
+ }
+ } else if (isMaximized) {
this.mainWindow.maximize();
}
@@ -205,12 +221,6 @@ export class WindowManager {
}
);
- const userPreferences = await db
- .get(levelKeys.userPreferences, {
- valueEncoding: "json",
- })
- .catch(() => null);
-
const initialHash = userPreferences?.launchToLibraryPage ? "library" : "";
this.loadMainWindowURL(initialHash);
@@ -219,7 +229,11 @@ export class WindowManager {
this.mainWindow.on("ready-to-show", () => {
if (!app.isPackaged || isStaging)
WindowManager.mainWindow?.webContents.openDevTools();
- WindowManager.mainWindow?.show();
+ if (userPreferences?.launchInBigPicture) {
+ void WindowManager.openBigPictureWindow();
+ } else {
+ WindowManager.mainWindow?.show();
+ }
});
this.mainWindow.on("close", async () => {
@@ -262,6 +276,55 @@ export class WindowManager {
});
}
+ public static async openBigPictureWindow() {
+ if (this.bigPicture) {
+ this.bigPicture.focus();
+ return;
+ }
+
+ this.bigPicture = new BrowserWindow({
+ fullscreen: true,
+ backgroundColor: "#0a0a0a",
+ icon,
+ show: false,
+ webPreferences: {
+ preload: path.join(__dirname, "../preload/index.mjs"),
+ sandbox: false,
+ },
+ });
+
+ this.bigPicture.removeMenu();
+
+ if (!app.isPackaged || isStaging) {
+ this.bigPicture.webContents.openDevTools();
+ }
+
+ this.loadWindowURL(this.bigPicture, "big-picture");
+
+ this.bigPicture.once("ready-to-show", () => {
+ const main = this.mainWindow;
+ if (main && !main.isDestroyed()) {
+ main.setOpacity(1);
+ main.hide();
+ }
+ this.bigPicture?.show();
+ });
+
+ this.bigPicture.on("closed", () => {
+ this.bigPicture = null;
+ const main = this.mainWindow;
+ if (main && !main.isDestroyed()) {
+ if (WindowManager.deferredMainMaximize) {
+ main.maximize();
+ WindowManager.deferredMainMaximize = false;
+ }
+ main.setSkipTaskbar(false);
+ main.show();
+ main.focus();
+ }
+ });
+ }
+
public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) {
if (this.mainWindow) {
const authWindow = new BrowserWindow({
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a6e0e39ed..9969c6517 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -770,6 +770,9 @@ contextBridge.exposeInMainWorld("electron", {
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
+ /* Big Picture */
+ openBigPictureWindow: () => ipcRenderer.invoke("openBigPictureWindow"),
+
/* Game Launcher Window */
showGameLauncherWindow: () => ipcRenderer.invoke("showGameLauncherWindow"),
closeGameLauncherWindow: () => ipcRenderer.invoke("closeGameLauncherWindow"),
diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx
index 48ec5958e..95438dfc5 100644
--- a/src/renderer/src/components/sidebar/sidebar.tsx
+++ b/src/renderer/src/components/sidebar/sidebar.tsx
@@ -7,11 +7,11 @@ import type { GameCollection, LibraryGame } from "@types";
import {
Button,
- TextField,
ConfirmationModal,
ContextMenu,
CreateCollectionModal,
Modal,
+ TextField,
} from "@renderer/components";
import {
useDownload,
@@ -28,25 +28,26 @@ import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers";
-import { SidebarProfile } from "./sidebar-profile";
-import { sortBy } from "lodash-es";
-import cn from "classnames";
import {
+ ChevronRightIcon,
CommentDiscussionIcon,
+ FileDirectoryIcon,
+ HeartIcon,
+ PencilIcon,
PlayIcon,
PlusIcon,
- ChevronRightIcon,
- HeartIcon,
- FileDirectoryIcon,
- PencilIcon,
TrashIcon,
+ VideoIcon,
} from "@primer/octicons-react";
-import { SidebarGameItem } from "./sidebar-game-item";
-import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
-import { setFriendRequestCount } from "@renderer/features/user-details-slice";
-import { setCollections } from "@renderer/features";
-import { useDispatch } from "react-redux";
import deckyIcon from "@renderer/assets/icons/decky.png";
+import { setCollections } from "@renderer/features";
+import { setFriendRequestCount } from "@renderer/features/user-details-slice";
+import cn from "classnames";
+import { sortBy } from "lodash-es";
+import { useDispatch } from "react-redux";
+import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
+import { SidebarGameItem } from "./sidebar-game-item";
+import { SidebarProfile } from "./sidebar-profile";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -535,6 +536,10 @@ export function Sidebar() {
];
}, [collections, favoritesCount, t]);
+ const handleOpenBigPictureWindow = () => {
+ globalThis.window.electron.openBigPictureWindow();
+ };
+
return (