From b4c87637ca94373d15c1d4d0215b8c0fee03ea44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:02:47 +0000 Subject: [PATCH 01/10] Initial plan From 077917a4db66e3a6948db00fdc472fec16d4c18f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:13:51 +0000 Subject: [PATCH 02/10] Add Big Picture Mode MVP implementation Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/desktop/src/main.ts | 9 + apps/desktop/src/preload.ts | 3 +- apps/web/src/App.tsx | 22 ++ apps/web/src/hooks/useGamepad.ts | 168 +++++++++++++ apps/web/src/pages/BigPicturePage.tsx | 320 ++++++++++++++++++++++++ apps/web/src/pages/SettingsPage.tsx | 34 +++ apps/web/src/store/slices/uiSlice.ts | 14 +- apps/web/src/store/types.ts | 2 + apps/web/src/styles/big-picture.css | 343 ++++++++++++++++++++++++++ 9 files changed, 911 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/hooks/useGamepad.ts create mode 100644 apps/web/src/pages/BigPicturePage.tsx create mode 100644 apps/web/src/styles/big-picture.css diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 83048cc..5a2c3b6 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -68,6 +68,15 @@ function createWindow(): void { } }); + // IPC handler for toggling fullscreen Big Picture mode + ipcMain.handle("toggle-big-picture", (_event, enabled: boolean) => { + if (enabled) { + window.setFullScreen(true); + } else { + window.setFullScreen(false); + } + }); + const devUrl = process.env.CROCDESK_DEV_URL || "http://localhost:5173"; if (process.env.CROCDESK_DEV_URL || process.env.NODE_ENV === "development") { window.loadURL(devUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4704500..4a00f95 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("crocdesk", { - revealInFolder: (filePath: string) => ipcRenderer.invoke("reveal-in-folder", filePath) + revealInFolder: (filePath: string) => ipcRenderer.invoke("reveal-in-folder", filePath), + toggleBigPicture: (enabled: boolean) => ipcRenderer.invoke("toggle-big-picture", enabled) }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 66aee24..4d82649 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,7 +7,9 @@ import DownloadsPage from "./pages/DownloadsPage"; import SettingsPage from "./pages/SettingsPage"; import GameDetailPage from "./pages/GameDetailPage"; import LibraryItemDetailPage from "./pages/LibraryItemDetailPage"; +import BigPicturePage from "./pages/BigPicturePage"; import { WelcomeView, shouldShowWelcome } from "./components/WelcomeView"; +import { useUIStore } from "./store"; // useMemo imported above function AppRoutes() { @@ -25,6 +27,7 @@ function AppRoutes() { } /> } /> } /> + } /> {state?.backgroundLocation && ( @@ -83,9 +86,28 @@ function ModalOverlay({ children }: { children: React.ReactNode }) { export default function App() { const [showWelcome, setShowWelcome] = useState(() => shouldShowWelcome()); + const bigPictureMode = useUIStore((state) => state.bigPictureMode); + const launchInBigPicture = useUIStore((state) => state.launchInBigPicture); + const setBigPictureMode = useUIStore((state) => state.setBigPictureMode); const navLinkClass = ({ isActive }: { isActive: boolean }) => isActive ? "active" : undefined; + // Auto-launch Big Picture mode if enabled + useEffect(() => { + if (launchInBigPicture && !bigPictureMode) { + setBigPictureMode(true); + } + }, [launchInBigPicture, bigPictureMode, setBigPictureMode]); + + // If Big Picture mode is enabled, show only Big Picture UI + if (bigPictureMode) { + return ( + + + + ); + } + return (
diff --git a/apps/web/src/hooks/useGamepad.ts b/apps/web/src/hooks/useGamepad.ts new file mode 100644 index 0000000..a7dbcfb --- /dev/null +++ b/apps/web/src/hooks/useGamepad.ts @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from "react"; + +export type GamepadButton = "A" | "B" | "X" | "Y" | "LB" | "RB" | "LT" | "RT" | "SELECT" | "START" | "L3" | "R3" | "DPAD_UP" | "DPAD_DOWN" | "DPAD_LEFT" | "DPAD_RIGHT"; + +export type GamepadState = { + connected: boolean; + buttons: Record; + axes: { leftX: number; leftY: number; rightX: number; rightY: number }; +}; + +const BUTTON_MAP: Record = { + 0: "A", // Cross (PS) / A (Xbox) + 1: "B", // Circle (PS) / B (Xbox) + 2: "X", // Square (PS) / X (Xbox) + 3: "Y", // Triangle (PS) / Y (Xbox) + 4: "LB", // L1 / LB + 5: "RB", // R1 / RB + 6: "LT", // L2 / LT + 7: "RT", // R2 / RT + 8: "SELECT", // Share / Select + 9: "START", // Options / Start + 10: "L3", // L3 + 11: "R3", // R3 + 12: "DPAD_UP", + 13: "DPAD_DOWN", + 14: "DPAD_LEFT", + 15: "DPAD_RIGHT" +}; + +const AXIS_THRESHOLD = 0.5; + +export function useGamepad() { + const [gamepadState, setGamepadState] = useState({ + connected: false, + buttons: {} as Record, + axes: { leftX: 0, leftY: 0, rightX: 0, rightY: 0 } + }); + + const animationFrameRef = useRef(); + const prevButtonsRef = useRef>({} as Record); + + useEffect(() => { + let connected = false; + + const checkGamepad = () => { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + const gamepad = gamepads[0]; // Use first gamepad + + if (gamepad) { + connected = true; + + // Map buttons + const buttons: Record = {} as Record; + gamepad.buttons.forEach((button, index) => { + const buttonName = BUTTON_MAP[index]; + if (buttonName) { + buttons[buttonName] = button.pressed; + } + }); + + // Map axes + const leftX = gamepad.axes[0] || 0; + const leftY = gamepad.axes[1] || 0; + const rightX = gamepad.axes[2] || 0; + const rightY = gamepad.axes[3] || 0; + + // Convert axes to digital directions + if (Math.abs(leftX) > AXIS_THRESHOLD) { + if (leftX < 0) buttons.DPAD_LEFT = true; + if (leftX > 0) buttons.DPAD_RIGHT = true; + } + if (Math.abs(leftY) > AXIS_THRESHOLD) { + if (leftY < 0) buttons.DPAD_UP = true; + if (leftY > 0) buttons.DPAD_DOWN = true; + } + + setGamepadState({ + connected: true, + buttons, + axes: { leftX, leftY, rightX, rightY } + }); + + prevButtonsRef.current = buttons; + } else if (connected) { + // Gamepad disconnected + setGamepadState({ + connected: false, + buttons: {} as Record, + axes: { leftX: 0, leftY: 0, rightX: 0, rightY: 0 } + }); + connected = false; + } + + animationFrameRef.current = requestAnimationFrame(checkGamepad); + }; + + // Start polling + animationFrameRef.current = requestAnimationFrame(checkGamepad); + + // Listen for gamepad connect/disconnect events + const handleGamepadConnected = () => { + connected = true; + }; + + const handleGamepadDisconnected = () => { + connected = false; + }; + + window.addEventListener("gamepadconnected", handleGamepadConnected); + window.addEventListener("gamepaddisconnected", handleGamepadDisconnected); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + window.removeEventListener("gamepadconnected", handleGamepadConnected); + window.removeEventListener("gamepaddisconnected", handleGamepadDisconnected); + }; + }, []); + + return gamepadState; +} + +export function useGamepadNavigation(onNavigate: (direction: "up" | "down" | "left" | "right") => void, onSelect: () => void, onBack: () => void) { + const gamepadState = useGamepad(); + const prevButtonsRef = useRef>({} as Record); + const lastNavigateTimeRef = useRef(0); + const NAVIGATE_COOLDOWN = 200; // ms between navigation events + + useEffect(() => { + const now = Date.now(); + const { buttons } = gamepadState; + + // Check for button press (not held) + const wasPressed = (button: GamepadButton) => { + return buttons[button] && !prevButtonsRef.current[button]; + }; + + // Navigation with cooldown + if (now - lastNavigateTimeRef.current > NAVIGATE_COOLDOWN) { + if (buttons.DPAD_UP) { + onNavigate("up"); + lastNavigateTimeRef.current = now; + } else if (buttons.DPAD_DOWN) { + onNavigate("down"); + lastNavigateTimeRef.current = now; + } else if (buttons.DPAD_LEFT) { + onNavigate("left"); + lastNavigateTimeRef.current = now; + } else if (buttons.DPAD_RIGHT) { + onNavigate("right"); + lastNavigateTimeRef.current = now; + } + } + + // Button presses (no cooldown) + if (wasPressed("A")) { + onSelect(); + } + if (wasPressed("B")) { + onBack(); + } + + prevButtonsRef.current = { ...buttons }; + }, [gamepadState, onNavigate, onSelect, onBack]); + + return gamepadState; +} diff --git a/apps/web/src/pages/BigPicturePage.tsx b/apps/web/src/pages/BigPicturePage.tsx new file mode 100644 index 0000000..dca7bf4 --- /dev/null +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../lib/api"; +import { useUIStore } from "../store"; +import { useGamepadNavigation } from "../hooks/useGamepad"; +import type { LibraryItem } from "@crocdesk/shared"; +import "../styles/big-picture.css"; + +type NavSection = "home" | "library" | "search" | "downloads" | "settings" | "exit"; + +export default function BigPicturePage() { + const navigate = useNavigate(); + const setBigPictureMode = useUIStore((state) => state.setBigPictureMode); + const [activeSection, setActiveSection] = useState("home"); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isSidebarFocused, setIsSidebarFocused] = useState(true); + const gridRef = useRef(null); + + // Fetch library items + const libraryQuery = useQuery({ + queryKey: ["library-items"], + queryFn: () => apiGet("/library/items") + }); + + const libraryItems = libraryQuery.data || []; + + // Enable fullscreen on mount if running in Electron + useEffect(() => { + if (typeof window !== "undefined" && "crocdesk" in window) { + const electron = window.crocdesk as { toggleBigPicture?: (enabled: boolean) => Promise }; + if (electron.toggleBigPicture) { + electron.toggleBigPicture(true).catch(console.error); + } + } + }, []); + + // Exit Big Picture mode + const exitBigPicture = useCallback(() => { + setBigPictureMode(false); + // Exit fullscreen if running in Electron + if (typeof window !== "undefined" && "crocdesk" in window) { + const electron = window.crocdesk as { toggleBigPicture?: (enabled: boolean) => Promise }; + if (electron.toggleBigPicture) { + electron.toggleBigPicture(false).catch(console.error); + } + } + navigate("/"); + }, [setBigPictureMode, navigate]); + + // Handle navigation + const handleNavigation = useCallback((direction: "up" | "down" | "left" | "right") => { + if (isSidebarFocused) { + // Navigate sidebar + const sections: NavSection[] = ["home", "library", "search", "downloads", "settings", "exit"]; + const currentIndex = sections.indexOf(activeSection); + + if (direction === "up" && currentIndex > 0) { + setActiveSection(sections[currentIndex - 1]); + } else if (direction === "down" && currentIndex < sections.length - 1) { + setActiveSection(sections[currentIndex + 1]); + } else if (direction === "right") { + setIsSidebarFocused(false); + setSelectedIndex(0); + } + } else { + // Navigate content grid + const itemsPerRow = 4; + const totalItems = libraryItems.length; + + if (direction === "left") { + if (selectedIndex % itemsPerRow === 0) { + setIsSidebarFocused(true); + } else { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } + } else if (direction === "right") { + setSelectedIndex(Math.min(totalItems - 1, selectedIndex + 1)); + } else if (direction === "up") { + setSelectedIndex(Math.max(0, selectedIndex - itemsPerRow)); + } else if (direction === "down") { + setSelectedIndex(Math.min(totalItems - 1, selectedIndex + itemsPerRow)); + } + } + }, [isSidebarFocused, activeSection, selectedIndex, libraryItems.length]); + + // Handle select + const handleSelect = useCallback(() => { + if (isSidebarFocused) { + if (activeSection === "exit") { + exitBigPicture(); + } else { + setIsSidebarFocused(false); + setSelectedIndex(0); + } + } else { + // Open selected game + const item = libraryItems[selectedIndex]; + if (item) { + // Navigate to detail view or launch game + navigate(`/library/item?id=${item.id}`, { state: { backgroundLocation: location } }); + } + } + }, [isSidebarFocused, activeSection, selectedIndex, libraryItems, exitBigPicture, navigate]); + + // Handle back + const handleBack = useCallback(() => { + if (!isSidebarFocused) { + setIsSidebarFocused(true); + } else { + exitBigPicture(); + } + }, [isSidebarFocused, exitBigPicture]); + + // Gamepad support + useGamepadNavigation(handleNavigation, handleSelect, handleBack); + + // Keyboard support + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + handleNavigation("up"); + break; + case "ArrowDown": + e.preventDefault(); + handleNavigation("down"); + break; + case "ArrowLeft": + e.preventDefault(); + handleNavigation("left"); + break; + case "ArrowRight": + e.preventDefault(); + handleNavigation("right"); + break; + case "Enter": + e.preventDefault(); + handleSelect(); + break; + case "Escape": + e.preventDefault(); + handleBack(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleNavigation, handleSelect, handleBack]); + + // Scroll selected item into view + useEffect(() => { + if (!isSidebarFocused && gridRef.current) { + const items = gridRef.current.querySelectorAll(".bp-game-card"); + const selectedItem = items[selectedIndex]; + if (selectedItem) { + selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + }, [selectedIndex, isSidebarFocused]); + + return ( +
+ + +
+
+

+ {activeSection === "home" && "Welcome"} + {activeSection === "library" && "Library"} + {activeSection === "search" && "Search"} + {activeSection === "downloads" && "Downloads"} + {activeSection === "settings" && "Settings"} +

+
+ + {activeSection === "library" && ( +
+ {libraryQuery.isLoading && ( +
Loading library...
+ )} + {libraryItems.length === 0 && !libraryQuery.isLoading && ( +
+

Your library is empty

+

Browse and download games to get started

+
+ )} + {libraryItems.map((item, index) => ( +
{ + setSelectedIndex(index); + setIsSidebarFocused(false); + handleSelect(); + }} + > +
+ {item.gameSlug ? ( +
+ {item.platform?.toUpperCase() || "?"} +
+ ) : ( +
+ {item.platform?.toUpperCase() || "?"} +
+ )} +
+
+
{item.path.split("/").pop() || item.path}
+
{item.platform?.toUpperCase() || "Unknown"}
+
+
+ ))} +
+ )} + + {activeSection === "home" && ( +
+

Welcome to Big Picture Mode

+

Use your controller or keyboard to navigate:

+
    +
  • D-Pad / Arrows: Navigate
  • +
  • A / Enter: Select
  • +
  • B / Escape: Back
  • +
+
+ )} + + {activeSection === "search" && ( +
+

Search coming soon

+
+ )} + + {activeSection === "downloads" && ( +
+

Downloads view coming soon

+
+ )} + + {activeSection === "settings" && ( +
+

Settings coming soon

+
+ )} +
+ +
+
+ 🎮 Controller connected + Press B to go back +
+
+
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 216e134..c1674c2 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -6,8 +6,10 @@ import { useUIStore } from "../store"; import { useTheme } from "../components/ThemeProvider"; import { Card, Input, Button } from "../components/ui"; import { spacing } from "../lib/design-tokens"; +import { useNavigate } from "react-router-dom"; export default function SettingsPage() { + const navigate = useNavigate(); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: () => apiGet("/settings") @@ -19,6 +21,9 @@ export default function SettingsPage() { const theme = useUIStore((state) => state.theme); const setThemePreference = useUIStore((state) => state.setTheme); + const setBigPictureMode = useUIStore((state) => state.setBigPictureMode); + const launchInBigPicture = useUIStore((state) => state.launchInBigPicture); + const setLaunchInBigPicture = useUIStore((state) => state.setLaunchInBigPicture); const { setTheme: _setThemeObject } = useTheme(); // Sync draft with query data when it changes (e.g., after refetch) @@ -46,6 +51,11 @@ export default function SettingsPage() { // ThemeProvider will pick up the change automatically }; + const handleEnterBigPicture = () => { + setBigPictureMode(true); + navigate("/big-picture"); + }; + return (
@@ -84,6 +94,30 @@ export default function SettingsPage() {
+ +

Big Picture Mode

+

+ Controller-friendly fullscreen mode optimized for TV/living room use. +

+
+ + +
+
+

Download Directory

diff --git a/apps/web/src/store/slices/uiSlice.ts b/apps/web/src/store/slices/uiSlice.ts index f603715..f495a95 100644 --- a/apps/web/src/store/slices/uiSlice.ts +++ b/apps/web/src/store/slices/uiSlice.ts @@ -7,6 +7,8 @@ type UIActions = { setStickyPlatform: (platform: string) => void; setStickyRegion: (region: string) => void; setTheme: (theme: "light" | "dark") => void; + setBigPictureMode: (enabled: boolean) => void; + setLaunchInBigPicture: (enabled: boolean) => void; }; export type UIStore = UIState & UIActions; @@ -42,7 +44,9 @@ const initialState: UIState = { gridColumns: 3, stickyPlatform: "", stickyRegion: "", - theme: getInitialTheme() + theme: getInitialTheme(), + bigPictureMode: false, + launchInBigPicture: false }; export const useUIStore = create()( @@ -53,7 +57,9 @@ export const useUIStore = create()( setGridColumns: (columns) => set({ gridColumns: columns }), setStickyPlatform: (platform) => set({ stickyPlatform: platform }), setStickyRegion: (region) => set({ stickyRegion: region }), - setTheme: (theme) => set({ theme }) + setTheme: (theme) => set({ theme }), + setBigPictureMode: (enabled) => set({ bigPictureMode: enabled }), + setLaunchInBigPicture: (enabled) => set({ launchInBigPicture: enabled }) }), { name: "crocdesk-ui-storage", @@ -61,7 +67,9 @@ export const useUIStore = create()( stickyPlatform: state.stickyPlatform, stickyRegion: state.stickyRegion, gridColumns: state.gridColumns, - theme: state.theme + theme: state.theme, + bigPictureMode: state.bigPictureMode, + launchInBigPicture: state.launchInBigPicture }) } ) diff --git a/apps/web/src/store/types.ts b/apps/web/src/store/types.ts index 5e17435..d4df04f 100644 --- a/apps/web/src/store/types.ts +++ b/apps/web/src/store/types.ts @@ -33,6 +33,8 @@ export type UIState = { stickyPlatform: string; stickyRegion: string; theme: "light" | "dark"; + bigPictureMode: boolean; + launchInBigPicture: boolean; }; diff --git a/apps/web/src/styles/big-picture.css b/apps/web/src/styles/big-picture.css new file mode 100644 index 0000000..9881d8a --- /dev/null +++ b/apps/web/src/styles/big-picture.css @@ -0,0 +1,343 @@ +/* Big Picture Mode Styles */ + +.big-picture-mode { + position: fixed; + inset: 0; + background: linear-gradient(135deg, #0a0e27 0%, #1a1d3a 100%); + color: #ffffff; + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 1fr auto; + font-family: "Space Grotesk", sans-serif; + overflow: hidden; + z-index: 9999; +} + +/* Sidebar Navigation */ +.bp-sidebar { + grid-row: 1 / -1; + background: linear-gradient(180deg, rgba(20, 30, 60, 0.9) 0%, rgba(10, 15, 35, 0.9) 100%); + backdrop-filter: blur(10px); + padding: 40px 20px; + display: flex; + flex-direction: column; + gap: 40px; + border-right: 2px solid rgba(61, 184, 117, 0.3); + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); +} + +.bp-logo { + font-size: 48px; + font-weight: 700; + text-align: center; + background: linear-gradient(135deg, #3db875 0%, #2d8659 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 0 30px rgba(61, 184, 117, 0.5); +} + +.bp-nav { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.bp-nav-item { + display: flex; + align-items: center; + gap: 20px; + padding: 24px 28px; + background: rgba(255, 255, 255, 0.05); + border: 3px solid transparent; + border-radius: 12px; + color: #ffffff; + font-size: 28px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.bp-nav-item:hover { + background: rgba(61, 184, 117, 0.2); + border-color: rgba(61, 184, 117, 0.4); + transform: translateX(8px); +} + +.bp-nav-item.focused { + background: linear-gradient(135deg, rgba(61, 184, 117, 0.3) 0%, rgba(45, 134, 89, 0.3) 100%); + border-color: #3db875; + box-shadow: 0 0 30px rgba(61, 184, 117, 0.6), inset 0 0 20px rgba(61, 184, 117, 0.2); + transform: translateX(12px) scale(1.05); +} + +.bp-nav-icon { + font-size: 36px; + width: 48px; + text-align: center; +} + +.bp-nav-label { + flex: 1; +} + +/* Content Area */ +.bp-content { + padding: 40px 60px; + overflow-y: auto; + overflow-x: hidden; +} + +.bp-header { + margin-bottom: 40px; +} + +.bp-title { + font-size: 64px; + font-weight: 700; + color: #ffffff; + text-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); + margin: 0; +} + +/* Game Grid */ +.bp-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 32px; + padding-bottom: 40px; +} + +.bp-game-card { + background: rgba(255, 255, 255, 0.08); + border: 4px solid transparent; + border-radius: 16px; + overflow: hidden; + cursor: pointer; + transition: all 0.3s ease; + aspect-ratio: 3 / 4; + display: flex; + flex-direction: column; +} + +.bp-game-card:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(61, 184, 117, 0.5); + transform: translateY(-8px); +} + +.bp-game-card.focused { + background: rgba(61, 184, 117, 0.2); + border-color: #3db875; + box-shadow: 0 0 40px rgba(61, 184, 117, 0.8), inset 0 0 30px rgba(61, 184, 117, 0.2); + transform: translateY(-12px) scale(1.08); +} + +.bp-game-cover { + flex: 1; + position: relative; + overflow: hidden; + background: linear-gradient(135deg, rgba(61, 184, 117, 0.1) 0%, rgba(45, 134, 89, 0.1) 100%); +} + +.bp-game-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bp-game-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 72px; + font-weight: 700; + color: rgba(255, 255, 255, 0.3); + background: linear-gradient(135deg, rgba(20, 30, 60, 0.8) 0%, rgba(10, 15, 35, 0.8) 100%); +} + +.bp-game-info { + padding: 20px; + background: rgba(0, 0, 0, 0.5); +} + +.bp-game-title { + font-size: 24px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bp-game-platform { + font-size: 18px; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; +} + +/* Loading & Empty States */ +.bp-loading, +.bp-empty, +.bp-section-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 40px; + text-align: center; +} + +.bp-loading { + font-size: 32px; + color: rgba(255, 255, 255, 0.6); +} + +.bp-empty p { + font-size: 36px; + margin: 0 0 20px 0; + color: rgba(255, 255, 255, 0.8); +} + +.bp-hint { + font-size: 24px; + color: rgba(255, 255, 255, 0.5); +} + +.bp-section-placeholder { + font-size: 36px; + color: rgba(255, 255, 255, 0.6); + min-height: 400px; +} + +/* Welcome Screen */ +.bp-welcome { + max-width: 900px; + margin: 0 auto; + padding: 60px 40px; +} + +.bp-welcome h2 { + font-size: 48px; + font-weight: 700; + color: #3db875; + margin: 0 0 40px 0; + text-shadow: 0 0 20px rgba(61, 184, 117, 0.5); +} + +.bp-welcome p { + font-size: 28px; + color: rgba(255, 255, 255, 0.8); + margin: 0 0 30px 0; + line-height: 1.6; +} + +.bp-controls { + list-style: none; + padding: 0; + margin: 40px 0 0 0; +} + +.bp-controls li { + font-size: 26px; + color: rgba(255, 255, 255, 0.9); + padding: 16px 0; + border-bottom: 2px solid rgba(255, 255, 255, 0.1); +} + +.bp-controls li:last-child { + border-bottom: none; +} + +.bp-controls strong { + color: #3db875; + font-weight: 600; + margin-right: 12px; +} + +/* Footer */ +.bp-footer { + grid-column: 2; + padding: 24px 60px; + background: rgba(10, 15, 35, 0.8); + border-top: 2px solid rgba(61, 184, 117, 0.3); +} + +.bp-hint-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 40px; +} + +.bp-hint-bar .bp-hint { + font-size: 20px; + color: rgba(255, 255, 255, 0.6); +} + +/* Scrollbar Styling */ +.bp-content::-webkit-scrollbar { + width: 12px; +} + +.bp-content::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +.bp-content::-webkit-scrollbar-thumb { + background: rgba(61, 184, 117, 0.5); + border-radius: 6px; +} + +.bp-content::-webkit-scrollbar-thumb:hover { + background: rgba(61, 184, 117, 0.7); +} + +/* Responsive adjustments for 4K */ +@media (min-width: 2560px) { + .big-picture-mode { + grid-template-columns: 400px 1fr; + } + + .bp-logo { + font-size: 64px; + } + + .bp-nav-item { + padding: 32px 36px; + font-size: 36px; + } + + .bp-nav-icon { + font-size: 48px; + } + + .bp-title { + font-size: 96px; + } + + .bp-grid { + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 48px; + } + + .bp-game-title { + font-size: 32px; + } + + .bp-game-platform { + font-size: 24px; + } +} + +/* Safe area margins for TV overscan */ +@media (display-mode: fullscreen) { + .big-picture-mode { + padding: 3vh 3vw; + } +} From b489554a06e262d57eeae916fc6bed17e0a05169 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:20:01 +0000 Subject: [PATCH 03/10] Add tests for Big Picture Mode and finalize implementation Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- .../pages/__tests__/BigPicturePage.spec.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/web/src/pages/__tests__/BigPicturePage.spec.tsx diff --git a/apps/web/src/pages/__tests__/BigPicturePage.spec.tsx b/apps/web/src/pages/__tests__/BigPicturePage.spec.tsx new file mode 100644 index 0000000..21361c8 --- /dev/null +++ b/apps/web/src/pages/__tests__/BigPicturePage.spec.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import BigPicturePage from '../BigPicturePage'; + +// Mock the useUIStore hook +vi.mock('../../store', () => ({ + useUIStore: vi.fn(() => ({ + setBigPictureMode: vi.fn() + })) +})); + +// Mock the API module +vi.mock('../../lib/api', () => ({ + apiGet: vi.fn(() => Promise.resolve([])) +})); + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ( + + + {children} + + + ); +}; + +describe('BigPicturePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the Big Picture welcome screen', () => { + render( + + + + ); + + expect(screen.getAllByText('Welcome to Big Picture Mode')[0]).toBeInTheDocument(); + }); + + it('renders navigation menu with all sections', () => { + render( + + + + ); + + expect(screen.getAllByText('Home')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Library')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Search')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Downloads')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Settings')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Exit')[0]).toBeInTheDocument(); + }); + + it('displays controller instructions', () => { + render( + + + + ); + + const dpadInstructions = screen.getAllByText(/D-Pad \/ Arrows:/); + const selectInstructions = screen.getAllByText(/A \/ Enter:/); + const backInstructions = screen.getAllByText(/B \/ Escape:/); + + expect(dpadInstructions.length).toBeGreaterThan(0); + expect(selectInstructions.length).toBeGreaterThan(0); + expect(backInstructions.length).toBeGreaterThan(0); + }); + + it('renders the Jacare logo', () => { + render( + + + + ); + + const logos = screen.getAllByText('Jacare'); + expect(logos.length).toBeGreaterThan(0); + }); +}); From 2ee14a6e255a638208edfb2ad627e3ba4c14b932 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:23:11 +0000 Subject: [PATCH 04/10] Fix code review issues - add constants and imports Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/web/src/hooks/useGamepad.ts | 9 +++++---- apps/web/src/pages/BigPicturePage.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useGamepad.ts b/apps/web/src/hooks/useGamepad.ts index a7dbcfb..724f565 100644 --- a/apps/web/src/hooks/useGamepad.ts +++ b/apps/web/src/hooks/useGamepad.ts @@ -8,6 +8,10 @@ export type GamepadState = { axes: { leftX: number; leftY: number; rightX: number; rightY: number }; }; +// Constants +const AXIS_THRESHOLD = 0.5; +const NAVIGATE_COOLDOWN_MS = 200; // Cooldown between navigation events to prevent double-input + const BUTTON_MAP: Record = { 0: "A", // Cross (PS) / A (Xbox) 1: "B", // Circle (PS) / B (Xbox) @@ -27,8 +31,6 @@ const BUTTON_MAP: Record = { 15: "DPAD_RIGHT" }; -const AXIS_THRESHOLD = 0.5; - export function useGamepad() { const [gamepadState, setGamepadState] = useState({ connected: false, @@ -125,7 +127,6 @@ export function useGamepadNavigation(onNavigate: (direction: "up" | "down" | "le const gamepadState = useGamepad(); const prevButtonsRef = useRef>({} as Record); const lastNavigateTimeRef = useRef(0); - const NAVIGATE_COOLDOWN = 200; // ms between navigation events useEffect(() => { const now = Date.now(); @@ -137,7 +138,7 @@ export function useGamepadNavigation(onNavigate: (direction: "up" | "down" | "le }; // Navigation with cooldown - if (now - lastNavigateTimeRef.current > NAVIGATE_COOLDOWN) { + if (now - lastNavigateTimeRef.current > NAVIGATE_COOLDOWN_MS) { if (buttons.DPAD_UP) { onNavigate("up"); lastNavigateTimeRef.current = now; diff --git a/apps/web/src/pages/BigPicturePage.tsx b/apps/web/src/pages/BigPicturePage.tsx index dca7bf4..d54bbf1 100644 --- a/apps/web/src/pages/BigPicturePage.tsx +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../lib/api"; import { useUIStore } from "../store"; @@ -9,8 +9,12 @@ import "../styles/big-picture.css"; type NavSection = "home" | "library" | "search" | "downloads" | "settings" | "exit"; +// Grid configuration +const ITEMS_PER_ROW = 4; // Matches the CSS grid-template-columns for Big Picture + export default function BigPicturePage() { const navigate = useNavigate(); + const location = useLocation(); const setBigPictureMode = useUIStore((state) => state.setBigPictureMode); const [activeSection, setActiveSection] = useState("home"); const [selectedIndex, setSelectedIndex] = useState(0); @@ -65,7 +69,7 @@ export default function BigPicturePage() { } } else { // Navigate content grid - const itemsPerRow = 4; + const itemsPerRow = ITEMS_PER_ROW; const totalItems = libraryItems.length; if (direction === "left") { From 3e7f08d975fc37af2d3e75562a4d9cd1205bfc26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:34:22 +0000 Subject: [PATCH 05/10] Fix linting issues - add location to useCallback deps and wrap libraryItems in useMemo Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/web/src/pages/BigPicturePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/BigPicturePage.tsx b/apps/web/src/pages/BigPicturePage.tsx index d54bbf1..d68bda0 100644 --- a/apps/web/src/pages/BigPicturePage.tsx +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useCallback } from "react"; +import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../lib/api"; @@ -27,7 +27,7 @@ export default function BigPicturePage() { queryFn: () => apiGet("/library/items") }); - const libraryItems = libraryQuery.data || []; + const libraryItems = useMemo(() => libraryQuery.data || [], [libraryQuery.data]); // Enable fullscreen on mount if running in Electron useEffect(() => { @@ -105,7 +105,7 @@ export default function BigPicturePage() { navigate(`/library/item?id=${item.id}`, { state: { backgroundLocation: location } }); } } - }, [isSidebarFocused, activeSection, selectedIndex, libraryItems, exitBigPicture, navigate]); + }, [isSidebarFocused, activeSection, selectedIndex, libraryItems, exitBigPicture, navigate, location]); // Handle back const handleBack = useCallback(() => { From 83b579565e434b0d247b70c23b3cb175a3ce73f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:40:11 +0000 Subject: [PATCH 06/10] Add coherent animations and e2e test coverage for Big Picture mode Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/web/src/pages/BigPicturePage.tsx | 2 +- apps/web/src/styles/big-picture.css | 84 +++++++++++- tests/e2e/big-picture.spec.ts | 179 ++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/big-picture.spec.ts diff --git a/apps/web/src/pages/BigPicturePage.tsx b/apps/web/src/pages/BigPicturePage.tsx index d68bda0..39ca800 100644 --- a/apps/web/src/pages/BigPicturePage.tsx +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -230,7 +230,7 @@ export default function BigPicturePage() {

-
+

{activeSection === "home" && "Welcome"} diff --git a/apps/web/src/styles/big-picture.css b/apps/web/src/styles/big-picture.css index 9881d8a..1c74b6d 100644 --- a/apps/web/src/styles/big-picture.css +++ b/apps/web/src/styles/big-picture.css @@ -11,6 +11,16 @@ font-family: "Space Grotesk", sans-serif; overflow: hidden; z-index: 9999; + animation: bp-fade-in 0.3s ease-out; +} + +@keyframes bp-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } } /* Sidebar Navigation */ @@ -56,8 +66,9 @@ font-size: 28px; font-weight: 600; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); text-align: left; + line-height: 1.2; } .bp-nav-item:hover { @@ -71,16 +82,31 @@ border-color: #3db875; box-shadow: 0 0 30px rgba(61, 184, 117, 0.6), inset 0 0 20px rgba(61, 184, 117, 0.2); transform: translateX(12px) scale(1.05); + animation: bp-pulse 2s ease-in-out infinite; +} + +@keyframes bp-pulse { + 0%, 100% { + box-shadow: 0 0 30px rgba(61, 184, 117, 0.6), inset 0 0 20px rgba(61, 184, 117, 0.2); + } + 50% { + box-shadow: 0 0 40px rgba(61, 184, 117, 0.8), inset 0 0 30px rgba(61, 184, 117, 0.3); + } } .bp-nav-icon { font-size: 36px; width: 48px; text-align: center; + display: flex; + align-items: center; + justify-content: center; } .bp-nav-label { flex: 1; + display: flex; + align-items: center; } /* Content Area */ @@ -88,6 +114,18 @@ padding: 40px 60px; overflow-y: auto; overflow-x: hidden; + animation: bp-slide-in 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes bp-slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } } .bp-header { @@ -100,6 +138,7 @@ color: #ffffff; text-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); margin: 0; + line-height: 1.1; } /* Game Grid */ @@ -116,10 +155,28 @@ border-radius: 16px; overflow: hidden; cursor: pointer; - transition: all 0.3s ease; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); aspect-ratio: 3 / 4; display: flex; flex-direction: column; + animation: bp-card-appear 0.4s cubic-bezier(0.4, 0, 0.2, 1) backwards; +} + +.bp-game-card:nth-child(1) { animation-delay: 0.05s; } +.bp-game-card:nth-child(2) { animation-delay: 0.1s; } +.bp-game-card:nth-child(3) { animation-delay: 0.15s; } +.bp-game-card:nth-child(4) { animation-delay: 0.2s; } +.bp-game-card:nth-child(n+5) { animation-delay: 0.25s; } + +@keyframes bp-card-appear { + from { + opacity: 0; + transform: scale(0.9) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } .bp-game-card:hover { @@ -133,6 +190,16 @@ border-color: #3db875; box-shadow: 0 0 40px rgba(61, 184, 117, 0.8), inset 0 0 30px rgba(61, 184, 117, 0.2); transform: translateY(-12px) scale(1.08); + animation: bp-card-appear 0.4s cubic-bezier(0.4, 0, 0.2, 1) backwards, bp-card-focus 2s ease-in-out infinite; +} + +@keyframes bp-card-focus { + 0%, 100% { + box-shadow: 0 0 40px rgba(61, 184, 117, 0.8), inset 0 0 30px rgba(61, 184, 117, 0.2); + } + 50% { + box-shadow: 0 0 50px rgba(61, 184, 117, 1), inset 0 0 40px rgba(61, 184, 117, 0.3); + } } .bp-game-cover { @@ -173,12 +240,14 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 1.3; } .bp-game-platform { font-size: 18px; color: rgba(255, 255, 255, 0.7); font-weight: 500; + line-height: 1.3; } /* Loading & Empty States */ @@ -191,11 +260,22 @@ justify-content: center; padding: 80px 40px; text-align: center; + animation: bp-fade-in 0.4s ease-out; } .bp-loading { font-size: 32px; color: rgba(255, 255, 255, 0.6); + animation: bp-pulse-opacity 1.5s ease-in-out infinite; +} + +@keyframes bp-pulse-opacity { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } } .bp-empty p { diff --git a/tests/e2e/big-picture.spec.ts b/tests/e2e/big-picture.spec.ts new file mode 100644 index 0000000..b1465c3 --- /dev/null +++ b/tests/e2e/big-picture.spec.ts @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test'; + +/** + * Big Picture Mode e2e test + * + * Tests the Big Picture Mode UI including: + * 1. Entering Big Picture mode from settings + * 2. Navigation with keyboard + * 3. Section switching + * 4. Exiting Big Picture mode + */ +test.describe('Big Picture Mode E2E', () => { + test('user can enter, navigate, and exit Big Picture mode', async ({ page }) => { + // Navigate to the app + await page.goto('/'); + + // Dismiss the welcome view if it appears + const welcomeSkipButton = page.getByRole('button', { name: /Skip|Get Started/i }); + try { + await welcomeSkipButton.waitFor({ state: 'visible', timeout: 2000 }); + await welcomeSkipButton.click(); + await page.waitForTimeout(500); + } catch { + // Welcome view not shown, continue + } + + // Step 1: Navigate to Settings + await test.step('Navigate to Settings page', async () => { + const settingsLink = page.getByRole('link', { name: 'Settings' }); + await settingsLink.click(); + await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); + }); + + // Step 2: Enter Big Picture Mode + await test.step('Enter Big Picture Mode', async () => { + const enterBigPictureButton = page.getByRole('button', { name: /Enter Big Picture Mode/i }); + await expect(enterBigPictureButton).toBeVisible(); + await enterBigPictureButton.click(); + + // Wait for Big Picture mode to load + await page.waitForTimeout(500); + + // Verify Big Picture elements are visible + await expect(page.locator('.big-picture-mode')).toBeVisible(); + await expect(page.locator('.bp-logo')).toContainText('Jacare'); + + // Verify navigation items + await expect(page.getByText('Home')).toBeVisible(); + await expect(page.getByText('Library')).toBeVisible(); + await expect(page.getByText('Search')).toBeVisible(); + await expect(page.getByText('Downloads')).toBeVisible(); + await expect(page.getByText('Settings')).toBeVisible(); + await expect(page.getByText('Exit')).toBeVisible(); + }); + + // Step 3: Test keyboard navigation + await test.step('Navigate with keyboard', async () => { + // Verify we're on Home (Welcome screen) + await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); + await expect(page.getByText('Welcome to Big Picture Mode')).toBeVisible(); + + // Press ArrowDown to navigate to Library + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(300); + await expect(page.getByRole('heading', { name: 'Library' })).toBeVisible(); + + // Press ArrowDown to navigate to Search + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(300); + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + // Press ArrowUp to go back to Library + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(300); + await expect(page.getByRole('heading', { name: 'Library' })).toBeVisible(); + }); + + // Step 4: Test Library section + await test.step('Verify Library section', async () => { + // Should show empty state if no items + const emptyMessage = page.getByText('Your library is empty'); + if (await emptyMessage.isVisible()) { + await expect(page.getByText('Browse and download games to get started')).toBeVisible(); + } + }); + + // Step 5: Navigate to Downloads + await test.step('Navigate to Downloads section', async () => { + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(300); + await expect(page.getByRole('heading', { name: 'Downloads' })).toBeVisible(); + }); + + // Step 6: Exit Big Picture Mode + await test.step('Exit Big Picture Mode', async () => { + // Press Escape to exit + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + + // Verify we're back to normal mode + const bigPictureMode = page.locator('.big-picture-mode'); + await expect(bigPictureMode).not.toBeVisible(); + + // Verify normal UI is visible + const sidebar = page.locator('.sidebar'); + await expect(sidebar).toBeVisible(); + }); + }); + + test('user can click navigation items in Big Picture mode', async ({ page }) => { + await page.goto('/'); + + // Dismiss welcome if shown + try { + await page.getByRole('button', { name: /Skip/i }).click({ timeout: 2000 }); + await page.waitForTimeout(300); + } catch { + // Continue + } + + // Navigate to Settings and enter Big Picture + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('button', { name: /Enter Big Picture Mode/i }).click(); + await page.waitForTimeout(500); + + // Test clicking navigation items + await test.step('Click Library navigation', async () => { + const libraryButton = page.locator('.bp-nav-item', { hasText: 'Library' }); + await libraryButton.click(); + await expect(page.getByRole('heading', { name: 'Library' })).toBeVisible(); + }); + + await test.step('Click Search navigation', async () => { + const searchButton = page.locator('.bp-nav-item', { hasText: 'Search' }); + await searchButton.click(); + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + }); + + await test.step('Click Exit to leave Big Picture', async () => { + const exitButton = page.locator('.bp-nav-item', { hasText: 'Exit' }); + await exitButton.click(); + await page.waitForTimeout(500); + + // Verify we exited + await expect(page.locator('.big-picture-mode')).not.toBeVisible(); + }); + }); + + test('Big Picture mode has proper focus indicators', async ({ page }) => { + await page.goto('/'); + + // Skip welcome + try { + await page.getByRole('button', { name: /Skip/i }).click({ timeout: 2000 }); + await page.waitForTimeout(300); + } catch { + // Continue + } + + // Enter Big Picture mode + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('button', { name: /Enter Big Picture Mode/i }).click(); + await page.waitForTimeout(500); + + await test.step('Verify focus indicators on navigation', async () => { + // Home should be focused initially + const homeButton = page.locator('.bp-nav-item.focused', { hasText: 'Home' }); + await expect(homeButton).toBeVisible(); + + // Navigate and check focus moves + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(200); + + const libraryButton = page.locator('.bp-nav-item.focused', { hasText: 'Library' }); + await expect(libraryButton).toBeVisible(); + }); + }); +}); From 30d578bdf1dddc7cc992ea7389986ec0ff09b136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:45:12 +0000 Subject: [PATCH 07/10] Add experimental EmulatorJS support in Big Picture mode Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/web/src/components/EmulatorPlayer.tsx | 194 +++++++++++++++++ apps/web/src/components/emulator.css | 220 +++++++++++++++++++ apps/web/src/pages/BigPicturePage.tsx | 36 ++- docs/EMULATORJS.md | 242 +++++++++++++++++++++ 4 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/EmulatorPlayer.tsx create mode 100644 apps/web/src/components/emulator.css create mode 100644 docs/EMULATORJS.md diff --git a/apps/web/src/components/EmulatorPlayer.tsx b/apps/web/src/components/EmulatorPlayer.tsx new file mode 100644 index 0000000..14d9d96 --- /dev/null +++ b/apps/web/src/components/EmulatorPlayer.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from "react"; +import "./emulator.css"; + +/** + * EmulatorJS Component - Experimental + * + * This component provides a web-based emulator interface for playing ROMs. + * It's designed to work within Big Picture Mode for a console-like experience. + * + * Requirements: + * 1. EmulatorJS library must be served from /emulatorjs/ path + * 2. ROM files must be accessible via URL + * 3. BIOS files must be provided for certain systems (PS1, PSP, etc.) + * + * Supported Systems: + * - NES, SNES, GB/GBC/GBA, N64, PS1, PSP, DS, Arcade (MAME), Sega systems + */ + +export type EmulatorConfig = { + core: string; // e.g., "nes", "snes", "gba", "n64", "psx", "psp" + romUrl: string; + biosUrl?: string; + saveStateUrl?: string; + gameId?: string; + gameName?: string; +}; + +type Props = { + config: EmulatorConfig; + onExit?: () => void; + onError?: (error: Error) => void; +}; + +// Platform to core mapping +const CORE_MAP: Record = { + nes: "nes", + snes: "snes", + "game-boy": "gb", + "game-boy-color": "gbc", + "game-boy-advance": "gba", + n64: "n64", + ps1: "psx", + psp: "psp", + nds: "nds", + genesis: "segaMD", + "master-system": "segaMS", + "game-gear": "segaGG", + arcade: "mame" +}; + +export function getEmulatorCore(platform: string): string | null { + return CORE_MAP[platform.toLowerCase()] || null; +} + +export default function EmulatorPlayer({ config, onExit, onError }: Props) { + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Check if EmulatorJS is available + if (typeof (window as any).EJS_player === "undefined") { + const errorMsg = "EmulatorJS library not found. Please ensure EmulatorJS is properly installed and served."; + // Use setTimeout to avoid synchronous setState in effect + setTimeout(() => { + setError(errorMsg); + onError?.(new Error(errorMsg)); + }, 0); + return; + } + + if (!containerRef.current) return; + + try { + // Initialize EmulatorJS + const EJS = (window as any).EJS_player; + + // Configure EmulatorJS + (window as any).EJS_core = config.core; + (window as any).EJS_gameUrl = config.romUrl; + (window as any).EJS_gameName = config.gameName || "Game"; + + if (config.biosUrl) { + (window as any).EJS_biosUrl = config.biosUrl; + } + + if (config.saveStateUrl) { + (window as any).EJS_saveStateURL = config.saveStateUrl; + } + + // Set paths (assumes EmulatorJS is served from /emulatorjs/) + (window as any).EJS_pathtodata = "/emulatorjs/data/"; + + // Enable fullscreen by default + (window as any).EJS_startFullscreen = false; // We handle fullscreen in Big Picture + + // Disable ads + (window as any).EJS_ads = false; + + // Initialize the player + EJS("#emulator-container"); + + setIsReady(true); + } catch (err) { + const errorMsg = `Failed to initialize emulator: ${err}`; + setTimeout(() => { + setError(errorMsg); + onError?.(new Error(errorMsg)); + }, 0); + } + + // Cleanup + return () => { + // EmulatorJS cleanup if needed + if ((window as any).EJS_emulator) { + try { + (window as any).EJS_emulator.pause(); + } catch (e) { + console.error("Error pausing emulator:", e); + } + } + }; + }, [config, onError]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Escape to exit + if (e.key === "Escape") { + e.preventDefault(); + onExit?.(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onExit]); + + return ( +
+
+
+ {config.gameName || "Playing"} ({config.core.toUpperCase()}) +
+ +
+ + {error ? ( +
+

Emulator Error

+

{error}

+
+

Installation Instructions:

+
    +
  1. Download EmulatorJS from: https://github.com/EmulatorJS/EmulatorJS
  2. +
  3. Place EmulatorJS files in your public/emulatorjs/ directory
  4. +
  5. Ensure your web server serves these files
  6. +
  7. Include the EmulatorJS script in your HTML
  8. +
+

+ Note: This is an experimental feature. EmulatorJS must be + separately installed and configured for game emulation to work. +

+
+ +
+ ) : ( + <> +
+ + {!isReady && ( +
+
+

Loading emulator...

+
+ )} + +
+

🎮 Use your controller or keyboard to play

+

Press ESC to exit

+
+ + )} +
+ ); +} diff --git a/apps/web/src/components/emulator.css b/apps/web/src/components/emulator.css new file mode 100644 index 0000000..effac70 --- /dev/null +++ b/apps/web/src/components/emulator.css @@ -0,0 +1,220 @@ +/* EmulatorJS Player Styles - Big Picture Mode Compatible */ + +.emulator-wrapper { + position: fixed; + inset: 0; + background: #000000; + color: #ffffff; + display: flex; + flex-direction: column; + z-index: 10000; + font-family: "Space Grotesk", sans-serif; +} + +.emulator-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 40px; + background: linear-gradient(180deg, rgba(10, 14, 39, 0.95) 0%, rgba(0, 0, 0, 0.8) 100%); + border-bottom: 2px solid rgba(61, 184, 117, 0.3); +} + +.emulator-title { + font-size: 32px; + font-weight: 700; + color: #3db875; + text-shadow: 0 2px 10px rgba(61, 184, 117, 0.5); +} + +.emulator-exit-btn { + padding: 12px 24px; + background: rgba(220, 53, 69, 0.8); + color: #ffffff; + border: 2px solid rgba(220, 53, 69, 1); + border-radius: 8px; + font-size: 20px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.emulator-exit-btn:hover { + background: rgba(220, 53, 69, 1); + transform: scale(1.05); + box-shadow: 0 0 20px rgba(220, 53, 69, 0.6); +} + +.emulator-container { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: #000000; +} + +/* EmulatorJS will inject its canvas here */ +#emulator-container canvas { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.emulator-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.9); + z-index: 10; +} + +.emulator-spinner { + width: 80px; + height: 80px; + border: 8px solid rgba(61, 184, 117, 0.2); + border-top-color: #3db875; + border-radius: 50%; + animation: emulator-spin 1s linear infinite; +} + +@keyframes emulator-spin { + to { + transform: rotate(360deg); + } +} + +.emulator-loading p { + margin-top: 30px; + font-size: 24px; + color: rgba(255, 255, 255, 0.8); +} + +.emulator-controls-hint { + padding: 15px 40px; + background: linear-gradient(0deg, rgba(10, 14, 39, 0.95) 0%, rgba(0, 0, 0, 0.8) 100%); + border-top: 2px solid rgba(61, 184, 117, 0.3); + text-align: center; +} + +.emulator-controls-hint p { + margin: 5px 0; + font-size: 18px; + color: rgba(255, 255, 255, 0.7); +} + +/* Error State */ +.emulator-error { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px; + text-align: center; + background: linear-gradient(135deg, #0a0e27 0%, #1a1d3a 100%); +} + +.emulator-error h2 { + font-size: 48px; + color: #dc3545; + margin: 0 0 20px 0; +} + +.emulator-error > p { + font-size: 24px; + color: rgba(255, 255, 255, 0.8); + margin: 0 0 40px 0; + max-width: 800px; +} + +.emulator-error-instructions { + max-width: 900px; + background: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(61, 184, 117, 0.3); + border-radius: 12px; + padding: 30px; + margin: 0 0 30px 0; + text-align: left; +} + +.emulator-error-instructions h3 { + font-size: 28px; + color: #3db875; + margin: 0 0 20px 0; +} + +.emulator-error-instructions ol { + font-size: 20px; + line-height: 1.8; + color: rgba(255, 255, 255, 0.9); + margin: 0 0 20px 0; + padding-left: 30px; +} + +.emulator-error-instructions li { + margin: 10px 0; +} + +.emulator-error-instructions p { + font-size: 18px; + color: rgba(255, 255, 255, 0.7); + margin: 0; + padding: 15px; + background: rgba(255, 193, 7, 0.1); + border-left: 4px solid #ffc107; + border-radius: 4px; +} + +.emulator-back-btn { + padding: 18px 40px; + background: linear-gradient(135deg, rgba(61, 184, 117, 0.8) 0%, rgba(45, 134, 89, 0.8) 100%); + color: #ffffff; + border: 3px solid #3db875; + border-radius: 12px; + font-size: 24px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.emulator-back-btn:hover { + background: linear-gradient(135deg, rgba(61, 184, 117, 1) 0%, rgba(45, 134, 89, 1) 100%); + transform: scale(1.05); + box-shadow: 0 0 30px rgba(61, 184, 117, 0.8); +} + +/* Responsive */ +@media (max-width: 1024px) { + .emulator-title { + font-size: 24px; + } + + .emulator-exit-btn { + font-size: 16px; + padding: 10px 20px; + } + + .emulator-error h2 { + font-size: 36px; + } + + .emulator-error > p { + font-size: 20px; + } + + .emulator-error-instructions { + padding: 20px; + } + + .emulator-error-instructions h3 { + font-size: 24px; + } + + .emulator-error-instructions ol { + font-size: 18px; + } +} diff --git a/apps/web/src/pages/BigPicturePage.tsx b/apps/web/src/pages/BigPicturePage.tsx index 39ca800..84752ca 100644 --- a/apps/web/src/pages/BigPicturePage.tsx +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../lib/api"; import { useUIStore } from "../store"; import { useGamepadNavigation } from "../hooks/useGamepad"; +import EmulatorPlayer, { getEmulatorCore, type EmulatorConfig } from "../components/EmulatorPlayer"; import type { LibraryItem } from "@crocdesk/shared"; import "../styles/big-picture.css"; @@ -19,6 +20,7 @@ export default function BigPicturePage() { const [activeSection, setActiveSection] = useState("home"); const [selectedIndex, setSelectedIndex] = useState(0); const [isSidebarFocused, setIsSidebarFocused] = useState(true); + const [emulatorConfig, setEmulatorConfig] = useState(null); const gridRef = useRef(null); // Fetch library items @@ -98,11 +100,25 @@ export default function BigPicturePage() { setSelectedIndex(0); } } else { - // Open selected game + // Open selected game or launch emulator const item = libraryItems[selectedIndex]; if (item) { - // Navigate to detail view or launch game - navigate(`/library/item?id=${item.id}`, { state: { backgroundLocation: location } }); + // Try to launch with emulator if platform is supported + const core = item.platform ? getEmulatorCore(item.platform) : null; + + if (core) { + // Launch emulator (experimental) + const config: EmulatorConfig = { + core, + romUrl: `/library-files/${item.path}`, // Adjust based on your file serving + gameName: item.path.split("/").pop() || "Unknown Game", + gameId: item.id.toString() + }; + setEmulatorConfig(config); + } else { + // Fallback: Navigate to detail view + navigate(`/library/item?id=${item.id}`, { state: { backgroundLocation: location } }); + } } } }, [isSidebarFocused, activeSection, selectedIndex, libraryItems, exitBigPicture, navigate, location]); @@ -165,6 +181,20 @@ export default function BigPicturePage() { } }, [selectedIndex, isSidebarFocused]); + // If emulator is active, show emulator + if (emulatorConfig) { + return ( + setEmulatorConfig(null)} + onError={(error) => { + console.error("Emulator error:", error); + setEmulatorConfig(null); + }} + /> + ); + } + return (