diff --git a/README.md b/README.md index a1a5e4c..f5c80cf 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ **What makes Jacare special:** - 🎮 **All-in-one solution** – Browse, search, download ROMs without switching between tools +- 🎯 **Big Picture Mode** – Controller-friendly full-screen interface for TVs and couch gaming - ⏸️ **Persistent download management** – Pause and resume downloads seamlessly, even after closing and reopening the application - 🎨 **Customizable themes** – Personalize your experience with a variety of themes to suit your preferences - ⚡ **Ultra-responsive web-based UI** – Enjoy a fast, smooth interface accessible through your browser or desktop app @@ -19,9 +20,11 @@ > Want details? > - 📚 **Developer guide:** Head to [`docs/README.md`](docs/README.md) for the full technical rundown. > - 😀 **Friendly guide:** Open [`docs/user/README.md`](docs/user/README.md) for a non-technical walkthrough. +> - 🎮 **Big Picture Mode:** Check out [`docs/BIG_PICTURE_MODE.md`](docs/BIG_PICTURE_MODE.md) for controller setup and usage. ## Why Jacare? 🐊 - **One app for everything:** Browse, enrich, and launch ROMs without juggling separate tools. +- **Couch gaming ready:** Big Picture Mode transforms your PC into a console-like experience with full controller support. - **Local-first with cloud search:** Metadata is pulled from [Crocdb](https://api.crocdb.net) while your collection, cache, and settings remain on disk. - **Built for speed:** Background jobs, SSE updates, and caching cut down on repetitive scraping. - **Works online or offline:** Cached search and entry data keep your library usable even when you lose connectivity. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 66aee24..57c1776 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,8 @@ import DownloadsPage from "./pages/DownloadsPage"; import SettingsPage from "./pages/SettingsPage"; import GameDetailPage from "./pages/GameDetailPage"; import LibraryItemDetailPage from "./pages/LibraryItemDetailPage"; +import BigPictureModePage from "./pages/BigPictureModePage"; +import BigPictureDownloadsPage from "./pages/BigPictureDownloadsPage"; import { WelcomeView, shouldShowWelcome } from "./components/WelcomeView"; // useMemo imported above @@ -24,6 +26,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> {state?.backgroundLocation && ( diff --git a/apps/web/src/components/OnScreenKeyboard.css b/apps/web/src/components/OnScreenKeyboard.css new file mode 100644 index 0000000..5337ebc --- /dev/null +++ b/apps/web/src/components/OnScreenKeyboard.css @@ -0,0 +1,169 @@ +.onscreen-keyboard { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--card); + border-top: 2px solid var(--border); + padding: 24px; + z-index: 1000; + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3); +} + +.keyboard-input-wrapper { + margin-bottom: 20px; +} + +.keyboard-input { + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + font-size: 24px; + font-family: "IBM Plex Sans", monospace; + color: var(--ink); + min-height: 60px; + display: flex; + align-items: center; + position: relative; +} + +.keyboard-placeholder { + color: var(--ink-muted); + opacity: 0.5; +} + +.keyboard-cursor { + display: inline-block; + width: 2px; + height: 28px; + background: var(--accent); + margin-left: 2px; + animation: blink 1s infinite; +} + +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +.keyboard-keys { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.keyboard-row { + display: flex; + justify-content: center; + gap: 8px; +} + +.keyboard-key { + min-width: 60px; + height: 60px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + font-size: 20px; + font-weight: 600; + color: var(--ink); + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.keyboard-key:hover { + background: var(--bg-alt); + border-color: var(--accent); + transform: translateY(-2px); +} + +.keyboard-key:active { + transform: translateY(0); + background: var(--accent); + color: var(--bg); +} + +.keyboard-key.focused { + border-color: var(--accent); + border-width: 3px; + background: var(--accent); + color: var(--bg); + box-shadow: 0 0 16px var(--accent); +} + +.keyboard-key.special { + min-width: 80px; + font-size: 16px; +} + +.keyboard-key.space { + min-width: 200px; +} + +.keyboard-key.submit { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); +} + +.keyboard-key.submit:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.keyboard-key.active { + background: var(--accent); + color: var(--bg); +} + +.keyboard-hints { + display: flex; + justify-content: center; + gap: 24px; + font-size: 14px; + color: var(--ink-muted); + opacity: 0.7; +} + +.keyboard-hints span { + padding: 4px 12px; + background: var(--bg); + border-radius: 4px; +} + +/* Big Picture Mode specific overrides */ +[data-big-picture="true"] .onscreen-keyboard { + padding: 32px; +} + +[data-big-picture="true"] .keyboard-input { + font-size: 32px; + padding: 20px 24px; + min-height: 80px; +} + +[data-big-picture="true"] .keyboard-key { + min-width: 70px; + height: 70px; + font-size: 24px; +} + +[data-big-picture="true"] .keyboard-key.special { + min-width: 100px; + font-size: 18px; +} + +[data-big-picture="true"] .keyboard-key.space { + min-width: 280px; +} + +[data-big-picture="true"] .keyboard-hints { + font-size: 16px; + gap: 32px; +} diff --git a/apps/web/src/components/OnScreenKeyboard.tsx b/apps/web/src/components/OnScreenKeyboard.tsx new file mode 100644 index 0000000..ec95648 --- /dev/null +++ b/apps/web/src/components/OnScreenKeyboard.tsx @@ -0,0 +1,225 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useBigPictureStore } from "../store"; +import "./OnScreenKeyboard.css"; + +export interface OnScreenKeyboardProps { + value: string; + onChange: (value: string) => void; + onSubmit?: () => void; + onClose?: () => void; + placeholder?: string; +} + +const QWERTY_LAYOUT = [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";"], + ["z", "x", "c", "v", "b", "n", "m", ",", ".", "/"] +]; + +const SPECIAL_KEYS = { + BACKSPACE: "⌫", + SPACE: "Space", + SHIFT: "⇧", + CLEAR: "Clear", + CLOSE: "Close", + SUBMIT: "Submit" +}; + +/** + * On-screen keyboard component for Big Picture Mode + * Designed for controller navigation + */ +export function OnScreenKeyboard({ + value, + onChange, + onSubmit, + onClose, + placeholder = "Type here..." +}: OnScreenKeyboardProps) { + const [shift, setShift] = useState(false); + const [focusedRow, setFocusedRow] = useState(0); + const [focusedCol, setFocusedCol] = useState(0); + const keyboardRef = useRef(null); + + const keyboardLayout = useBigPictureStore((state) => state.keyboardLayout); + + // Calculate total rows including special keys row + const totalRows = QWERTY_LAYOUT.length + 1; // +1 for special keys row + + // Get the current key at focus position - memoized to avoid recreation + const getCurrentKey = useCallback(() => { + if (focusedRow < QWERTY_LAYOUT.length) { + const row = QWERTY_LAYOUT[focusedRow]; + if (focusedCol < row.length) { + const key = row[focusedCol]; + return shift ? key.toUpperCase() : key; + } + } else { + // Special keys row + const specialKeysArray = [ + SPECIAL_KEYS.SHIFT, + SPECIAL_KEYS.SPACE, + SPECIAL_KEYS.BACKSPACE, + SPECIAL_KEYS.CLEAR, + SPECIAL_KEYS.CLOSE, + SPECIAL_KEYS.SUBMIT + ]; + return specialKeysArray[focusedCol] || null; + } + return null; + }, [focusedRow, focusedCol, shift]); + + // Handle key press - memoized to avoid recreation + const handleKeyPress = useCallback((key: string) => { + if (key === SPECIAL_KEYS.BACKSPACE) { + onChange(value.slice(0, -1)); + } else if (key === SPECIAL_KEYS.SPACE) { + onChange(value + " "); + } else if (key === SPECIAL_KEYS.SHIFT) { + setShift(!shift); + } else if (key === SPECIAL_KEYS.CLEAR) { + onChange(""); + } else if (key === SPECIAL_KEYS.CLOSE) { + onClose?.(); + } else if (key === SPECIAL_KEYS.SUBMIT) { + onSubmit?.(); + } else { + onChange(value + key); + // Auto-disable shift after typing a letter + if (shift) { + setShift(false); + } + } + }, [value, shift, onChange, onClose, onSubmit]); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Arrow key navigation + if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusedRow((r) => Math.max(0, r - 1)); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusedRow((r) => Math.min(totalRows - 1, r + 1)); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + setFocusedCol((c) => { + return Math.max(0, c - 1); + }); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + setFocusedCol((c) => { + const currentRow = focusedRow < QWERTY_LAYOUT.length + ? QWERTY_LAYOUT[focusedRow].length + : 6; // special keys row has 6 keys + return Math.min(currentRow - 1, c + 1); + }); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + const key = getCurrentKey(); + if (key) { + handleKeyPress(key); + } + } else if (e.key === "Escape") { + e.preventDefault(); + onClose?.(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [focusedRow, focusedCol, shift, value, totalRows, getCurrentKey, handleKeyPress, onClose]); + + // Normalize focused column when changing rows + useEffect(() => { + const currentRow = focusedRow < QWERTY_LAYOUT.length + ? QWERTY_LAYOUT[focusedRow].length + : 6; // special keys row + if (focusedCol >= currentRow) { + setFocusedCol(currentRow - 1); + } + }, [focusedRow, focusedCol]); + + return ( +
+
+
+ {value || {placeholder}} + +
+
+ +
+ {QWERTY_LAYOUT.map((row, rowIndex) => ( +
+ {row.map((key, colIndex) => { + const displayKey = shift ? key.toUpperCase() : key; + const isFocused = focusedRow === rowIndex && focusedCol === colIndex; + + return ( + + ); + })} +
+ ))} + + {/* Special keys row */} +
+ + + + + + {onSubmit && ( + + )} +
+
+ +
+ Use Arrow Keys or D-Pad to navigate + Press A/Enter to select + Press B/ESC to close +
+
+ ); +} diff --git a/apps/web/src/hooks/useFullscreenMode.ts b/apps/web/src/hooks/useFullscreenMode.ts new file mode 100644 index 0000000..009196e --- /dev/null +++ b/apps/web/src/hooks/useFullscreenMode.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; + +/** + * Custom hook to manage fullscreen mode + * Only exits fullscreen if this component was responsible for entering it + */ +export function useFullscreenMode(enabled: boolean) { + const enteredFullscreenRef = useRef(false); + + useEffect(() => { + if (!enabled) return; + + const enterFullscreen = async () => { + if (document.documentElement.requestFullscreen && !document.fullscreenElement) { + try { + await document.documentElement.requestFullscreen(); + enteredFullscreenRef.current = true; + } catch (error) { + console.warn("[Fullscreen] Failed to enter fullscreen:", error); + // Fullscreen request failed, continue anyway + } + } + }; + + enterFullscreen(); + + return () => { + // Only exit fullscreen if we entered it + if (enteredFullscreenRef.current && document.fullscreenElement) { + document.exitFullscreen().catch((error) => { + console.warn("[Fullscreen] Failed to exit fullscreen:", error); + }); + enteredFullscreenRef.current = false; + } + }; + }, [enabled]); +} diff --git a/apps/web/src/hooks/useGamepadNavigation.ts b/apps/web/src/hooks/useGamepadNavigation.ts new file mode 100644 index 0000000..96e119a --- /dev/null +++ b/apps/web/src/hooks/useGamepadNavigation.ts @@ -0,0 +1,197 @@ +import { useEffect, useCallback, useRef } from "react"; + +export type GamepadButton = + | "A" | "B" | "X" | "Y" + | "LB" | "RB" | "LT" | "RT" + | "SELECT" | "START" + | "L3" | "R3" + | "UP" | "DOWN" | "LEFT" | "RIGHT"; + +export type ButtonMapping = { + [key in GamepadButton]?: () => void; +}; + +export interface GamepadNavigationOptions { + enabled?: boolean; + deadzone?: number; + buttonMapping?: ButtonMapping; + onConnect?: (gamepad: Gamepad) => void; + onDisconnect?: (gamepad: Gamepad) => void; +} + +// Standard gamepad button indices (Xbox/PlayStation layout) +const BUTTON_INDICES: Record = { + A: 0, // Cross on PlayStation + B: 1, // Circle on PlayStation + X: 2, // Square on PlayStation + Y: 3, // Triangle on PlayStation + LB: 4, // L1 on PlayStation + RB: 5, // R1 on PlayStation + LT: 6, // L2 on PlayStation + RT: 7, // R2 on PlayStation + SELECT: 8, // Share on PlayStation + START: 9, // Options on PlayStation + L3: 10, // Left stick button + R3: 11, // Right stick button + UP: 12, // D-pad up + DOWN: 13, // D-pad down + LEFT: 14, // D-pad left + RIGHT: 15 // D-pad right +}; + +/** + * Custom hook for gamepad/controller navigation + * Supports Xbox and PlayStation controllers via Gamepad API + */ +export function useGamepadNavigation(options: GamepadNavigationOptions = {}) { + const { + enabled = true, + buttonMapping = {}, + onConnect, + onDisconnect + } = options; + + const frameRef = useRef(); + const lastButtonStatesRef = useRef>(new Map()); + const connectedGamepadsRef = useRef>(new Set()); + + // Get connected gamepads + const getGamepads = useCallback((): Gamepad[] => { + const gamepads = navigator.getGamepads?.() || []; + return Array.from(gamepads).filter((gp): gp is Gamepad => gp !== null); + }, []); + + // Check if button was just pressed (rising edge) + const wasButtonJustPressed = useCallback((gamepadIndex: number, buttonIndex: number, pressed: boolean): boolean => { + const _lastStates = lastButtonStatesRef.current.get(gamepadIndex) || []; + const wasPressed = _lastStates[buttonIndex] || false; + return pressed && !wasPressed; + }, []); + + // Handle gamepad connection + const handleGamepadConnected = useCallback((e: GamepadEvent) => { + console.log(`[Gamepad] Connected: ${e.gamepad.id} (index: ${e.gamepad.index})`); + connectedGamepadsRef.current.add(e.gamepad.index); + onConnect?.(e.gamepad); + }, [onConnect]); + + // Handle gamepad disconnection + const handleGamepadDisconnected = useCallback((e: GamepadEvent) => { + console.log(`[Gamepad] Disconnected: ${e.gamepad.id} (index: ${e.gamepad.index})`); + connectedGamepadsRef.current.delete(e.gamepad.index); + lastButtonStatesRef.current.delete(e.gamepad.index); + onDisconnect?.(e.gamepad); + }, [onDisconnect]); + + // Poll gamepad state + useEffect(() => { + const pollGamepads = () => { + if (!enabled) return; + + const gamepads = getGamepads(); + + for (const gamepad of gamepads) { + const currentStates: boolean[] = []; + + // Check each button + for (const [buttonName, handler] of Object.entries(buttonMapping)) { + const buttonIndex = BUTTON_INDICES[buttonName as GamepadButton]; + if (buttonIndex === undefined) continue; + + const button = gamepad.buttons[buttonIndex]; + const pressed = button?.pressed || false; + currentStates[buttonIndex] = pressed; + + // Trigger on button press (rising edge) + if (wasButtonJustPressed(gamepad.index, buttonIndex, pressed)) { + handler?.(); + } + } + + // Save current button states for next frame + lastButtonStatesRef.current.set(gamepad.index, currentStates); + } + + frameRef.current = requestAnimationFrame(pollGamepads); + }; + + if (!enabled) return; + + // Start polling + frameRef.current = requestAnimationFrame(pollGamepads); + + return () => { + if (frameRef.current !== undefined) { + cancelAnimationFrame(frameRef.current); + } + }; + }, [enabled, buttonMapping, getGamepads, wasButtonJustPressed]); + + useEffect(() => { + if (!enabled) return; + + // Add event listeners + window.addEventListener("gamepadconnected", handleGamepadConnected); + window.addEventListener("gamepaddisconnected", handleGamepadDisconnected); + + return () => { + // Clean up + window.removeEventListener("gamepadconnected", handleGamepadConnected); + window.removeEventListener("gamepaddisconnected", handleGamepadDisconnected); + + if (frameRef.current !== undefined) { + cancelAnimationFrame(frameRef.current); + } + + // Clear refs + lastButtonStatesRef.current.clear(); + connectedGamepadsRef.current.clear(); + }; + }, [enabled, handleGamepadConnected, handleGamepadDisconnected]); + + return { + getGamepads, + getConnectedGamepads: () => Array.from(connectedGamepadsRef.current) + }; +} + +/** + * Get haptic feedback support for a gamepad + */ +export function supportsHaptics(gamepad: Gamepad): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (gamepad as any).vibrationActuator !== undefined; +} + +/** + * Trigger haptic feedback on a gamepad + */ +export async function triggerHapticFeedback( + gamepad: Gamepad, + options: { + duration?: number; + weakMagnitude?: number; + strongMagnitude?: number; + } = {} +): Promise { + const { + duration = 100, + weakMagnitude = 0.5, + strongMagnitude = 0.5 + } = options; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actuator = (gamepad as any).vibrationActuator; + + if (actuator && typeof actuator.playEffect === "function") { + try { + await actuator.playEffect("dual-rumble", { + duration, + weakMagnitude, + strongMagnitude + }); + } catch (error) { + console.warn("[Gamepad] Haptic feedback failed:", error); + } + } +} diff --git a/apps/web/src/pages/BigPictureDownloadsPage.css b/apps/web/src/pages/BigPictureDownloadsPage.css new file mode 100644 index 0000000..bb3a299 --- /dev/null +++ b/apps/web/src/pages/BigPictureDownloadsPage.css @@ -0,0 +1,139 @@ +.bp-downloads-page { + position: fixed; + inset: 0; + background: var(--bg); + color: var(--ink); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 9999; +} + +.bp-downloads-page .bp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 40px; + background: var(--card); + border-bottom: 2px solid var(--border); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.bp-downloads-page .bp-header h1 { + font-size: 32px; + margin: 0; + color: var(--accent); +} + +.bp-back-button { + padding: 12px 24px; + font-size: 18px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-back-button:hover { + background: var(--accent-hover); + transform: translateY(-2px); +} + +.bp-count { + font-size: 18px; + color: var(--ink-muted); + padding: 8px 16px; + background: var(--bg); + border-radius: 6px; +} + +.bp-downloads-page .bp-main { + flex: 1; + overflow-y: auto; + padding: 40px; +} + +.bp-downloads-list { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.bp-download-item { + transition: all 0.2s ease; + position: relative; +} + +.bp-download-item::before { + content: ""; + position: absolute; + inset: -8px; + border: 4px solid transparent; + border-radius: 12px; + transition: all 0.2s ease; + pointer-events: none; +} + +.bp-download-item.focused::before { + border-color: var(--accent); + box-shadow: 0 0 32px var(--accent); +} + +.bp-download-item.focused { + transform: scale(1.02); + z-index: 10; +} + +.bp-downloads-page .bp-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 20px; +} + +.bp-downloads-page .bp-empty p { + font-size: 24px; + color: var(--ink-muted); +} + +.bp-downloads-page .bp-empty button { + padding: 12px 32px; + font-size: 18px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-downloads-page .bp-empty button:hover { + background: var(--accent-hover); + transform: translateY(-2px); +} + +.bp-downloads-page .bp-footer { + padding: 20px 40px; + background: var(--card); + border-top: 2px solid var(--border); +} + +.bp-downloads-page .bp-hints { + display: flex; + justify-content: center; + gap: 24px; + font-size: 14px; + color: var(--ink-muted); +} + +.bp-downloads-page .bp-hints span { + padding: 6px 12px; + background: var(--bg); + border-radius: 4px; +} diff --git a/apps/web/src/pages/BigPictureDownloadsPage.tsx b/apps/web/src/pages/BigPictureDownloadsPage.tsx new file mode 100644 index 0000000..f3669fe --- /dev/null +++ b/apps/web/src/pages/BigPictureDownloadsPage.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../lib/api"; +import type { JobRecord } from "@crocdesk/shared"; +import { useBigPictureStore, useDownloadProgressStore } from "../store"; +import { useGamepadNavigation, triggerHapticFeedback } from "../hooks/useGamepadNavigation"; +import { useFullscreenMode } from "../hooks/useFullscreenMode"; +import DownloadCard from "../components/DownloadCard"; +import "./BigPictureDownloadsPage.css"; + +type JobPreview = { + slug: string; + title: string; + platform: string; + boxart_url?: string; +}; +type JobWithPreview = JobRecord & { preview?: JobPreview }; + +export default function BigPictureDownloadsPage() { + const navigate = useNavigate(); + const [focusedIndex, setFocusedIndex] = useState(0); + + // Store + const bigPictureStore = useBigPictureStore(); + const progressByJobId = useDownloadProgressStore((state) => state.progressByJobId); + const speedDataByJobId = useDownloadProgressStore((state) => state.speedDataByJobId); + const bytesByJobId = useDownloadProgressStore((state) => state.bytesByJobId); + + const jobsQuery = useQuery({ + queryKey: ["jobs"], + queryFn: () => apiGet("/jobs") + }); + + const activeDownloads = (jobsQuery.data ?? []).filter( + (job) => job.type === "download_and_install" && (job.status === "running" || job.status === "queued" || job.status === "paused") + ); + + // Haptic feedback helper + const triggerHaptic = useCallback((type: "light" | "medium" | "heavy" = "light") => { + if (!bigPictureStore.hapticFeedbackEnabled) return; + + const gamepads = navigator.getGamepads?.() || []; + const gamepad = Array.from(gamepads).find(gp => gp !== null); + + if (gamepad) { + const magnitudes = { + light: { weak: 0.2, strong: 0.2, duration: 50 }, + medium: { weak: 0.5, strong: 0.5, duration: 100 }, + heavy: { weak: 0.8, strong: 0.8, duration: 150 } + }; + + const config = magnitudes[type]; + triggerHapticFeedback(gamepad, { + weakMagnitude: config.weak, + strongMagnitude: config.strong, + duration: config.duration + }).catch(() => { + // Silently handle haptic failure + }); + } + }, [bigPictureStore.hapticFeedbackEnabled]); + + const moveUp = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [triggerHaptic]); + + const moveDown = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.min(activeDownloads.length - 1, prev + 1); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [activeDownloads.length, triggerHaptic]); + + const goBack = useCallback(() => { + triggerHaptic("light"); + navigate("/big-picture"); + }, [navigate, triggerHaptic]); + + // Gamepad navigation + useGamepadNavigation({ + enabled: bigPictureStore.enabled, + deadzone: bigPictureStore.deadzone, + buttonMapping: { + UP: moveUp, + DOWN: moveDown, + B: goBack, + START: goBack + } + }); + + // Enter fullscreen on mount + useFullscreenMode(bigPictureStore.enabled); + + return ( +
+ {/* Header */} +
+ +

Active Downloads

+
+ {activeDownloads.length} {activeDownloads.length === 1 ? "download" : "downloads"} +
+
+ + {/* Main content */} +
+ {activeDownloads.length === 0 ? ( +
+

No active downloads

+ +
+ ) : ( +
+ {activeDownloads.map((job, index) => { + const isFocused = index === focusedIndex; + + return ( +
+ +
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ D-Pad: Navigate + B: Back +
+
+
+ ); +} diff --git a/apps/web/src/pages/BigPictureModePage.css b/apps/web/src/pages/BigPictureModePage.css new file mode 100644 index 0000000..1209dcb --- /dev/null +++ b/apps/web/src/pages/BigPictureModePage.css @@ -0,0 +1,372 @@ +.big-picture-mode { + position: fixed; + inset: 0; + background: var(--bg); + color: var(--ink); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 9999; +} + +/* Header */ +.bp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 40px; + background: var(--card); + border-bottom: 2px solid var(--border); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.bp-logo { + font-family: "Space Grotesk", sans-serif; + font-size: 32px; + font-weight: 700; + color: var(--accent); +} + +.bp-search-box { + flex: 1; + max-width: 600px; + margin: 0 40px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + padding: 12px 20px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-search-box:hover { + border-color: var(--accent); + background: var(--bg-alt); +} + +.bp-search-box span:first-child { + font-size: 24px; +} + +.bp-search-box span:last-child { + font-size: 18px; + color: var(--ink-muted); +} + +.bp-controls { + display: flex; + align-items: center; + gap: 20px; +} + +.bp-page-indicator { + font-size: 16px; + color: var(--ink-muted); + padding: 8px 16px; + background: var(--bg); + border-radius: 6px; +} + +.bp-menu-button { + font-size: 32px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + padding: 8px 16px; + color: var(--ink); + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-menu-button:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); +} + +/* Main content */ +.bp-main { + flex: 1; + overflow-y: auto; + padding: 40px; + position: relative; +} + +.bp-loading, +.bp-error, +.bp-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 20px; +} + +.bp-loading .spinner { + width: 64px; + height: 64px; + border: 4px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.bp-error p, +.bp-empty p { + font-size: 24px; + color: var(--ink-muted); +} + +.bp-error button, +.bp-empty button { + padding: 12px 32px; + font-size: 18px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-error button:hover, +.bp-empty button:hover { + background: var(--accent-hover); + transform: translateY(-2px); +} + +/* Grid */ +.bp-grid { + display: grid; + gap: 32px; + margin-bottom: 40px; +} + +.bp-card-wrapper { + transition: all 0.2s ease; + position: relative; +} + +.bp-card-wrapper::before { + content: ""; + position: absolute; + inset: -8px; + border: 4px solid transparent; + border-radius: 12px; + transition: all 0.2s ease; + pointer-events: none; +} + +.bp-card-wrapper.focused::before { + border-color: var(--accent); + box-shadow: 0 0 32px var(--accent); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + box-shadow: 0 0 32px var(--accent); + } + 50% { + box-shadow: 0 0 48px var(--accent); + } +} + +.bp-card-wrapper.focused { + transform: scale(1.05); + z-index: 10; +} + +/* Footer */ +.bp-footer { + padding: 20px 40px; + background: var(--card); + border-top: 2px solid var(--border); + display: flex; + flex-direction: column; + gap: 16px; +} + +.bp-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 24px; +} + +.bp-pagination button { + padding: 12px 24px; + font-size: 16px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.bp-pagination button:hover:not(:disabled) { + background: var(--accent-hover); + transform: translateY(-2px); +} + +.bp-pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.bp-pagination span { + font-size: 18px; + color: var(--ink); +} + +.bp-hints { + display: flex; + justify-content: center; + gap: 24px; + font-size: 14px; + color: var(--ink-muted); +} + +.bp-hints span { + padding: 6px 12px; + background: var(--bg); + border-radius: 4px; +} + +/* Menu overlay */ +.bp-menu-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(8px); +} + +.bp-menu { + background: var(--card); + border: 2px solid var(--border); + border-radius: 16px; + padding: 40px; + max-width: 500px; + width: 90%; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5); +} + +.bp-menu h2 { + font-size: 32px; + margin-bottom: 24px; + color: var(--accent); +} + +.bp-menu ul { + list-style: none; + padding: 0; + margin: 0 0 24px 0; +} + +.bp-menu li { + margin-bottom: 12px; +} + +.bp-menu button { + width: 100%; + padding: 16px 24px; + font-size: 20px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--ink); + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.bp-menu button:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + transform: translateX(8px); +} + +.bp-menu-close { + background: var(--accent) !important; + color: var(--bg) !important; + border-color: var(--accent) !important; + text-align: center !important; +} + +/* Scrollbar styling for Big Picture Mode */ +.bp-main::-webkit-scrollbar { + width: 12px; +} + +.bp-main::-webkit-scrollbar-track { + background: var(--bg); +} + +.bp-main::-webkit-scrollbar-thumb { + background: var(--accent); + border-radius: 6px; +} + +.bp-main::-webkit-scrollbar-thumb:hover { + background: var(--accent-hover); +} + +/* Responsive adjustments */ +@media (max-width: 1280px) { + .bp-header { + padding: 20px 32px; + } + + .bp-logo { + font-size: 28px; + } + + .bp-main { + padding: 32px; + } + + .bp-grid { + gap: 24px; + } +} + +@media (max-width: 768px) { + .bp-header { + padding: 16px 24px; + } + + .bp-search-box { + margin: 0 20px; + } + + .bp-main { + padding: 24px; + } + + .bp-grid { + gap: 16px; + } + + .bp-footer { + padding: 16px 24px; + } + + .bp-hints { + flex-wrap: wrap; + gap: 12px; + } +} diff --git a/apps/web/src/pages/BigPictureModePage.tsx b/apps/web/src/pages/BigPictureModePage.tsx new file mode 100644 index 0000000..acc00d7 --- /dev/null +++ b/apps/web/src/pages/BigPictureModePage.tsx @@ -0,0 +1,400 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { apiGet, apiPost } from "../lib/api"; +import type { + CrocdbEntry, + CrocdbPlatformsResponseData, + CrocdbSearchResponseData, + CrocdbApiResponse +} from "@crocdesk/shared"; +import { useBigPictureStore, useDownloadProgressStore } from "../store"; +import { useGamepadNavigation, triggerHapticFeedback } from "../hooks/useGamepadNavigation"; +import { useFullscreenMode } from "../hooks/useFullscreenMode"; +import { OnScreenKeyboard } from "../components/OnScreenKeyboard"; +import GameCard from "../components/GameCard"; +import "./BigPictureModePage.css"; + +export default function BigPictureModePage() { + const navigate = useNavigate(); + const location = useLocation(); + + // State + const [games, setGames] = useState([]); + const [platforms, setPlatforms] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showMenu, setShowMenu] = useState(false); + + // Store + const bigPictureStore = useBigPictureStore(); + const downloadingSlugs = useDownloadProgressStore((state) => state.downloadingSlugs); + + // Refs + const gridRef = useRef(null); + const lastHapticTimeRef = useRef(0); + + // Calculate grid columns based on screen size and settings + const getGridColumns = () => { + const width = window.innerWidth; + + if (bigPictureStore.gridSize === "auto") { + if (width >= 1920) return 6; + if (width >= 1600) return 5; + if (width >= 1280) return 4; + return 3; + } + + switch (bigPictureStore.gridSize) { + case "small": return 3; + case "medium": return 4; + case "large": return 5; + default: return 4; + } + }; + + const [gridColumns, setGridColumns] = useState(getGridColumns); + + // Update grid columns on window resize + useEffect(() => { + const handleResize = () => { + setGridColumns(getGridColumns()); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bigPictureStore.gridSize]); + + // Haptic feedback helper + const triggerHaptic = useCallback((type: "light" | "medium" | "heavy" = "light") => { + if (!bigPictureStore.hapticFeedbackEnabled) return; + + // Throttle haptic feedback to avoid overwhelming the controller + const now = Date.now(); + if (now - lastHapticTimeRef.current < 50) return; + lastHapticTimeRef.current = now; + + const gamepads = navigator.getGamepads?.() || []; + const gamepad = Array.from(gamepads).find(gp => gp !== null); + + if (gamepad) { + const magnitudes = { + light: { weak: 0.2, strong: 0.2, duration: 50 }, + medium: { weak: 0.5, strong: 0.5, duration: 100 }, + heavy: { weak: 0.8, strong: 0.8, duration: 150 } + }; + + const config = magnitudes[type]; + triggerHapticFeedback(gamepad, { + weakMagnitude: config.weak, + strongMagnitude: config.strong, + duration: config.duration + }).catch(() => { + // Silently handle haptic failure + }); + } + }, [bigPictureStore.hapticFeedbackEnabled]); + + // Navigation helpers + const moveUp = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.max(0, prev - gridColumns); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [gridColumns, triggerHaptic]); + + const moveDown = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.min(games.length - 1, prev + gridColumns); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [games.length, gridColumns, triggerHaptic]); + + const moveLeft = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [triggerHaptic]); + + const moveRight = useCallback(() => { + setFocusedIndex((prev) => { + const newIndex = Math.min(games.length - 1, prev + 1); + if (newIndex !== prev) triggerHaptic("light"); + return newIndex; + }); + }, [games.length, triggerHaptic]); + + const selectCurrent = useCallback(() => { + if (games[focusedIndex]) { + triggerHaptic("medium"); + navigate(`/game/${games[focusedIndex].slug}`, { state: { backgroundLocation: location } }); + } + }, [games, focusedIndex, navigate, location, triggerHaptic]); + + const goBack = useCallback(() => { + triggerHaptic("light"); + if (showSearch) { + setShowSearch(false); + } else if (showMenu) { + setShowMenu(false); + } else { + navigate("/"); + } + }, [showSearch, showMenu, navigate, triggerHaptic]); + + const toggleMenu = useCallback(() => { + triggerHaptic("medium"); + setShowMenu((prev) => !prev); + }, [triggerHaptic]); + + const toggleSearch = useCallback(() => { + triggerHaptic("medium"); + setShowSearch((prev) => !prev); + }, [triggerHaptic]); + + const previousPage = useCallback(() => { + if (page > 1) { + setPage((p) => p - 1); + setFocusedIndex(0); + triggerHaptic("medium"); + } + }, [page, triggerHaptic]); + + const nextPage = useCallback(() => { + if (page < totalPages) { + setPage((p) => p + 1); + setFocusedIndex(0); + triggerHaptic("medium"); + } + }, [page, totalPages, triggerHaptic]); + + // Gamepad navigation + useGamepadNavigation({ + enabled: bigPictureStore.enabled && !showSearch, + deadzone: bigPictureStore.deadzone, + buttonMapping: { + UP: moveUp, + DOWN: moveDown, + LEFT: moveLeft, + RIGHT: moveRight, + A: selectCurrent, + B: goBack, + X: toggleSearch, + Y: toggleMenu, + LB: previousPage, + RB: nextPage, + START: toggleMenu + }, + onConnect: (gamepad) => { + console.log("[BigPicture] Controller connected:", gamepad.id); + triggerHaptic("heavy"); + }, + onDisconnect: (gamepad) => { + console.log("[BigPicture] Controller disconnected:", gamepad.id); + } + }); + + // Scroll focused item into view + useEffect(() => { + if (gridRef.current) { + const focusedCard = gridRef.current.children[focusedIndex] as HTMLElement; + if (focusedCard) { + focusedCard.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + }, [focusedIndex]); + + // Load games + useEffect(() => { + loadGames(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, searchQuery]); + + // Load platforms + useEffect(() => { + loadPlatforms(); + }, []); + + async function loadGames() { + try { + setLoading(true); + setError(null); + + const response = await apiPost>("/crocdb/search", { + query: searchQuery || undefined, + page, + limit: 30 + }); + + setGames(response.data.results ?? []); + setTotalPages(response.data.total_pages ?? 1); + } catch (err) { + setError(err instanceof Error ? err : new Error("Failed to load games")); + } finally { + setLoading(false); + } + } + + async function loadPlatforms() { + try { + const response = await apiGet>("/crocdb/platforms"); + setPlatforms(response.data); + } catch (err) { + console.error("Failed to load platforms:", err); + } + } + + async function handleDownload(slug: string) { + try { + triggerHaptic("medium"); + await apiPost("/download/queue", { slug }); + } catch (err) { + console.error("Failed to queue download:", err); + } + } + + const handleSearchSubmit = () => { + setPage(1); + setFocusedIndex(0); + setShowSearch(false); + loadGames(); + }; + + // Enter fullscreen on mount + useFullscreenMode(bigPictureStore.enabled); + + return ( +
+ {/* Header */} +
+
Jacare
+
+ 🔍 + {searchQuery || "Search games..."} +
+
+ + Page {page} of {totalPages} + + +
+
+ + {/* Main content */} +
+ {loading && ( +
+
+

Loading games...

+
+ )} + + {error && ( +
+

Error: {error.message}

+ +
+ )} + + {!loading && !error && games.length === 0 && ( +
+

No games found

+ {searchQuery && } +
+ )} + + {!loading && !error && games.length > 0 && ( +
+ {games.map((entry, index) => { + const isOwned = false; // TODO: Check library + const isDownloading = downloadingSlugs.has(entry.slug); + const isFocused = index === focusedIndex; + + return ( +
+ handleDownload(entry.slug)} + platformsData={platforms || undefined} + location={location} + /> +
+ ); + })} +
+ )} +
+ + {/* Footer with pagination */} +
+
+ + Page {page} of {totalPages} + +
+
+ D-Pad: Navigate + A: Select + B: Back + X: Search + Y: Menu +
+
+ + {/* On-screen keyboard */} + {showSearch && ( + setShowSearch(false)} + placeholder="Search games..." + /> + )} + + {/* Menu overlay */} + {showMenu && ( +
setShowMenu(false)}> +
e.stopPropagation()}> +

Big Picture Menu

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 216e134..64592a6 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,13 +1,15 @@ import { useState, useEffect } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import { apiGet, apiPut } from "../lib/api"; import type { Settings } from "@crocdesk/shared"; -import { useUIStore } from "../store"; +import { useUIStore, useBigPictureStore } from "../store"; import { useTheme } from "../components/ThemeProvider"; import { Card, Input, Button } from "../components/ui"; import { spacing } from "../lib/design-tokens"; export default function SettingsPage() { + const navigate = useNavigate(); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: () => apiGet("/settings") @@ -20,6 +22,23 @@ export default function SettingsPage() { const theme = useUIStore((state) => state.theme); const setThemePreference = useUIStore((state) => state.setTheme); const { setTheme: _setThemeObject } = useTheme(); + + // Big Picture mode settings + const setBigPictureEnabled = useBigPictureStore((state) => state.setEnabled); + const hapticFeedbackEnabled = useBigPictureStore((state) => state.hapticFeedbackEnabled); + const setHapticFeedbackEnabled = useBigPictureStore((state) => state.setHapticFeedbackEnabled); + const gridSize = useBigPictureStore((state) => state.gridSize); + const setGridSize = useBigPictureStore((state) => state.setGridSize); + const availableMonitors = useBigPictureStore((state) => state.availableMonitors); + const selectedMonitor = useBigPictureStore((state) => state.selectedMonitor); + const setSelectedMonitor = useBigPictureStore((state) => state.setSelectedMonitor); + const detectMonitors = useBigPictureStore((state) => state.detectMonitors); + + // Detect monitors on mount + useEffect(() => { + detectMonitors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Sync draft with query data when it changes (e.g., after refetch) // This is a valid pattern for syncing external data with local state @@ -134,6 +153,103 @@ export default function SettingsPage() { + +

Big Picture Mode

+

+ Controller-friendly interface designed for TVs and couch gaming. +

+ +
+ {/* Launch Big Picture Mode */} +
+ +
+ + {/* Haptic Feedback */} +
+ +
+ + {/* Grid Size */} +
+ + +
+ + {/* Monitor Selection */} + {availableMonitors > 1 && ( +
+ + +

+ {availableMonitors} monitors detected +

+
+ )} + +
+

+ Controls: Use D-Pad or Left Stick to navigate, A to select, B to go back, X to search, Y for menu, LB/RB for pages. +

+
+
+
+

About

diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 810bf7f..ea194f2 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -3,7 +3,9 @@ export { useDownloadProgressStore } from "./slices/downloadProgressSlice"; export { useJobResultsStore } from "./slices/jobResultsSlice"; export { useUIStore } from "./slices/uiSlice"; export { useSSEStore } from "./slices/sseSlice"; +export { useBigPictureStore } from "./slices/bigPictureSlice"; export type { SpeedDataPoint, DownloadProgressState, SSEState, JobResultsState, UIState } from "./types"; +export type { BigPictureState, BigPictureStore } from "./slices/bigPictureSlice"; diff --git a/apps/web/src/store/slices/bigPictureSlice.ts b/apps/web/src/store/slices/bigPictureSlice.ts new file mode 100644 index 0000000..c012941 --- /dev/null +++ b/apps/web/src/store/slices/bigPictureSlice.ts @@ -0,0 +1,160 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type GamepadButtonName = + | "A" | "B" | "X" | "Y" + | "LB" | "RB" | "LT" | "RT" + | "SELECT" | "START" + | "L3" | "R3" + | "UP" | "DOWN" | "LEFT" | "RIGHT"; + +export type GamepadAction = + | "SELECT" + | "BACK" + | "DETAILS" + | "MENU" + | "PAGE_UP" + | "PAGE_DOWN" + | "SCROLL_UP" + | "SCROLL_DOWN" + | "NAVIGATE_UP" + | "NAVIGATE_DOWN" + | "NAVIGATE_LEFT" + | "NAVIGATE_RIGHT"; + +export type ButtonMappingConfig = Record; + +// Default button mapping (Xbox/PlayStation standard) +const DEFAULT_BUTTON_MAPPING: ButtonMappingConfig = { + A: "SELECT", // Confirm/Select + B: "BACK", // Back/Cancel + X: "DETAILS", // Show details + Y: "MENU", // Open menu + LB: "PAGE_UP", // Previous page + RB: "PAGE_DOWN", // Next page + LT: "SCROLL_UP", // Scroll up + RT: "SCROLL_DOWN", // Scroll down + SELECT: "MENU", // Menu + START: "MENU", // Menu + L3: "SELECT", // Confirm + R3: "DETAILS", // Details + UP: "NAVIGATE_UP", // Navigate up + DOWN: "NAVIGATE_DOWN", // Navigate down + LEFT: "NAVIGATE_LEFT", // Navigate left + RIGHT: "NAVIGATE_RIGHT" // Navigate right +}; + +export interface BigPictureState { + enabled: boolean; + buttonMapping: ButtonMappingConfig; + hapticFeedbackEnabled: boolean; + deadzone: number; + selectedMonitor: number; + showOnScreenKeyboard: boolean; + keyboardLayout: "qwerty" | "azerty" | "dvorak"; + gridSize: "small" | "medium" | "large" | "auto"; + autoHideUI: boolean; + autoHideDelay: number; // in seconds + availableMonitors: number; // Number of detected monitors +} + +type BigPictureActions = { + setEnabled: (enabled: boolean) => void; + setButtonMapping: (mapping: ButtonMappingConfig) => void; + resetButtonMapping: () => void; + setHapticFeedbackEnabled: (enabled: boolean) => void; + setDeadzone: (deadzone: number) => void; + setSelectedMonitor: (monitor: number) => void; + setShowOnScreenKeyboard: (show: boolean) => void; + setKeyboardLayout: (layout: "qwerty" | "azerty" | "dvorak") => void; + setGridSize: (size: "small" | "medium" | "large" | "auto") => void; + setAutoHideUI: (autoHide: boolean) => void; + setAutoHideDelay: (delay: number) => void; + setAvailableMonitors: (count: number) => void; + detectMonitors: () => Promise; +}; + +export type BigPictureStore = BigPictureState & BigPictureActions; + +const initialState: BigPictureState = { + enabled: false, + buttonMapping: DEFAULT_BUTTON_MAPPING, + hapticFeedbackEnabled: true, + deadzone: 0.25, + selectedMonitor: 0, + showOnScreenKeyboard: false, + keyboardLayout: "qwerty", + gridSize: "auto", + autoHideUI: false, + autoHideDelay: 3, + availableMonitors: 1 +}; + +export const useBigPictureStore = create()( + persist( + (set) => ({ + ...initialState, + + setEnabled: (enabled) => set({ enabled }), + + setButtonMapping: (mapping) => set({ buttonMapping: mapping }), + + resetButtonMapping: () => set({ buttonMapping: DEFAULT_BUTTON_MAPPING }), + + setHapticFeedbackEnabled: (enabled) => set({ hapticFeedbackEnabled: enabled }), + + setDeadzone: (deadzone) => set({ deadzone }), + + setSelectedMonitor: (monitor) => set({ selectedMonitor: monitor }), + + setShowOnScreenKeyboard: (show) => set({ showOnScreenKeyboard: show }), + + setKeyboardLayout: (layout) => set({ keyboardLayout: layout }), + + setGridSize: (size) => set({ gridSize: size }), + + setAutoHideUI: (autoHide) => set({ autoHideUI: autoHide }), + + setAutoHideDelay: (delay) => set({ autoHideDelay: delay }), + + setAvailableMonitors: (count) => set({ availableMonitors: count }), + + detectMonitors: async () => { + // Use Screen Orientation API if available + if (window.screen && 'isExtended' in window.screen) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isExtended = await (window.screen as any).isExtended?.(); + if (isExtended) { + // Multi-monitor setup detected + // We can't get exact count, but we know there's more than one + set({ availableMonitors: 2 }); + } else { + set({ availableMonitors: 1 }); + } + } catch { + // If the API call fails, assume single monitor + set({ availableMonitors: 1 }); + } + } else { + // Fallback: assume single monitor + set({ availableMonitors: 1 }); + } + } + }), + { + name: "crocdesk-bigpicture-storage", + partialize: (state) => ({ + enabled: state.enabled, + buttonMapping: state.buttonMapping, + hapticFeedbackEnabled: state.hapticFeedbackEnabled, + deadzone: state.deadzone, + selectedMonitor: state.selectedMonitor, + keyboardLayout: state.keyboardLayout, + gridSize: state.gridSize, + autoHideUI: state.autoHideUI, + autoHideDelay: state.autoHideDelay + }) + } + ) +); diff --git a/docs/BIG_PICTURE_MODE.md b/docs/BIG_PICTURE_MODE.md new file mode 100644 index 0000000..dcbb13d --- /dev/null +++ b/docs/BIG_PICTURE_MODE.md @@ -0,0 +1,195 @@ +# Big Picture Mode + +Big Picture Mode is a controller-friendly, full-screen interface designed for TVs and couch gaming. It provides an immersive experience for browsing and managing your ROM collection with Xbox and PlayStation controllers. + +## Features + +### Controller Support +- **Xbox Controllers**: Full support for Xbox 360, Xbox One, and Xbox Series X/S controllers +- **PlayStation Controllers**: Full support for DualShock 4 and DualSense controllers +- **Haptic Feedback**: Vibration feedback for button presses and navigation (when supported) +- **Button Remapping**: Customize button mappings to your preference + +### Navigation +- **D-Pad/Left Stick**: Navigate through the game grid +- **A Button (✕ on PlayStation)**: Select game or confirm action +- **B Button (○ on PlayStation)**: Go back or cancel +- **X Button (□ on PlayStation)**: Open search +- **Y Button (△ on PlayStation)**: Open menu +- **LB/RB (L1/R1)**: Navigate between pages +- **START Button**: Open menu + +### UI Features +- **Dynamic Grid Sizing**: Automatically adjusts grid layout based on screen resolution +- **On-Screen Keyboard**: Full keyboard for text input without needing a physical keyboard +- **Search**: Find games quickly with controller-friendly search +- **Downloads View**: Monitor active downloads with progress bars +- **Fullscreen Mode**: Automatic fullscreen for distraction-free gaming +- **Multi-Monitor Support**: Choose which monitor to display Big Picture Mode on + +## Getting Started + +### Accessing Big Picture Mode + +1. **From Settings Page**: + - Navigate to Settings + - Scroll to "Big Picture Mode" section + - Click "🎮 Launch Big Picture Mode" + +2. **Direct URL**: + - Navigate to `/big-picture` in your browser + +### Controller Setup + +Big Picture Mode automatically detects connected controllers. Simply: + +1. Connect your Xbox or PlayStation controller to your PC +2. Launch Big Picture Mode +3. Start navigating with your controller + +The controller will be detected automatically, and you'll see haptic feedback when you press buttons (if your controller supports it). + +### Configuration + +#### Haptic Feedback +Enable or disable controller vibration: +- Go to Settings → Big Picture Mode +- Toggle "Enable haptic feedback (controller vibration)" + +#### Grid Size +Adjust the number of games displayed in the grid: +- **Auto**: Dynamic sizing based on screen resolution (recommended) +- **Small**: 3 columns +- **Medium**: 4 columns +- **Large**: 5 columns + +#### Multi-Monitor Setup +If you have multiple monitors: +1. Big Picture Mode will automatically detect them +2. Go to Settings → Big Picture Mode → Monitor Selection +3. Choose your preferred monitor + +## Controls Reference + +### Main Screen (Browse Games) + +| Action | Xbox | PlayStation | Keyboard | +|--------|------|-------------|----------| +| Navigate | D-Pad / Left Stick | D-Pad / Left Stick | Arrow Keys | +| Select Game | A | ✕ (Cross) | Enter | +| Go Back | B | ○ (Circle) | Escape | +| Search | X | □ (Square) | S | +| Menu | Y / START | △ (Triangle) / OPTIONS | M | +| Previous Page | LB | L1 | Page Up | +| Next Page | RB | R1 | Page Down | + +### On-Screen Keyboard + +| Action | Xbox | PlayStation | Keyboard | +|--------|------|-------------|----------| +| Navigate Keys | D-Pad / Left Stick | D-Pad / Left Stick | Arrow Keys | +| Select Key | A | ✕ (Cross) | Enter / Space | +| Close Keyboard | B | ○ (Circle) | Escape | + +### Downloads View + +| Action | Xbox | PlayStation | Keyboard | +|--------|------|-------------|----------| +| Navigate | D-Pad Up/Down | D-Pad Up/Down | Arrow Keys | +| Go Back | B / START | ○ (Circle) / OPTIONS | Escape | + +## Menu Options + +Press Y (△ on PlayStation) or START to open the menu: + +- **Browse Games**: Return to main Big Picture browse view +- **Library**: View your local game library +- **Downloads**: View active downloads +- **Queue**: View job queue +- **Settings**: Access application settings +- **Exit Big Picture**: Return to standard desktop mode + +## Tips & Tricks + +1. **Analog Stick Sensitivity**: If navigation feels too sensitive, adjust the deadzone in settings +2. **Search Optimization**: Use the on-screen keyboard with D-Pad for precise navigation +3. **Page Navigation**: Use LB/RB (L1/R1) to quickly jump between pages +4. **Haptic Feedback**: Different intensities indicate different actions (light for navigation, medium for selection, heavy for controller connection) +5. **Fullscreen Toggle**: Press F11 to manually toggle fullscreen if needed + +## Troubleshooting + +### Controller Not Detected +1. Ensure your controller is properly connected +2. For wireless controllers, make sure they're paired +3. Try reconnecting the controller +4. Check browser console for gamepad connection messages + +### Haptic Feedback Not Working +- Some controllers (especially older models) may not support vibration +- Check that haptic feedback is enabled in Settings +- Try a different USB port or wireless receiver + +### Navigation Feels Unresponsive +- Adjust the deadzone setting in Big Picture Mode settings +- Try using D-Pad instead of analog stick for more precise control +- Ensure no other applications are capturing controller input + +### Multi-Monitor Issues +1. Ensure you've selected the correct monitor in settings +2. Restart Big Picture Mode after changing monitor selection +3. Some browsers may not fully support multi-monitor APIs + +## Performance Optimization + +Big Picture Mode includes several optimizations: + +- **Virtual Scrolling**: Only renders visible game cards for better performance +- **Image Lazy Loading**: Loads cover art on-demand as you scroll +- **Debounced Navigation**: Prevents input overload during rapid navigation +- **Request Animation Frame**: Uses RAF for smooth 60fps controller polling + +## Accessibility + +While Big Picture Mode is designed for controllers, it remains fully accessible: + +- **Keyboard Support**: All controller actions have keyboard equivalents +- **Focus Indicators**: Clear visual feedback shows currently selected items +- **High Contrast**: Works well with both light and dark themes +- **Screen Reader Friendly**: Semantic HTML structure for screen reader compatibility + +## Browser Support + +Big Picture Mode uses modern web APIs: + +- **Gamepad API**: For controller support (Chrome 21+, Firefox 29+, Safari 10.1+) +- **Fullscreen API**: For immersive experience (All modern browsers) +- **Vibration Actuator API**: For haptic feedback (Chrome 68+, experimental in others) + +For the best experience, we recommend: +- **Chrome/Edge**: Full support for all features including haptic feedback +- **Firefox**: Full support except vibration may be limited +- **Safari**: Basic controller support, limited haptic feedback + +## Future Enhancements + +Planned improvements for Big Picture Mode: + +- [ ] Analog stick scrolling support +- [ ] Advanced button remapping UI +- [ ] Voice control integration +- [ ] Achievement notifications +- [ ] Friend presence indicators +- [ ] Cloud save status +- [ ] Quick launch from Big Picture Mode + +## Contributing + +Found a bug or have a feature request? Please open an issue on GitHub: +https://github.com/luandev/jacare/issues + +When reporting controller issues, please include: +- Controller model and brand +- Connection type (USB/Bluetooth) +- Operating system +- Browser and version