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