Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -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)
});
22 changes: 22 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -25,6 +27,7 @@ function AppRoutes() {
<Route path="/downloads" element={<DownloadsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/game/:slug" element={<GameDetailPage />} />
<Route path="/big-picture" element={<BigPicturePage />} />
</Routes>
{state?.backgroundLocation && (
<Routes>
Expand Down Expand Up @@ -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 (
<BrowserRouter>
<BigPicturePage />
</BrowserRouter>
);
}
Comment on lines +103 to +109
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BigPicturePage component is wrapped in a BrowserRouter when bigPictureMode is true, but BigPicturePage uses router hooks (useNavigate, useLocation) which expect to be within a router context. This creates a nested router structure where the BigPicturePage route is never actually matched, since it's not within the Routes definition. The component will work for navigation but bypasses the routing system.

Consider either:

  1. Conditionally rendering BigPicturePage inside the existing router structure, or
  2. Using navigate programmatically to redirect to /big-picture instead of conditionally rendering

Copilot uses AI. Check for mistakes.

return (
<BrowserRouter>
<div className="app-shell">
Expand Down
194 changes: 194 additions & 0 deletions apps/web/src/components/EmulatorPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(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]);
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect that initializes EmulatorJS has an incomplete dependency array. The effect depends on config.biosUrl, config.saveStateUrl, config.gameName, config.romUrl, and config.core, but only includes config and onError in the dependency array. If individual properties of config change without the object reference changing, the emulator won't reinitialize. Consider either adding these specific properties to the dependency array or ensuring config is always a new object when any property changes.

Suggested change
}, [config, onError]);
}, [
config.core,
config.romUrl,
config.biosUrl,
config.saveStateUrl,
config.gameName,
onError
]);

Copilot uses AI. Check for mistakes.

// 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 (
<div className="emulator-wrapper">
<div className="emulator-header">
<div className="emulator-title">
{config.gameName || "Playing"} ({config.core.toUpperCase()})
</div>
<button className="emulator-exit-btn" onClick={onExit}>
Exit (ESC)
</button>
</div>

{error ? (
<div className="emulator-error">
<h2>Emulator Error</h2>
<p>{error}</p>
<div className="emulator-error-instructions">
<h3>Installation Instructions:</h3>
<ol>
<li>Download EmulatorJS from: https://github.com/EmulatorJS/EmulatorJS</li>
<li>Place EmulatorJS files in your public/emulatorjs/ directory</li>
<li>Ensure your web server serves these files</li>
<li>Include the EmulatorJS script in your HTML</li>
</ol>
<p>
<strong>Note:</strong> This is an experimental feature. EmulatorJS must be
separately installed and configured for game emulation to work.
</p>
</div>
<button className="emulator-back-btn" onClick={onExit}>
Back to Library
</button>
</div>
) : (
<>
<div
id="emulator-container"
ref={containerRef}
className="emulator-container"
/>

{!isReady && (
<div className="emulator-loading">
<div className="emulator-spinner"></div>
<p>Loading emulator...</p>
</div>
)}

<div className="emulator-controls-hint">
<p>🎮 Use your controller or keyboard to play</p>
<p>Press ESC to exit</p>
</div>
</>
)}
</div>
);
}
Loading
Loading