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 ( +
+ {title} +
{children}
+
+ ); +} + +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 ( +
+
+ + {title} + + + +
+ + +
+ ); +} + +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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[ + { label: "Y", x: 329, y: 138, button: GamepadButtonType.BUTTON_Y }, + { label: "B", x: 351, y: 160, button: GamepadButtonType.BUTTON_B }, + { label: "A", x: 329, y: 182, button: GamepadButtonType.BUTTON_A }, + { label: "X", x: 307, y: 160, button: GamepadButtonType.BUTTON_X }, + ].map((item) => ( + + + + {item.label} + + + ))} + + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} + +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"} + /> + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + ") || "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} + ) : ( +
+ )} +
+ {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} +
+
+
+

{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 ( + + {alt} + + ); +} 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 ( + +
+
+ Game List Card +
+
+
+

{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 ? ( + {label} + ) : ( + 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 ? ( + +
+

{title}

+
+
+ ) : ( +
+

{title}

+
+ )} + + ); +} 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 && ( + + )} +
+ ); +} 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} + +
+

{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} + ) : ( + + +
+
+
+

{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} + ) : ( + + +
+

{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.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} + ) : ( +
+ )} + +
+ + {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 ( + + ); + } + + if (game?.executablePath) { + return ( + + ); + } + + if (game) { + return ( + + ); + } + + return ( + + ); + }, [isGameRunning, game, onClose, onPlay, heroDownNavigationTarget]); + + return ( +
+ + +
+ {shopDetails.assets?.title + + + + + {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 ( + {`Screenshot + ); + }; + + 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} + ) : ( + + {featuredGame?.title ?? ""} + + )} +
+ +
+

Continue playing:

+

{lastPlayedLabel}

+
+ +
+ + {hasExecutable ? ( + + ) : ( + + )} + +
+ +
+ + + + +
+
+
+ +
+
+
+ + {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 ( + <> + +
+ + +
+
+ + +
+ {game.executablePath || "No executable selected"} +
+ + + + + {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)} + ) : ( + + )} +
+
+

{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 + )} +

+ +
+ +
+
+

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 ( +
+
+ +

+ {formatBytes(artifact.artifactLengthInBytes)} - {artifact.hostname} -{" "} + {formatDateTime(artifact.createdAt)} +

+

{artifact.downloadOptionTitle ?? "No download option info"}

+
+ + + + + +
+ ); +} + +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. +

+ ) : ( + + + + + )} +
+ ); +} + +function DangerZoneSection({ + game, + controller, + onOpenChangePlaytimeModal, + onRequestConfirmation, +}: Readonly<{ + game: LibraryGame; + controller: GameSettingsController; + onOpenChangePlaytimeModal: () => void; + onRequestConfirmation: (confirmation: GameSettingsConfirmation) => void; +}>) { + return ( + +
+ + {game.shop !== "custom" && ( + + )} + + {game.shop !== "custom" && ( + + )} +
+
+ ); +} + +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 +
    +
    + + +
    + + + + + +
    + +
    + + + + + + +
    +
    + + +
    + + } + 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} + +
    + ))} +
    +
    + + +
    +
    + Before + + After +
    + + + + Right +
    +
    + + +
    + + +
    +
    +
    + + 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 ( +
    +

    Downloads

    +
    + ); +} 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 ( +
    +

    Loading...

    +
    + ); + } + + 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 ? ( + {featuredGame.title} setShouldShowLogoFallback(true)} + /> + ) : ( + + {featuredGame.title} + + )} +
    + + {featuredGame.description && ( +

    + {featuredGame.description} +

    + )} + + + + + {!isInLibrary ? ( + + ) : gameState.hasExecutable ? ( + + ) : ( + + )} + +
    +
    +
    + ); +} 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 ( +
    +

    No games in library

    +
    + ); + } + + 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 (
    {window.electron.platform === "linux" && ( diff --git a/src/shared/index.ts b/src/shared/index.ts index 50168176d..0849331fb 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -20,6 +20,7 @@ import { AchievementNotificationInfo } from "@types"; export * from "./constants"; export * from "./html-sanitizer"; +export * from "./use-hls-video"; export class UserNotLoggedInError extends Error { constructor() { diff --git a/src/renderer/src/hooks/use-hls-video.ts b/src/shared/use-hls-video.ts similarity index 74% rename from src/renderer/src/hooks/use-hls-video.ts rename to src/shared/use-hls-video.ts index eea4065d4..54fe2de99 100644 --- a/src/renderer/src/hooks/use-hls-video.ts +++ b/src/shared/use-hls-video.ts @@ -1,6 +1,5 @@ import { useEffect, useRef } from "react"; import Hls from "hls.js"; -import { logger } from "@renderer/logger"; interface UseHlsVideoOptions { videoSrc: string | undefined; @@ -10,9 +9,20 @@ interface UseHlsVideoOptions { loop?: boolean; } +interface HlsVideoLogger { + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +const defaultLogger: HlsVideoLogger = { + warn: console.warn, + error: console.error, +}; + export function useHlsVideo( videoRef: React.RefObject, - { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions + { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions, + log: HlsVideoLogger = defaultLogger ) { const hlsRef = useRef(null); @@ -40,7 +50,7 @@ export function useHlsVideo( hls.on(Hls.Events.MANIFEST_PARSED, () => { if (autoplay) { video.play().catch((err) => { - logger.warn("Failed to autoplay HLS video:", err); + log.warn("Failed to autoplay HLS video:", err); }); } }); @@ -49,15 +59,15 @@ export function useHlsVideo( if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: - logger.error("HLS network error, trying to recover"); + log.error("HLS network error, trying to recover"); hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: - logger.error("HLS media error, trying to recover"); + log.error("HLS media error, trying to recover"); hls.recoverMediaError(); break; default: - logger.error("HLS fatal error, destroying instance"); + log.error("HLS fatal error, destroying instance"); hls.destroy(); break; } @@ -73,18 +83,18 @@ export function useHlsVideo( video.load(); if (autoplay) { video.play().catch((err) => { - logger.warn("Failed to autoplay HLS video:", err); + log.warn("Failed to autoplay HLS video:", err); }); } return () => { video.src = ""; }; - } else { - logger.warn("HLS playback is not supported in this browser"); - return undefined; } - }, [videoRef, videoSrc, videoType, autoplay, muted, loop]); + + log.warn("HLS playback is not supported in this browser"); + return undefined; + }, [videoRef, videoSrc, videoType, autoplay, muted, loop, log]); useEffect(() => { const video = videoRef.current; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index a79da3c93..8a5d1e40b 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -120,6 +120,7 @@ export interface UserPreferences { runAtStartup?: boolean; startMinimized?: boolean; launchToLibraryPage?: boolean; + launchInBigPicture?: boolean; disableNsfwAlert?: boolean; enableAutoInstall?: boolean; seedAfterDownloadComplete?: boolean; diff --git a/tsconfig.node.json b/tsconfig.node.json index b876123b6..706628257 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,21 +1,21 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*"], - "compilerOptions": { - "module": "ESNext", - "composite": true, - "types": ["electron-vite/node"], - "baseUrl": ".", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "strictPropertyInitialization": false, - "paths": { - "@main/*": ["src/main/*"], - "@renderer/*": ["src/renderer/*"], - "@types": ["src/types/index.ts"], - "@locales": ["src/locales/index.ts"], - "@resources": ["src/resources/index.ts"], - "@shared": ["src/shared/index.ts"] - } - } -} \ No newline at end of file +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*", "src/big-picture/vite-scope-big-picture-css.ts"], + "compilerOptions": { + "module": "ESNext", + "composite": true, + "types": ["electron-vite/node"], + "baseUrl": ".", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "paths": { + "@main/*": ["src/main/*"], + "@renderer/*": ["src/renderer/*"], + "@types": ["src/types/index.ts"], + "@locales": ["src/locales/index.ts"], + "@resources": ["src/resources/index.ts"], + "@shared": ["src/shared/index.ts"] + } + } +} diff --git a/tsconfig.web.json b/tsconfig.web.json index 51ba22a2c..5e8d64be3 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,27 +1,29 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", - "include": [ - "src/renderer/src/env.d.ts", - "src/renderer/src/**/*", - "src/renderer/src/**/*.tsx", - "src/preload/*.d.ts", - "src/locales/index.ts", - "src/shared/**/*", - "src/stories/**/*", - "src/types/**/*", - ".storybook/**/*" - ], - "compilerOptions": { - "composite": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@renderer/*": [ - "src/renderer/src/*" - ], - "@types": ["src/types/index.ts"], - "@locales": ["src/locales/index.ts"], - "@shared": ["src/shared/index.ts"] - } - } -} +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", + "include": [ + "src/renderer/src/env.d.ts", + "src/renderer/src/**/*", + "src/renderer/src/**/*.tsx", + "src/big-picture/src/**/*", + "src/big-picture/src/**/*.tsx", + "src/preload/*.d.ts", + "src/locales/index.ts", + "src/shared/**/*", + "src/stories/**/*", + "src/types/**/*", + ".storybook/**/*" + ], + "compilerOptions": { + "composite": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@renderer/*": [ + "src/renderer/src/*" + ], + "@types": ["src/types/index.ts"], + "@locales": ["src/locales/index.ts"], + "@shared": ["src/shared/index.ts"] + } + } +} diff --git a/yarn.lock b/yarn.lock index 2011b2e50..a581e7daf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1382,6 +1382,11 @@ resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.2.10.tgz#74476071b53fda1554ac009b98f18d763d0f7135" integrity sha512-J58RVfS/C0Z2VBF+PoU260Tx8cdRGYuS+e3yQe4hYaIYDl0sEVn5CzlLo5zVRvQD0HaIUTV8AZMfqR7rtdEpqQ== +"@fontsource/space-grotesk@^5.2.10": + version "5.2.10" + resolved "https://registry.yarnpkg.com/@fontsource/space-grotesk/-/space-grotesk-5.2.10.tgz#36eef6a105be125d1025c206eb48f1a3d37ad512" + integrity sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew== + "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -1790,6 +1795,11 @@ "@parcel/watcher-win32-ia32" "2.5.1" "@parcel/watcher-win32-x64" "2.5.1" +"@phosphor-icons/react@^2.1.10": + version "2.1.10" + resolved "https://registry.yarnpkg.com/@phosphor-icons/react/-/react-2.1.10.tgz#3a97ec5b7a4b8d53afeb29125bc17e74ed2daf92" + integrity sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -2001,6 +2011,13 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.2" +"@radix-ui/react-slot@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83" + integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-callback-ref@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" @@ -9470,3 +9487,8 @@ yup@^1.5.0: tiny-case "^1.0.3" toposort "^2.0.2" type-fest "^2.19.0" + +zustand@^5.0.12: + version "5.0.12" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.12.tgz#ed36f647aa89965c4019b671dfc23ef6c6e3af8c" + integrity sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==