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/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/hooks/useGamepad.ts b/apps/web/src/hooks/useGamepad.ts new file mode 100644 index 0000000..724f565 --- /dev/null +++ b/apps/web/src/hooks/useGamepad.ts @@ -0,0 +1,169 @@ +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 }; +}; + +// 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) + 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" +}; + +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); + + 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_MS) { + 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..84752ca --- /dev/null +++ b/apps/web/src/pages/BigPicturePage.tsx @@ -0,0 +1,354 @@ +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"; +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"; + +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); + const [isSidebarFocused, setIsSidebarFocused] = useState(true); + const [emulatorConfig, setEmulatorConfig] = useState(null); + const gridRef = useRef(null); + + // Fetch library items + const libraryQuery = useQuery({ + queryKey: ["library-items"], + queryFn: () => apiGet("/library/items") + }); + + const libraryItems = useMemo(() => libraryQuery.data || [], [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 = ITEMS_PER_ROW; + 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 or launch emulator + const item = libraryItems[selectedIndex]; + if (item) { + // 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]); + + // 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]); + + // If emulator is active, show emulator + if (emulatorConfig) { + return ( + setEmulatorConfig(null)} + onError={(error) => { + console.error("Emulator error:", error); + setEmulatorConfig(null); + }} + /> + ); + } + + 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/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); + }); +}); 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..1c74b6d --- /dev/null +++ b/apps/web/src/styles/big-picture.css @@ -0,0 +1,423 @@ +/* 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; + animation: bp-fade-in 0.3s ease-out; +} + +@keyframes bp-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 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.25s cubic-bezier(0.4, 0, 0.2, 1); + text-align: left; + line-height: 1.2; +} + +.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); + 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 */ +.bp-content { + 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 { + 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; + line-height: 1.1; +} + +/* 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 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 { + 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); + 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 { + 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; + 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 */ +.bp-loading, +.bp-empty, +.bp-section-placeholder { + display: flex; + flex-direction: column; + align-items: center; + 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 { + 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; + } +} diff --git a/docs/EMULATORJS.md b/docs/EMULATORJS.md new file mode 100644 index 0000000..1f9f41c --- /dev/null +++ b/docs/EMULATORJS.md @@ -0,0 +1,242 @@ +# EmulatorJS Integration - Experimental + +This document describes the experimental EmulatorJS integration for Jacare's Big Picture Mode. + +## Overview + +EmulatorJS is a web-based emulator wrapper that allows playing classic console games directly in the browser. This integration is **experimental** and requires additional setup. + +## Features + +- **In-Browser Emulation**: Play games without external emulators +- **Multi-Platform**: Supports NES, SNES, GB/GBC/GBA, N64, PS1, PSP, DS, Arcade, and Sega systems +- **Controller Support**: Works with gamepads via the existing gamepad integration +- **Big Picture Integration**: Seamlessly launches from the library view + +## Installation + +### 1. Install EmulatorJS + +```bash +# Option 1: Via npm (if you want to bundle it) +npm install emulatorjs + +# Option 2: Download from GitHub +# Download from https://github.com/EmulatorJS/EmulatorJS/releases +``` + +### 2. Set Up EmulatorJS Files + +EmulatorJS requires specific files to be served from your web server: + +``` +public/ + emulatorjs/ + data/ # Core emulator files + nes.data + snes.data + gba.data + n64.data + psx.data + ... (other cores) + loader.js # Main EmulatorJS loader +``` + +### 3. Add EmulatorJS Script to Your HTML + +Add this to your `index.html` (or load it dynamically): + +```html + +``` + +### 4. Configure ROM Serving + +Ensure your server can serve ROM files from a configured path. By default, the integration expects ROMs to be accessible via: + +``` +/library-files/{rom-path} +``` + +You may need to configure your server to serve files from the library directory. + +## Usage + +### From Big Picture Mode + +1. Navigate to the Library section +2. Select a game that has a supported platform +3. Press Enter or A button to launch +4. The emulator will load automatically if EmulatorJS is installed + +### Supported Platforms + +| Platform | Core | BIOS Required | +|----------|------|---------------| +| NES | nes | No | +| SNES | snes | No | +| Game Boy | gb | No | +| Game Boy Color | gbc | No | +| Game Boy Advance | gba | No | +| N64 | n64 | No | +| PlayStation 1 | psx | Yes | +| PSP | psp | Yes | +| Nintendo DS | nds | No | +| Genesis/Mega Drive | segaMD | No | +| Master System | segaMS | No | +| Game Gear | segaGG | No | +| Arcade (MAME) | mame | No | + +### BIOS Files + +Some systems (PS1, PSP) require BIOS files. Place these in: + +``` +public/emulatorjs/data/bios/ +``` + +Required BIOS files: +- **PS1**: `scph1001.bin`, `scph5500.bin`, `scph5501.bin` +- **PSP**: `ppsspp/` directory with PSP firmware files + +## Configuration + +### Custom Paths + +If you need to customize paths, modify the `EmulatorPlayer` component: + +```typescript +// In EmulatorPlayer.tsx +(window as any).EJS_pathtodata = "/your-custom-path/data/"; +``` + +### Save States + +To enable save state persistence, provide a save state URL: + +```typescript +const config: EmulatorConfig = { + core: "nes", + romUrl: "/path/to/rom.nes", + saveStateUrl: "/api/save-states/game-id", // Your API endpoint + // ... +}; +``` + +## Controls + +### Keyboard + +Default EmulatorJS keyboard controls: +- **Arrow Keys**: D-Pad +- **Z / X**: A / B buttons +- **A / S**: X / Y buttons (SNES) +- **Enter**: Start +- **Shift**: Select +- **ESC**: Exit emulator + +### Gamepad + +Controllers are automatically detected and mapped by EmulatorJS. + +## Troubleshooting + +### "EmulatorJS library not found" + +**Cause**: EmulatorJS script not loaded or path incorrect + +**Solution**: +1. Verify EmulatorJS files are in `public/emulatorjs/` +2. Check that `loader.js` is loaded in your HTML +3. Open browser console to see any loading errors + +### "Failed to load ROM" + +**Cause**: ROM file not accessible or CORS issues + +**Solution**: +1. Verify ROM file path is correct +2. Check server is serving files from library directory +3. Ensure CORS headers allow file access +4. Check browser console for 404 or CORS errors + +### Black Screen / No Video + +**Cause**: Missing core files or unsupported ROM format + +**Solution**: +1. Verify correct core files are in `data/` directory +2. Check ROM file is not corrupted +3. Ensure ROM format matches the core (e.g., .nes for NES) + +### Performance Issues + +**Cause**: Heavy cores (N64, PSP) require significant CPU + +**Solution**: +1. Use a modern browser with good performance +2. Close other browser tabs +3. Try lighter cores (NES, SNES, GBA) first +4. Consider native emulators for heavy systems + +## Limitations + +- **Performance**: Web-based emulation is slower than native emulators +- **Compatibility**: Not all games work perfectly +- **Heavy Systems**: N64, PS1, PSP may struggle on slower machines +- **Mobile**: Limited support on mobile browsers +- **Save States**: Require server-side storage implementation + +## Security Considerations + +⚠️ **Important**: +- Do not serve copyrighted ROMs +- Ensure users only play games they legally own +- Consider adding authentication/authorization for ROM access +- Be aware of copyright laws in your jurisdiction + +## Development + +### Adding Support for New Cores + +1. Download the core data files from EmulatorJS repo +2. Add core to `CORE_MAP` in `EmulatorPlayer.tsx`: + +```typescript +const CORE_MAP: Record = { + // ... existing cores + "new-platform": "new-core-name" +}; +``` + +3. Test with sample ROM + +### Custom UI + +The EmulatorPlayer component can be customized: +- Modify `emulator.css` for styling +- Add custom controls in `EmulatorPlayer.tsx` +- Integrate with your settings/save system + +## Resources + +- **EmulatorJS GitHub**: https://github.com/EmulatorJS/EmulatorJS +- **EmulatorJS Demo**: https://emulatorjs.org/ +- **Documentation**: https://github.com/EmulatorJS/EmulatorJS/wiki +- **Supported Cores**: https://github.com/EmulatorJS/EmulatorJS#supported-systems + +## Contributing + +To improve the EmulatorJS integration: + +1. Test with various ROM formats and platforms +2. Improve error handling and user feedback +3. Add save state management +4. Optimize loading and performance +5. Add more customization options + +## License + +EmulatorJS is licensed under GPL-3.0. Ensure compliance when using this integration. + +Jacare's EmulatorJS integration code is part of Jacare and follows its MIT license. diff --git a/package-lock.json b/package-lock.json index c6f057c..a45d457 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8069,7 +8069,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/tests/e2e/big-picture.spec.ts b/tests/e2e/big-picture.spec.ts new file mode 100644 index 0000000..da55b83 --- /dev/null +++ b/tests/e2e/big-picture.spec.ts @@ -0,0 +1,172 @@ +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 + const bigPictureMode = page.locator('.big-picture-mode'); + await expect(bigPictureMode).toBeVisible(); + await expect(bigPictureMode.locator('.bp-logo')).toContainText('Jacare'); + + // Verify navigation items within Big Picture mode + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Home' })).toBeVisible(); + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Library' })).toBeVisible(); + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Search' })).toBeVisible(); + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Downloads' })).toBeVisible(); + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Settings' })).toBeVisible(); + await expect(bigPictureMode.locator('.bp-nav-item', { hasText: 'Exit' })).toBeVisible(); + }); + + // Step 3: Test keyboard navigation + await test.step('Navigate with keyboard', async () => { + // Verify we're on Home (Welcome screen) + await expect(page.locator('.bp-title', { hasText: 'Welcome' })).toBeVisible(); + await expect(page.getByText('Welcome to Big Picture Mode')).toBeVisible(); + + // Press ArrowDown to navigate to Library + await page.keyboard.press('ArrowDown'); + await expect(page.locator('.bp-title', { hasText: 'Library' })).toBeVisible({ timeout: 1000 }); + + // Press ArrowDown to navigate to Search + await page.keyboard.press('ArrowDown'); + await expect(page.locator('.bp-title', { hasText: 'Search' })).toBeVisible({ timeout: 1000 }); + + // Press ArrowUp to go back to Library + await page.keyboard.press('ArrowUp'); + await expect(page.locator('.bp-title', { hasText: 'Library' })).toBeVisible({ timeout: 1000 }); + }); + + // 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 expect(page.locator('.bp-title', { hasText: 'Downloads' })).toBeVisible({ timeout: 1000 }); + }); + + // Step 6: Exit Big Picture Mode + await test.step('Exit Big Picture Mode', async () => { + // Press Escape to exit + await page.keyboard.press('Escape'); + + // Wait for Big Picture mode to disappear + await expect(page.locator('.big-picture-mode')).not.toBeVisible(); + + // Verify normal UI is visible + await expect(page.locator('.sidebar')).toBeVisible(); + await expect(page.locator('.app-shell')).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 }); + } 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(); + + // Wait for Big Picture mode to be visible + await expect(page.locator('.big-picture-mode')).toBeVisible(); + + // 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.locator('.bp-title', { hasText: 'Library' })).toBeVisible(); + }); + + await test.step('Click Search navigation', async () => { + const searchButton = page.locator('.bp-nav-item', { hasText: 'Search' }); + await searchButton.click(); + await expect(page.locator('.bp-title', { hasText: 'Search' })).toBeVisible(); + }); + + await test.step('Click Exit to leave Big Picture', async () => { + const exitButton = page.locator('.bp-nav-item', { hasText: 'Exit' }); + await exitButton.click(); + + // Verify we exited + await expect(page.locator('.big-picture-mode')).not.toBeVisible(); + await expect(page.locator('.sidebar')).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 }); + } catch { + // Continue + } + + // Enter Big Picture mode + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('button', { name: /Enter Big Picture Mode/i }).click(); + + // Wait for Big Picture mode to be visible + await expect(page.locator('.big-picture-mode')).toBeVisible(); + + 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'); + + const libraryButton = page.locator('.bp-nav-item.focused', { hasText: 'Library' }); + await expect(libraryButton).toBeVisible({ timeout: 1000 }); + }); + }); +});