diff --git a/documentation/BIG_PICTURE_MODE.md b/documentation/BIG_PICTURE_MODE.md new file mode 100644 index 0000000..6367469 --- /dev/null +++ b/documentation/BIG_PICTURE_MODE.md @@ -0,0 +1,120 @@ +# Big Picture Mode - Steam Deck & Controller UI + +Nebula Browser includes a **Big Picture Mode** - a controller-friendly, console-style interface designed for Steam Deck, handheld devices, and living room setups. + +## Features + +### 🎮 Controller Support +- **Full gamepad navigation** - Use D-pad or left stick to navigate +- **Button mapping**: + - **A / Cross** - Select/Activate + - **B / Circle** - Go Back + - **Y / Triangle** - Quick Search + - **Start** - Toggle Settings +- **Audio feedback** for navigation + +### 📱 Optimized for Steam Deck +- **1280x800 native resolution** support +- Automatic detection of Steam Deck screens +- Large touch-friendly UI elements +- Fullscreen immersive experience + +### 🎨 Modern Console-Style UI +- Inspired by Steam OS Big Picture and Xbox Dashboard +- Smooth animations and transitions +- Glowing focus indicators +- Dark theme optimized for OLED displays + +### ⌨️ On-Screen Keyboard +- Built-in virtual keyboard for controller input +- URL and search input support +- Special keys for common domains (.com, .org, etc.) + +## How to Access + +### From Desktop Mode +1. **Menu Button (☰)** → Click **"🎮 Big Picture Mode"** +2. **Settings** → **General** → Click **"Launch Big Picture Mode"** + +### Keyboard Shortcut +- Press `F11` while in Big Picture Mode to toggle fullscreen + +### Automatic Detection +If Nebula detects a Steam Deck-sized display (1280x800), it will suggest Big Picture Mode in settings. + +## Navigation Sections + +| Section | Description | +|---------|-------------| +| **Home** | Quick access sites, search, and recent browsing | +| **Bookmarks** | Your saved websites in a tile grid | +| **History** | Recently visited sites | +| **Downloads** | Downloaded files | +| **NeBot AI** | Launch the AI assistant | +| **Settings** | Theme, privacy, and display options | + +## Controller Button Reference + +| Button | Action | +|--------|--------| +| D-Pad / Left Stick | Navigate between elements | +| A / Cross | Select focused element | +| B / Circle | Go back / Close menu | +| Y / Triangle | Open search (on-screen keyboard) | +| Start | Open/Close settings | +| LB/RB | Scroll horizontally | + +## Exiting Big Picture Mode + +- Press the **Exit** button in the top-right corner +- Go to **Settings** → **Desktop Mode** +- Press `Escape` key multiple times + +## Technical Details + +### Files +- `renderer/bigpicture.html` - Main HTML structure +- `renderer/bigpicture.css` - Console-optimized styles +- `renderer/bigpicture.js` - Controller handling and navigation + +### Screen Detection +Big Picture Mode is suggested for displays matching: +- Steam Deck resolution: 1280×800 +- Screens smaller than 1366px width +- 16:10 or 16:9 aspect ratios + +### API +```javascript +// Check if Big Picture Mode is recommended +const suggested = await window.bigPictureAPI.isSuggested(); + +// Get screen information +const info = await window.bigPictureAPI.getScreenInfo(); + +// Launch Big Picture Mode +await window.bigPictureAPI.launch(); + +// Exit Big Picture Mode +await window.bigPictureAPI.exit(); +``` + +## Customization + +The Big Picture Mode respects your theme settings. Colors are applied from your selected theme: +- Background colors +- Accent and primary colors +- Text colors + +## Known Limitations + +- Some complex web forms may be difficult to navigate with controller only +- Video players use native controls +- Right-click context menus require mouse/touch + +## Future Improvements + +- [ ] Rumble/haptic feedback for compatible controllers +- [ ] Voice search integration with NeBot +- [ ] Picture-in-picture mode for videos +- [ ] Game overlay mode +- [ ] Custom controller mappings diff --git a/main.js b/main.js index 739d0b5..54841ee 100644 --- a/main.js +++ b/main.js @@ -68,6 +68,143 @@ ipcMain.removeHandler('window-minimize'); ipcMain.removeHandler('window-maximize'); ipcMain.removeHandler('window-close'); +// ============================================================================= +// BIG PICTURE MODE - Steam Deck / Console UI +// ============================================================================= + +// Steam Deck screen dimensions: 1280x800 +const STEAM_DECK_WIDTH = 1280; +const STEAM_DECK_HEIGHT = 800; +const HANDHELD_THRESHOLD = 1366; // Consider screens smaller than this as "handheld" + +let bigPictureWindow = null; + +/** + * Check if the current display is likely a Steam Deck or similar handheld + */ +function isSteamDeckDisplay() { + const primaryDisplay = screen.getPrimaryDisplay(); + const { width, height } = primaryDisplay.size; + + // Check for Steam Deck resolution or similar small screens + const isSteamDeckRes = width === STEAM_DECK_WIDTH && height === STEAM_DECK_HEIGHT; + const isSmallScreen = width <= HANDHELD_THRESHOLD; + + // Also check for certain aspect ratios common in handhelds (16:10, 16:9) + const aspectRatio = width / height; + const isHandheldAspect = aspectRatio >= 1.5 && aspectRatio <= 1.8; + + return isSteamDeckRes || (isSmallScreen && isHandheldAspect); +} + +/** + * Get screen info for UI decisions + */ +function getScreenInfo() { + const primaryDisplay = screen.getPrimaryDisplay(); + const { width, height } = primaryDisplay.size; + const { scaleFactor } = primaryDisplay; + + return { + width, + height, + scaleFactor, + isSteamDeck: width === STEAM_DECK_WIDTH && height === STEAM_DECK_HEIGHT, + isSmallScreen: width <= HANDHELD_THRESHOLD, + aspectRatio: width / height, + suggestBigPicture: isSteamDeckDisplay() + }; +} + +/** + * Create Big Picture Mode window + */ +function createBigPictureWindow() { + if (bigPictureWindow && !bigPictureWindow.isDestroyed()) { + bigPictureWindow.focus(); + return bigPictureWindow; + } + + const primaryDisplay = screen.getPrimaryDisplay(); + const { width, height } = primaryDisplay.workAreaSize; + + bigPictureWindow = new BrowserWindow({ + width: width, + height: height, + fullscreen: true, + frame: false, + show: false, + backgroundColor: '#0a0a0f', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + webviewTag: true, + spellcheck: false, + webSecurity: true, + }, + icon: process.platform === 'darwin' + ? path.join(__dirname, 'assets/images/Logos/Nebula-Favicon.icns') + : path.join(__dirname, 'assets/images/Logos/Nebula-favicon.png'), + title: 'Nebula - Big Picture Mode' + }); + + bigPictureWindow.loadFile('renderer/bigpicture.html'); + + bigPictureWindow.once('ready-to-show', () => { + bigPictureWindow.show(); + console.log('[BigPicture] Window ready'); + }); + + bigPictureWindow.on('closed', () => { + bigPictureWindow = null; + console.log('[BigPicture] Window closed'); + }); + + return bigPictureWindow; +} + +/** + * Exit Big Picture Mode and return to desktop UI + */ +function exitBigPictureMode() { + if (bigPictureWindow && !bigPictureWindow.isDestroyed()) { + bigPictureWindow.close(); + bigPictureWindow = null; + } + + // Ensure main window exists and is focused + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(w => w !== bigPictureWindow); + if (mainWindow) { + mainWindow.focus(); + } else if (windows.length === 0) { + createWindow(); + } +} + +// IPC handlers for Big Picture Mode +ipcMain.handle('get-screen-info', () => getScreenInfo()); + +ipcMain.handle('launch-bigpicture', () => { + createBigPictureWindow(); + return { success: true }; +}); + +ipcMain.handle('exit-bigpicture', () => { + exitBigPictureMode(); + return { success: true }; +}); + +ipcMain.handle('is-bigpicture-suggested', () => { + return isSteamDeckDisplay(); +}); + +ipcMain.on('exit-bigpicture', () => { + exitBigPictureMode(); +}); + +// ============================================================================= function createWindow(startUrl) { diff --git a/preload.js b/preload.js index d717f1e..39b7e78 100644 --- a/preload.js +++ b/preload.js @@ -116,6 +116,20 @@ contextBridge.exposeInMainWorld('aboutAPI', { getInfo: () => ipcRenderer.invoke('get-about-info') }); +// Big Picture Mode API - Steam Deck / Console UI +contextBridge.exposeInMainWorld('bigPictureAPI', { + // Get screen info to determine if Big Picture Mode is recommended + getScreenInfo: () => ipcRenderer.invoke('get-screen-info'), + // Check if device is likely a Steam Deck or handheld + isSuggested: () => ipcRenderer.invoke('is-bigpicture-suggested'), + // Launch Big Picture Mode + launch: () => ipcRenderer.invoke('launch-bigpicture'), + // Exit Big Picture Mode + exit: () => ipcRenderer.invoke('exit-bigpicture'), + // Navigate to URL (from Big Picture Mode) + navigate: (url) => ipcRenderer.send('bigpicture-navigate', url) +}); + // Relay context-menu commands from main to active renderer context (open new tabs etc.) ipcRenderer.on('context-menu-command', (event, payload) => { window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload })); diff --git a/renderer/bigpicture.css b/renderer/bigpicture.css new file mode 100644 index 0000000..3d7ee62 --- /dev/null +++ b/renderer/bigpicture.css @@ -0,0 +1,1428 @@ +/* Big Picture Mode - Steam Deck / Console-style UI */ +/* Optimized for 1280x800 (Steam Deck) and controller navigation */ + +@font-face { + font-family: 'InterVariable'; + src: url('../assets/images/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +/* CSS Variables for theming */ +:root { + --bp-bg: #0a0a0f; + --bp-surface: #14141f; + --bp-surface-hover: #1e1e2d; + --bp-surface-active: #28283d; + --bp-primary: #7B2EFF; + --bp-primary-glow: rgba(123, 46, 255, 0.4); + --bp-accent: #00C6FF; + --bp-accent-glow: rgba(0, 198, 255, 0.3); + --bp-text: #ffffff; + --bp-text-muted: #8888a0; + --bp-text-dim: #555570; + --bp-border: #2a2a40; + --bp-success: #4ade80; + --bp-warning: #fbbf24; + --bp-danger: #ef4444; + + /* Focus ring for controller navigation */ + --bp-focus-ring: 0 0 0 3px var(--bp-primary), 0 0 30px var(--bp-primary-glow); + --bp-focus-ring-accent: 0 0 0 3px var(--bp-accent), 0 0 30px var(--bp-accent-glow); + + /* Spacing scaled for touch/controller */ + --bp-spacing-xs: 8px; + --bp-spacing-sm: 12px; + --bp-spacing-md: 20px; + --bp-spacing-lg: 32px; + --bp-spacing-xl: 48px; + + /* Border radius */ + --bp-radius-sm: 8px; + --bp-radius-md: 12px; + --bp-radius-lg: 16px; + --bp-radius-xl: 24px; + + /* Animation timing */ + --bp-transition-fast: 150ms ease; + --bp-transition-normal: 250ms ease; + --bp-transition-slow: 400ms ease; +} + +/* Base reset */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: 'InterVariable', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 18px; /* Larger base for readability on TV/handheld */ + line-height: 1.5; + color: var(--bp-text); + background: var(--bp-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + cursor: none; /* Hide cursor for controller-only mode */ +} + +/* Show cursor when mouse moves */ +body.mouse-active { + cursor: auto; +} + +/* Main container */ +.bp-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} + +/* Animated background */ +.bp-background { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} + +.bg-gradient { + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 120% 80% at 20% 10%, rgba(123, 46, 255, 0.15) 0%, transparent 50%), + radial-gradient(ellipse 100% 60% at 80% 90%, rgba(0, 198, 255, 0.1) 0%, transparent 40%), + linear-gradient(180deg, var(--bp-bg) 0%, #0d0d15 100%); +} + +.bg-particles { + position: absolute; + inset: 0; + background-image: + radial-gradient(2px 2px at 20% 30%, rgba(255,255,255,0.15), transparent), + radial-gradient(2px 2px at 40% 70%, rgba(255,255,255,0.1), transparent), + radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.12), transparent), + radial-gradient(2px 2px at 80% 50%, rgba(255,255,255,0.08), transparent); + animation: particles-drift 60s linear infinite; +} + +@keyframes particles-drift { + 0% { transform: translateY(0); } + 100% { transform: translateY(-100px); } +} + +.bg-glow { + position: absolute; + width: 600px; + height: 600px; + border-radius: 50%; + background: radial-gradient(circle, var(--bp-primary-glow) 0%, transparent 70%); + filter: blur(80px); + opacity: 0.5; + animation: glow-pulse 8s ease-in-out infinite alternate; + top: -200px; + left: -100px; +} + +@keyframes glow-pulse { + 0% { transform: scale(1); opacity: 0.3; } + 100% { transform: scale(1.2); opacity: 0.5; } +} + +/* Header */ +.bp-header { + position: relative; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--bp-spacing-sm) var(--bp-spacing-lg); + background: linear-gradient(180deg, rgba(10,10,15,0.95) 0%, rgba(10,10,15,0.8) 100%); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--bp-border); +} + +.header-left { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); +} + +.bp-logo { + width: 40px; + height: 40px; + filter: drop-shadow(0 0 10px var(--bp-primary-glow)); +} + +.bp-title { + font-size: 1.4rem; + font-weight: 700; + background: linear-gradient(135deg, var(--bp-text) 0%, var(--bp-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.header-center { + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.clock-widget { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.clock-widget .time { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 1px; +} + +.clock-widget .date { + font-size: 0.8rem; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); +} + +.status-icons { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); +} + +.status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--bp-radius-sm); + background: var(--bp-surface); + color: var(--bp-text-muted); +} + +.status-icon .material-symbols-outlined { + font-size: 20px; +} + +.bp-exit-btn { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); + padding: var(--bp-spacing-xs) var(--bp-spacing-md); + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.bp-exit-btn:hover, +.bp-exit-btn:focus { + background: var(--bp-surface-hover); + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + outline: none; +} + +.bp-exit-btn .material-symbols-outlined { + font-size: 18px; +} + +/* Main layout */ +.bp-main { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + z-index: 1; +} + +/* Sidebar navigation */ +.bp-sidebar { + width: 220px; + min-width: 220px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: var(--bp-spacing-md); + background: rgba(20, 20, 31, 0.6); + backdrop-filter: blur(10px); + border-right: 1px solid var(--bp-border); +} + +.nav-items { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-xs); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-md); + background: transparent; + border: 2px solid transparent; + border-radius: var(--bp-radius-lg); + color: var(--bp-text-muted); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); + text-align: left; + width: 100%; +} + +.nav-item .material-symbols-outlined { + font-size: 28px; + transition: transform var(--bp-transition-fast); +} + +.nav-item:hover { + background: var(--bp-surface); + color: var(--bp-text); +} + +.nav-item:focus { + outline: none; + background: var(--bp-surface-hover); + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + color: var(--bp-text); +} + +.nav-item:focus .material-symbols-outlined { + transform: scale(1.1); +} + +.nav-item.active { + background: linear-gradient(135deg, var(--bp-primary) 0%, #5a1fd4 100%); + border-color: var(--bp-primary); + color: var(--bp-text); + box-shadow: 0 4px 20px var(--bp-primary-glow); +} + +.nav-item.active .material-symbols-outlined { + color: var(--bp-text); +} + +/* Webview container for browsing */ +.webview-container { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: var(--bp-bg); + z-index: 2; +} + +.webview-container.hidden { + display: none; + pointer-events: none; +} + +.webview-container webview { + width: 100%; + height: 100%; + border: none; +} + +/* Content area */ +.bp-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: var(--bp-spacing-lg); + scroll-behavior: smooth; + position: relative; +} + +/* Custom scrollbar */ +.bp-content::-webkit-scrollbar { + width: 8px; +} + +.bp-content::-webkit-scrollbar-track { + background: var(--bp-surface); + border-radius: 4px; +} + +.bp-content::-webkit-scrollbar-thumb { + background: var(--bp-border); + border-radius: 4px; +} + +.bp-content::-webkit-scrollbar-thumb:hover { + background: var(--bp-primary); +} + +/* Sections */ +.bp-section { + display: none; + animation: fadeIn 0.3s ease; +} + +.bp-section.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.section-header { + margin-bottom: var(--bp-spacing-lg); +} + +.section-title { + font-size: 2rem; + font-weight: 700; + margin-bottom: var(--bp-spacing-xs); + background: linear-gradient(135deg, var(--bp-text) 0%, var(--bp-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.section-subtitle { + font-size: 1rem; + color: var(--bp-text-muted); +} + +.subsection-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--bp-text-muted); + margin-bottom: var(--bp-spacing-md); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Search card */ +.search-card { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-xl); + margin-bottom: var(--bp-spacing-xl); + transition: all var(--bp-transition-fast); + cursor: pointer; +} + +.search-card:hover { + background: var(--bp-surface-hover); + border-color: var(--bp-text-dim); +} + +.search-card:focus, +.search-card:focus-within { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); +} + +.search-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--bp-radius-md); + background: linear-gradient(135deg, var(--bp-accent) 0%, var(--bp-primary) 100%); +} + +.search-icon .material-symbols-outlined { + font-size: 28px; + color: var(--bp-text); +} + +.search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 1.2rem; + color: var(--bp-text); + caret-color: var(--bp-accent); +} + +.search-input::placeholder { + color: var(--bp-text-dim); +} + +.search-hint { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +/* Key hints */ +.key-hint { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + background: var(--bp-primary); + border-radius: var(--bp-radius-sm); + font-size: 0.85rem; + font-weight: 700; + color: var(--bp-text); +} + +/* Tile grid */ +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-xl); +} + +.tile-grid.large { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + aspect-ratio: 16 / 10; + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); + overflow: hidden; + position: relative; +} + +.tile::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 0%, rgba(123, 46, 255, 0.1) 100%); + opacity: 0; + transition: opacity var(--bp-transition-fast); +} + +.tile:hover { + background: var(--bp-surface-hover); + transform: scale(1.02); +} + +.tile:hover::before { + opacity: 1; +} + +.tile:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: scale(1.02); +} + +.tile:focus::before { + opacity: 1; +} + +.tile-icon { + width: 64px; + height: 64px; + border-radius: var(--bp-radius-md); + background: var(--bp-surface-active); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--bp-spacing-sm); + overflow: hidden; +} + +.tile-icon img { + width: 40px; + height: 40px; + object-fit: contain; +} + +.tile-icon .material-symbols-outlined { + font-size: 36px; + color: var(--bp-accent); +} + +.tile-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.tile-url { + font-size: 0.8rem; + color: var(--bp-text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* Add tile button */ +.tile.add-tile { + border-style: dashed; + border-color: var(--bp-text-dim); +} + +.tile.add-tile .material-symbols-outlined { + font-size: 48px; + color: var(--bp-text-dim); +} + +.tile.add-tile:hover, +.tile.add-tile:focus { + border-color: var(--bp-accent); + border-style: solid; +} + +.tile.add-tile:hover .material-symbols-outlined, +.tile.add-tile:focus .material-symbols-outlined { + color: var(--bp-accent); +} + +/* Horizontal scroll */ +.horizontal-scroll { + display: flex; + gap: var(--bp-spacing-md); + overflow-x: auto; + padding-bottom: var(--bp-spacing-md); + scroll-snap-type: x mandatory; +} + +.horizontal-scroll::-webkit-scrollbar { + height: 6px; +} + +.horizontal-scroll::-webkit-scrollbar-track { + background: var(--bp-surface); + border-radius: 3px; +} + +.horizontal-scroll::-webkit-scrollbar-thumb { + background: var(--bp-border); + border-radius: 3px; +} + +.scroll-card { + flex: 0 0 280px; + scroll-snap-align: start; + display: flex; + flex-direction: column; + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.scroll-card:hover { + background: var(--bp-surface-hover); + transform: translateY(-4px); +} + +.scroll-card:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: translateY(-4px); +} + +.scroll-card-preview { + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bp-surface-active); + border-radius: var(--bp-radius-sm); + margin-bottom: var(--bp-spacing-sm); + overflow: hidden; +} + +.scroll-card-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.scroll-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.scroll-card-meta { + font-size: 0.85rem; + color: var(--bp-text-muted); +} + +/* List container */ +.list-container { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-sm); +} + +.list-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.list-item:hover { + background: var(--bp-surface-hover); +} + +.list-item:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); +} + +.list-item-icon { + width: 48px; + height: 48px; + border-radius: var(--bp-radius-sm); + background: var(--bp-surface-active); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.list-item-icon img { + width: 32px; + height: 32px; + object-fit: contain; +} + +.list-item-content { + flex: 1; + min-width: 0; +} + +.list-item-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-item-meta { + font-size: 0.85rem; + color: var(--bp-text-muted); +} + +.list-item-action { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--bp-spacing-xl); + color: var(--bp-text-dim); +} + +.empty-state .material-symbols-outlined { + font-size: 64px; + margin-bottom: var(--bp-spacing-md); + opacity: 0.5; +} + +.empty-state p { + font-size: 1.1rem; +} + +/* NeBot section */ +.nebot-launch { + display: flex; + justify-content: center; +} + +.nebot-card { + display: flex; + align-items: center; + gap: var(--bp-spacing-lg); + padding: var(--bp-spacing-lg) var(--bp-spacing-xl); + background: linear-gradient(135deg, var(--bp-surface) 0%, var(--bp-surface-hover) 100%); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-xl); + cursor: pointer; + transition: all var(--bp-transition-fast); + max-width: 500px; + width: 100%; +} + +.nebot-card:hover { + border-color: var(--bp-accent); + transform: scale(1.02); +} + +.nebot-card:focus { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); + transform: scale(1.02); +} + +.nebot-icon { + width: 72px; + height: 72px; + border-radius: var(--bp-radius-lg); + background: linear-gradient(135deg, var(--bp-accent) 0%, var(--bp-primary) 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.nebot-icon .material-symbols-outlined { + font-size: 40px; + color: var(--bp-text); +} + +.nebot-info h3 { + font-size: 1.3rem; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 4px; +} + +.nebot-info p { + font-size: 0.95rem; + color: var(--bp-text-muted); +} + +.nebot-action { + margin-left: auto; +} + +/* Settings grid */ +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--bp-spacing-md); +} + +.settings-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.settings-card:hover { + background: var(--bp-surface-hover); + transform: scale(1.02); +} + +.settings-card:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: scale(1.02); +} + +.settings-card .material-symbols-outlined { + font-size: 40px; + color: var(--bp-accent); +} + +.settings-label { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); +} + +/* Footer controller hints */ +.bp-footer { + position: relative; + z-index: 100; + padding: var(--bp-spacing-sm) var(--bp-spacing-lg); + background: linear-gradient(0deg, rgba(10,10,15,0.95) 0%, rgba(10,10,15,0.8) 100%); + backdrop-filter: blur(20px); + border-top: 1px solid var(--bp-border); +} + +.controller-hints { + display: flex; + justify-content: center; + gap: var(--bp-spacing-xl); +} + +.hint { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +.controller-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-sm); + font-size: 0.85rem; + font-weight: 700; + color: var(--bp-text); +} + +.controller-btn.a-btn { + background: #107c10; + border-color: #107c10; +} + +.controller-btn.b-btn { + background: #e81123; + border-color: #e81123; +} + +.controller-btn.y-btn { + background: #ffb900; + border-color: #ffb900; + color: #000; +} + +.controller-btn .material-symbols-outlined { + font-size: 18px; +} + +/* On-screen keyboard */ +.osk-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: var(--bp-spacing-xl); +} + +.osk-overlay.hidden { + display: none; +} + +.osk-container { + width: 100%; + max-width: 900px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-xl) var(--bp-radius-xl) 0 0; + padding: var(--bp-spacing-lg); +} + +.osk-header { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-md); +} + +.osk-text-input { + flex: 1; + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-bg); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + font-size: 1.2rem; + color: var(--bp-text); +} + +.osk-text-input:focus { + outline: none; + border-color: var(--bp-accent); +} + +.osk-close { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--bp-surface-hover); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.osk-close:hover, +.osk-close:focus { + background: var(--bp-danger); + border-color: var(--bp-danger); + outline: none; +} + +.osk-keyboard { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-sm); + margin-bottom: var(--bp-spacing-md); +} + +.osk-row { + display: flex; + justify-content: center; + gap: var(--bp-spacing-sm); +} + +.osk-key { + display: flex; + align-items: center; + justify-content: center; + min-width: 64px; + height: 64px; + padding: 0 var(--bp-spacing-md); + background: var(--bp-surface-hover); + border: 3px solid var(--bp-border); + border-radius: var(--bp-radius-md); + font-size: 1.3rem; + font-weight: 700; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); + text-transform: uppercase; +} + +.osk-key:hover { + background: var(--bp-surface-active); + transform: scale(1.05); +} + +.osk-key:focus { + outline: none; + border-color: var(--bp-accent); + box-shadow: 0 0 0 4px var(--bp-accent-glow), 0 0 20px var(--bp-accent-glow); + background: var(--bp-surface-active); + transform: scale(1.1); + z-index: 1; +} + +.osk-key.wide { + min-width: 120px; +} + +.osk-actions { + display: flex; + justify-content: center; + gap: var(--bp-spacing-md); + flex-wrap: wrap; +} + +.osk-action-btn { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-md) var(--bp-spacing-xl); + background: var(--bp-surface-hover); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.osk-action-btn .btn-hint { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 6px; + background: var(--bp-primary); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 700; + color: white; +} + +.osk-action-btn:hover, +.osk-action-btn:focus { + background: var(--bp-surface-active); + outline: none; + border-color: var(--bp-accent); +} + +.osk-action-btn.primary { + background: linear-gradient(135deg, var(--bp-primary) 0%, #5a1fd4 100%); + border-color: var(--bp-primary); +} + +.osk-action-btn.primary:hover, +.osk-action-btn.primary:focus { + box-shadow: var(--bp-focus-ring); +} + +.osk-action-btn.primary .btn-hint { + background: rgba(255, 255, 255, 0.2); +} + +/* OSK hints bar */ +.osk-hints { + display: flex; + justify-content: center; + gap: var(--bp-spacing-lg); + margin-top: var(--bp-spacing-md); + padding-top: var(--bp-spacing-md); + border-top: 1px solid var(--bp-border); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +.osk-hints b { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + margin-right: 4px; + background: var(--bp-surface-active); + border: 1px solid var(--bp-border); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + color: var(--bp-text); +} + +/* Context menu */ +.context-menu { + position: fixed; + z-index: 1001; + min-width: 200px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + padding: var(--bp-spacing-xs); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.context-menu.hidden { + display: none; +} + +.context-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + width: 100%; + padding: var(--bp-spacing-md); + background: transparent; + border: 2px solid transparent; + border-radius: var(--bp-radius-sm); + font-size: 1rem; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); + text-align: left; +} + +.context-item:hover { + background: var(--bp-surface-hover); +} + +.context-item:focus { + outline: none; + border-color: var(--bp-primary); + background: var(--bp-surface-hover); +} + +.context-item .material-symbols-outlined { + font-size: 20px; + color: var(--bp-accent); +} + +/* Focus indicators for controller navigation */ +[data-focusable]:focus { + outline: none; +} + +/* Quick access specific styles */ +.quick-access { + margin-bottom: var(--bp-spacing-xl); +} + +/* Recent sites specific styles */ +.recent-sites { + margin-bottom: var(--bp-spacing-lg); +} + +/* Responsive adjustments for Steam Deck (1280x800) */ +@media screen and (max-width: 1280px) and (max-height: 800px) { + html, body { + font-size: 16px; + } + + .bp-sidebar { + width: 180px; + min-width: 180px; + } + + .nav-item { + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + } + + .nav-item .material-symbols-outlined { + font-size: 24px; + } + + .section-title { + font-size: 1.6rem; + } + + .tile-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .tile-icon { + width: 48px; + height: 48px; + } +} + +/* Even smaller screens */ +@media screen and (max-width: 960px) { + .bp-sidebar { + width: 80px; + min-width: 80px; + } + + .nav-label { + display: none; + } + + .nav-item { + justify-content: center; + } +} + +/* Fullscreen mode */ +body.fullscreen .bp-header, +body.fullscreen .bp-footer { + display: none; +} + +body.fullscreen .bp-main { + height: 100vh; +} + +/* Loading state */ +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--bp-spacing-xl); +} + +.loading::after { + content: ''; + width: 40px; + height: 40px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Notification toast */ +.toast { + position: fixed; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + font-size: 1rem; + z-index: 1002; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(20px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(-50%) translateY(0); } + to { opacity: 0; transform: translateX(-50%) translateY(20px); } +} + +/* Virtual Cursor for controller-based web browsing */ +.virtual-cursor { + position: fixed; + z-index: 10000; + pointer-events: none; + transform: translate(-2px, -2px); + opacity: 0; + transition: opacity 0.2s ease; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); +} + +.virtual-cursor.active { + opacity: 1; +} + +.virtual-cursor svg { + width: 28px; + height: 28px; + transition: transform 0.1s ease; +} + +.virtual-cursor.clicking svg { + transform: scale(0.85); +} + +.cursor-click-indicator { + position: absolute; + top: 4px; + left: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bp-primary); + opacity: 0; + transform: scale(0); + transition: all 0.15s ease; +} + +.virtual-cursor.clicking .cursor-click-indicator { + opacity: 0.5; + transform: scale(1.5); +} + +/* Cursor trail effect (optional visual enhancement) */ +.virtual-cursor::after { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: 8px; + height: 8px; + background: var(--bp-accent); + border-radius: 50%; + opacity: 0.6; + animation: cursorPulse 1.5s ease-in-out infinite; +} + +@keyframes cursorPulse { + 0%, 100% { transform: scale(1); opacity: 0.6; } + 50% { transform: scale(1.3); opacity: 0.3; } +} + +/* Cursor hint overlay when in webview */ +.cursor-controls-hint { + position: fixed; + bottom: 80px; + right: 20px; + background: rgba(20, 20, 31, 0.9); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + font-size: 0.8rem; + color: var(--bp-text-muted); + z-index: 9999; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cursor-controls-hint .hint-row { + display: flex; + align-items: center; + gap: 8px; +} + +.cursor-controls-hint .hint-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 2px 6px; + background: var(--bp-surface-active); + border: 1px solid var(--bp-border); + border-radius: 4px; + font-weight: 600; + font-size: 0.7rem; + color: var(--bp-text); +} diff --git a/renderer/bigpicture.html b/renderer/bigpicture.html new file mode 100644 index 0000000..75a8f87 --- /dev/null +++ b/renderer/bigpicture.html @@ -0,0 +1,311 @@ + + + + + + Nebula - Big Picture Mode + + + + + + + + + + + +
+ +
+
+
+
+
+ + +
+
+ + Nebula +
+
+
+ --:-- + --- +
+
+
+
+ + wifi + + + battery_full + +
+ +
+
+ + +
+ + + + +
+ + + + +
+
+

+ Welcome back +

+

What would you like to browse today?

+
+ + +
+
+ search +
+ +
+ A Search +
+
+ + +
+

Quick Access

+
+ +
+
+ + +
+

Continue Browsing

+
+ +
+
+
+ + +
+ +
+ + +
+
+

Bookmarks

+

Your saved websites

+
+
+ +
+
+ + +
+
+

History

+

Recently visited sites

+
+
+ +
+
+ + +
+
+

Downloads

+

Your downloaded files

+
+
+
+ folder_open +

No recent downloads

+
+
+
+ + +
+
+

NeBot AI Assistant

+

Your AI-powered browsing companion

+
+
+
+
+ smart_toy +
+
+

Start Conversation

+

Ask questions, get summaries, and more

+
+
+ A +
+
+
+
+ + +
+
+

Settings

+

Configure your browser

+
+
+
+ palette + Themes +
+
+ shield + Privacy +
+
+ display_settings + Display +
+
+ desktop_windows + Desktop Mode +
+
+
+
+
+ + + + + + + + + +
+ + + + diff --git a/renderer/bigpicture.js b/renderer/bigpicture.js new file mode 100644 index 0000000..4a7ae7e --- /dev/null +++ b/renderer/bigpicture.js @@ -0,0 +1,1452 @@ +/** + * Big Picture Mode - Controller-friendly UI for Steam Deck / Console + * Supports gamepad navigation, on-screen keyboard, and touch input + */ + +const ipcRenderer = window.electronAPI; + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +const CONFIG = { + // Navigation + NAV_SOUND_ENABLED: true, + HAPTIC_FEEDBACK: true, + + // Controller deadzone + STICK_DEADZONE: 0.3, + TRIGGER_DEADZONE: 0.1, + + // Timing + REPEAT_DELAY: 500, // Initial delay before key repeat + REPEAT_RATE: 100, // Rate of key repeat + + // Quick access sites + DEFAULT_QUICK_ACCESS: [ + { title: 'Google', url: 'https://www.google.com', icon: 'search' }, + { title: 'YouTube', url: 'https://www.youtube.com', icon: 'play_circle' }, + { title: 'Reddit', url: 'https://www.reddit.com', icon: 'forum' }, + { title: 'Twitter', url: 'https://twitter.com', icon: 'tag' }, + { title: 'Wikipedia', url: 'https://www.wikipedia.org', icon: 'school' }, + { title: 'Netflix', url: 'https://www.netflix.com', icon: 'movie' }, + ] +}; + +// ============================================================================= +// STATE +// ============================================================================= + +const state = { + currentSection: 'home', + focusedElement: null, + focusableElements: [], + focusIndex: 0, + + // Gamepad + gamepadConnected: false, + gamepadIndex: null, + lastInput: { x: 0, y: 0 }, + inputRepeatTimer: null, + + // Virtual cursor for webview + cursorEnabled: false, + cursorX: 0, + cursorY: 0, + cursorSpeed: 15, + cursorElement: null, + + // OSK (On-Screen Keyboard) + oskVisible: false, + oskCallback: null, + oskFocusIndex: 0, + + // Data + bookmarks: [], + history: [], + + // Mouse tracking + mouseTimeout: null, + + // Webview for browsing + currentWebview: null, + webviewStack: [] // Stack of webview instances for navigation history +}; + +// ============================================================================= +// INITIALIZATION +// ============================================================================= + +document.addEventListener('DOMContentLoaded', () => { + console.log('[BigPicture] Initializing Big Picture Mode'); + + initClock(); + initNavigation(); + initGamepadSupport(); + initMouseTracking(); + initKeyboardShortcuts(); + initOSK(); + loadData(); + + // Set initial focus + setTimeout(() => { + updateFocusableElements(); + focusFirstElement(); + }, 100); +}); + +// ============================================================================= +// CLOCK & DATE +// ============================================================================= + +function initClock() { + updateClock(); + setInterval(updateClock, 1000); +} + +function updateClock() { + const now = new Date(); + const timeEl = document.getElementById('bp-time'); + const dateEl = document.getElementById('bp-date'); + + if (timeEl) { + timeEl.textContent = now.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } + + if (dateEl) { + dateEl.textContent = now.toLocaleDateString([], { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + } + + // Update greeting based on time + const greetingEl = document.getElementById('greeting-text'); + if (greetingEl) { + const hour = now.getHours(); + let greeting = 'Welcome back'; + if (hour < 12) greeting = 'Good morning'; + else if (hour < 17) greeting = 'Good afternoon'; + else greeting = 'Good evening'; + greetingEl.textContent = greeting; + } +} + +// ============================================================================= +// NAVIGATION +// ============================================================================= + +function initNavigation() { + // Sidebar navigation + document.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', () => { + const section = item.dataset.section; + if (section) { + switchSection(section); + } + }); + }); + + // Exit button + const exitBtn = document.getElementById('exitBigPicture'); + if (exitBtn) { + exitBtn.addEventListener('click', exitBigPictureMode); + } + + // Search card click + const searchCard = document.querySelector('.search-card'); + if (searchCard) { + searchCard.addEventListener('click', () => openOSK('search')); + } + + // Search input + const searchInput = document.getElementById('bp-search'); + if (searchInput) { + searchInput.addEventListener('focus', () => openOSK('search')); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + performSearch(searchInput.value); + } + }); + } + + // NeBot launch + const launchNebot = document.getElementById('launchNebot'); + if (launchNebot) { + launchNebot.addEventListener('click', () => navigateTo('browser://nebot')); + } + + // Settings cards + document.querySelectorAll('.settings-card').forEach(card => { + card.addEventListener('click', () => { + const action = card.dataset.action; + handleSettingsAction(action); + }); + }); +} + +function switchSection(sectionId) { + console.log('[BigPicture] Switching to section:', sectionId); + + // Handle webview container visibility (preserve state instead of destroying) + const webviewContainer = document.getElementById('webview-container'); + if (webviewContainer) { + if (sectionId === 'browse' && state.currentWebview) { + // Show the preserved webview when going back to browse + webviewContainer.classList.remove('hidden'); + // Re-enable cursor when returning to browse + enableCursor(); + } else if (sectionId !== 'browse') { + // Just hide the webview, don't destroy it + webviewContainer.classList.add('hidden'); + // Disable cursor when leaving browse + disableCursor(); + } + } + + // Update nav items + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.toggle('active', item.dataset.section === sectionId); + }); + + // Update sections + document.querySelectorAll('.bp-section').forEach(section => { + section.classList.toggle('active', section.id === `section-${sectionId}`); + }); + + state.currentSection = sectionId; + + // Update focusable elements for new section + setTimeout(() => { + updateFocusableElements(); + focusFirstInContent(); + }, 50); + + playNavSound(); +} + +function updateFocusableElements() { + // If OSK is visible, only include OSK elements + if (state.oskVisible) { + const oskOverlay = document.getElementById('osk-overlay'); + if (oskOverlay) { + state.focusableElements = [...oskOverlay.querySelectorAll('[data-focusable]')]; + console.log('[BigPicture] OSK focusable elements:', state.focusableElements.length); + return; + } + } + + const activeSection = document.querySelector('.bp-section.active'); + if (!activeSection) return; + + // Get all focusable elements in sidebar and active section + state.focusableElements = [ + ...document.querySelectorAll('.bp-sidebar [data-focusable]'), + ...activeSection.querySelectorAll('[data-focusable]'), + ...document.querySelectorAll('.bp-header [data-focusable]') + ]; + + console.log('[BigPicture] Focusable elements:', state.focusableElements.length); +} + +function focusFirstElement() { + if (state.focusableElements.length > 0) { + focusElement(state.focusableElements[0]); + state.focusIndex = 0; + } +} + +function focusFirstInContent() { + const activeSection = document.querySelector('.bp-section.active'); + if (!activeSection) return; + + const firstFocusable = activeSection.querySelector('[data-focusable]'); + if (firstFocusable) { + const index = state.focusableElements.indexOf(firstFocusable); + if (index !== -1) { + focusElement(firstFocusable); + state.focusIndex = index; + } + } +} + +function focusElement(element) { + if (!element) return; + + // Remove focus from previous + if (state.focusedElement) { + state.focusedElement.classList.remove('focused'); + } + + // Add focus to new element + element.classList.add('focused'); + element.focus(); + state.focusedElement = element; + + // Scroll into view if needed + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function navigateFocus(direction) { + if (state.focusableElements.length === 0) return; + + let newIndex = state.focusIndex; + + switch (direction) { + case 'up': + newIndex = findElementInDirection('up'); + break; + case 'down': + newIndex = findElementInDirection('down'); + break; + case 'left': + newIndex = findElementInDirection('left'); + break; + case 'right': + newIndex = findElementInDirection('right'); + break; + } + + if (newIndex !== state.focusIndex && newIndex >= 0 && newIndex < state.focusableElements.length) { + state.focusIndex = newIndex; + focusElement(state.focusableElements[newIndex]); + playNavSound(); + } +} + +function findElementInDirection(direction) { + const current = state.focusedElement; + if (!current) return 0; + + const currentRect = current.getBoundingClientRect(); + const currentCenter = { + x: currentRect.left + currentRect.width / 2, + y: currentRect.top + currentRect.height / 2 + }; + + let bestIndex = state.focusIndex; + let bestDistance = Infinity; + + state.focusableElements.forEach((element, index) => { + if (element === current) return; + + const rect = element.getBoundingClientRect(); + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + }; + + // Check if element is in the correct direction + let isValid = false; + switch (direction) { + case 'up': + isValid = center.y < currentCenter.y - 10; + break; + case 'down': + isValid = center.y > currentCenter.y + 10; + break; + case 'left': + isValid = center.x < currentCenter.x - 10; + break; + case 'right': + isValid = center.x > currentCenter.x + 10; + break; + } + + if (isValid) { + const distance = Math.sqrt( + Math.pow(center.x - currentCenter.x, 2) + + Math.pow(center.y - currentCenter.y, 2) + ); + + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + }); + + return bestIndex; +} + +function activateFocused() { + if (state.focusedElement) { + state.focusedElement.click(); + playSelectSound(); + } +} + +function goBack() { + // If OSK is open, close it + if (state.oskVisible) { + closeOSK(); + return; + } + + // If viewing a website, go back in browsing history + if (state.currentSection === 'browse' && state.currentWebview) { + if (state.currentWebview.canGoBack()) { + state.currentWebview.goBack(); + return; + } + } + + // If not on home, go to home + if (state.currentSection !== 'home') { + switchSection('home'); + // Cleanup webview + const container = document.getElementById('webview-container'); + if (container) { + const webview = container.querySelector('webview'); + if (webview) webview.remove(); + container.classList.add('hidden'); + } + state.currentWebview = null; + // Focus the home nav item + const homeNav = document.querySelector('.nav-item[data-section="home"]'); + if (homeNav) { + const index = state.focusableElements.indexOf(homeNav); + if (index !== -1) { + state.focusIndex = index; + focusElement(homeNav); + } + } + } +} + +// ============================================================================= +// GAMEPAD SUPPORT +// ============================================================================= + +function initGamepadSupport() { + window.addEventListener('gamepadconnected', (e) => { + console.log('[BigPicture] Gamepad connected:', e.gamepad.id); + state.gamepadConnected = true; + state.gamepadIndex = e.gamepad.index; + showToast('Controller connected'); + }); + + window.addEventListener('gamepaddisconnected', (e) => { + console.log('[BigPicture] Gamepad disconnected'); + state.gamepadConnected = false; + state.gamepadIndex = null; + showToast('Controller disconnected'); + }); + + // Start polling for gamepad input + requestAnimationFrame(pollGamepad); +} + +function pollGamepad() { + if (state.gamepadConnected && state.gamepadIndex !== null) { + const gamepads = navigator.getGamepads(); + const gamepad = gamepads[state.gamepadIndex]; + + if (gamepad) { + handleGamepadInput(gamepad); + } + } + + requestAnimationFrame(pollGamepad); +} + +function handleGamepadInput(gamepad) { + // D-pad and left stick for navigation + const leftX = gamepad.axes[0]; + const leftY = gamepad.axes[1]; + + // D-pad buttons (indices may vary by controller) + const dpadUp = gamepad.buttons[12]?.pressed; + const dpadDown = gamepad.buttons[13]?.pressed; + const dpadLeft = gamepad.buttons[14]?.pressed; + const dpadRight = gamepad.buttons[15]?.pressed; + + // Analog stick with deadzone + const stickUp = leftY < -CONFIG.STICK_DEADZONE; + const stickDown = leftY > CONFIG.STICK_DEADZONE; + const stickLeft = leftX < -CONFIG.STICK_DEADZONE; + const stickRight = leftX > CONFIG.STICK_DEADZONE; + + // Combine inputs + const up = dpadUp || stickUp; + const down = dpadDown || stickDown; + const left = dpadLeft || stickLeft; + const right = dpadRight || stickRight; + + // Navigation with repeat prevention + const now = Date.now(); + + if (up && !state.lastInput.up) { + navigateFocus('up'); + state.lastInput.up = now; + } else if (!up) { + state.lastInput.up = 0; + } + + if (down && !state.lastInput.down) { + navigateFocus('down'); + state.lastInput.down = now; + } else if (!down) { + state.lastInput.down = 0; + } + + if (left && !state.lastInput.left) { + navigateFocus('left'); + state.lastInput.left = now; + } else if (!left) { + state.lastInput.left = 0; + } + + if (right && !state.lastInput.right) { + navigateFocus('right'); + state.lastInput.right = now; + } else if (!right) { + state.lastInput.right = 0; + } + + // A button (usually index 0) - Select/Type letter + if (gamepad.buttons[0]?.pressed && !state.lastInput.a) { + activateFocused(); + state.lastInput.a = true; + } else if (!gamepad.buttons[0]?.pressed) { + state.lastInput.a = false; + } + + // B button (usually index 1) - Back/Close OSK + if (gamepad.buttons[1]?.pressed && !state.lastInput.b) { + goBack(); + state.lastInput.b = true; + } else if (!gamepad.buttons[1]?.pressed) { + state.lastInput.b = false; + } + + // X button (usually index 2) - Backspace when OSK is open + if (gamepad.buttons[2]?.pressed && !state.lastInput.x) { + if (state.oskVisible) { + backspaceOSK(); + } + state.lastInput.x = true; + } else if (!gamepad.buttons[2]?.pressed) { + state.lastInput.x = false; + } + + // Y button (usually index 3) - Space when OSK open, otherwise open search + if (gamepad.buttons[3]?.pressed && !state.lastInput.y) { + if (state.oskVisible) { + appendToOSK(' '); + } else { + openOSK('search'); + } + state.lastInput.y = true; + } else if (!gamepad.buttons[3]?.pressed) { + state.lastInput.y = false; + } + + // LB button (usually index 4) - Move cursor left / clear all + if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) { + if (state.oskVisible) { + clearOSK(); + } + state.lastInput.lb = true; + } else if (!gamepad.buttons[4]?.pressed) { + state.lastInput.lb = false; + } + + // RB button (usually index 5) - Submit when OSK open + if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) { + if (state.oskVisible) { + submitOSK(); + } + state.lastInput.rb = true; + } else if (!gamepad.buttons[5]?.pressed) { + state.lastInput.rb = false; + } + + // Start button (usually index 9) - Menu + if (gamepad.buttons[9]?.pressed && !state.lastInput.start) { + // Toggle to settings + if (state.currentSection !== 'settings') { + switchSection('settings'); + } else { + switchSection('home'); + } + state.lastInput.start = true; + } else if (!gamepad.buttons[9]?.pressed) { + state.lastInput.start = false; + } + + // Virtual cursor handling when webview is active + if (state.cursorEnabled && state.currentWebview) { + // Right stick for cursor movement + const rightX = gamepad.axes[2] || 0; + const rightY = gamepad.axes[3] || 0; + + // Apply deadzone + const deadzone = 0.15; + const moveX = Math.abs(rightX) > deadzone ? rightX : 0; + const moveY = Math.abs(rightY) > deadzone ? rightY : 0; + + if (moveX !== 0 || moveY !== 0) { + moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed); + } + + // Right trigger (index 7) - Left click + if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) { + virtualClick(); + state.lastInput.rt = true; + } else if (!gamepad.buttons[7]?.pressed) { + state.lastInput.rt = false; + } + + // Left trigger (index 6) - Right click + if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) { + virtualClick(true); + state.lastInput.lt = true; + } else if (!gamepad.buttons[6]?.pressed) { + state.lastInput.lt = false; + } + + // Right stick click (index 11) - Toggle cursor speed + if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) { + state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15); + showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`); + state.lastInput.rs = true; + } else if (!gamepad.buttons[11]?.pressed) { + state.lastInput.rs = false; + } + + // Left stick click (index 10) - Scroll mode toggle could go here + if (gamepad.buttons[10]?.pressed && !state.lastInput.ls) { + // Scroll the page + scrollWebview(leftY * 100); + state.lastInput.ls = true; + } else if (!gamepad.buttons[10]?.pressed) { + state.lastInput.ls = false; + } + } +} + +// ============================================================================= +// KEYBOARD SHORTCUTS +// ============================================================================= + +function initKeyboardShortcuts() { + document.addEventListener('keydown', (e) => { + // Don't handle if OSK is visible and we're typing + if (state.oskVisible) { + handleOSKKeyboard(e); + return; + } + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + navigateFocus('up'); + break; + case 'ArrowDown': + e.preventDefault(); + navigateFocus('down'); + break; + case 'ArrowLeft': + e.preventDefault(); + navigateFocus('left'); + break; + case 'ArrowRight': + e.preventDefault(); + navigateFocus('right'); + break; + case 'Enter': + case ' ': + e.preventDefault(); + activateFocused(); + break; + case 'Escape': + case 'Backspace': + e.preventDefault(); + goBack(); + break; + case 'Tab': + // Allow tab navigation + break; + } + }); +} + +// ============================================================================= +// MOUSE TRACKING +// ============================================================================= + +function initMouseTracking() { + document.addEventListener('mousemove', () => { + document.body.classList.add('mouse-active'); + + clearTimeout(state.mouseTimeout); + state.mouseTimeout = setTimeout(() => { + document.body.classList.remove('mouse-active'); + }, 3000); + }); + + // Add hover focus for mouse + document.addEventListener('mouseover', (e) => { + const focusable = e.target.closest('[data-focusable]'); + if (focusable && state.focusableElements.includes(focusable)) { + const index = state.focusableElements.indexOf(focusable); + state.focusIndex = index; + focusElement(focusable); + } + }); +} + +// ============================================================================= +// ON-SCREEN KEYBOARD +// ============================================================================= + +function initOSK() { + const keyboard = document.getElementById('osk-keyboard'); + if (!keyboard) return; + + const rows = [ + '1234567890', + 'qwertyuiop', + 'asdfghjkl', + 'zxcvbnm', + ]; + + rows.forEach(row => { + const rowEl = document.createElement('div'); + rowEl.className = 'osk-row'; + + [...row].forEach(char => { + const key = document.createElement('button'); + key.className = 'osk-key'; + key.textContent = char; + key.dataset.focusable = ''; + key.tabIndex = 0; + key.addEventListener('click', () => appendToOSK(char)); + rowEl.appendChild(key); + }); + + keyboard.appendChild(rowEl); + }); + + // Special keys + const specialRow = document.createElement('div'); + specialRow.className = 'osk-row'; + + ['.', '-', '_', '@', '/', ':', '.com'].forEach(char => { + const key = document.createElement('button'); + key.className = 'osk-key' + (char === '.com' ? ' wide' : ''); + key.textContent = char; + key.dataset.focusable = ''; + key.tabIndex = 0; + key.addEventListener('click', () => appendToOSK(char)); + specialRow.appendChild(key); + }); + + keyboard.appendChild(specialRow); + + // Action buttons + document.getElementById('osk-space')?.addEventListener('click', () => appendToOSK(' ')); + document.getElementById('osk-backspace')?.addEventListener('click', () => backspaceOSK()); + document.getElementById('osk-clear')?.addEventListener('click', () => clearOSK()); + document.getElementById('osk-submit')?.addEventListener('click', () => submitOSK()); + + // Close button + document.querySelector('.osk-close')?.addEventListener('click', () => closeOSK()); +} + +function openOSK(mode = 'search') { + const overlay = document.getElementById('osk-overlay'); + const input = document.getElementById('osk-input'); + + if (!overlay || !input) return; + + state.oskVisible = true; + state.oskMode = mode; + overlay.classList.remove('hidden'); + + // Clear input + input.value = ''; + + // Update focusable elements to only include OSK keys + updateFocusableElements(); + + // Focus first key + setTimeout(() => { + const firstKey = overlay.querySelector('.osk-key'); + if (firstKey) { + const index = state.focusableElements.indexOf(firstKey); + if (index !== -1) { + state.focusIndex = index; + focusElement(firstKey); + } else { + firstKey.focus(); + } + } + }, 100); +} + +function closeOSK() { + const overlay = document.getElementById('osk-overlay'); + if (!overlay) return; + + state.oskVisible = false; + overlay.classList.add('hidden'); + + // Return focus to main content + setTimeout(() => { + updateFocusableElements(); + focusFirstInContent(); + }, 100); +} + +function appendToOSK(char) { + const input = document.getElementById('osk-input'); + if (input) { + input.value += char; + } +} + +function backspaceOSK() { + const input = document.getElementById('osk-input'); + if (input && input.value.length > 0) { + input.value = input.value.slice(0, -1); + playNavSound(); + } +} + +function clearOSK() { + const input = document.getElementById('osk-input'); + if (input) { + input.value = ''; + playNavSound(); + } +} + +function submitOSK() { + const input = document.getElementById('osk-input'); + if (!input || !input.value.trim()) return; + + const value = input.value.trim(); + + if (state.oskMode === 'search') { + performSearch(value); + } + + closeOSK(); +} + +function handleOSKKeyboard(e) { + if (e.key === 'Escape') { + e.preventDefault(); + closeOSK(); + } else if (e.key === 'Enter') { + e.preventDefault(); + submitOSK(); + } else if (e.key === 'Backspace') { + backspaceOSK(); + } else if (e.key.length === 1) { + appendToOSK(e.key); + } +} + +// ============================================================================= +// DATA LOADING +// ============================================================================= + +async function loadData() { + await loadBookmarks(); + await loadHistory(); + renderQuickAccess(); +} + +async function loadBookmarks() { + try { + if (ipcRenderer && ipcRenderer.invoke) { + state.bookmarks = await ipcRenderer.invoke('load-bookmarks') || []; + } else { + // Fallback to localStorage + const stored = localStorage.getItem('bookmarks'); + state.bookmarks = stored ? JSON.parse(stored) : []; + } + renderBookmarks(); + } catch (err) { + console.error('[BigPicture] Failed to load bookmarks:', err); + state.bookmarks = []; + } +} + +async function loadHistory() { + try { + const stored = localStorage.getItem('siteHistory'); + state.history = stored ? JSON.parse(stored) : []; + renderHistory(); + renderRecentSites(); + } catch (err) { + console.error('[BigPicture] Failed to load history:', err); + state.history = []; + } +} + +// ============================================================================= +// RENDERING +// ============================================================================= + +function renderQuickAccess() { + const grid = document.getElementById('quickAccessGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + CONFIG.DEFAULT_QUICK_ACCESS.forEach(site => { + const tile = createTile(site.title, site.url, site.icon); + grid.appendChild(tile); + }); + + // Add "Add" tile + const addTile = document.createElement('div'); + addTile.className = 'tile add-tile'; + addTile.dataset.focusable = ''; + addTile.tabIndex = 0; + addTile.innerHTML = `add`; + addTile.addEventListener('click', () => showToast('Add bookmark coming soon')); + grid.appendChild(addTile); + + updateFocusableElements(); +} + +function renderBookmarks() { + const grid = document.getElementById('bookmarksGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + if (state.bookmarks.length === 0) { + grid.innerHTML = ` +
+ bookmark_border +

No bookmarks yet

+
+ `; + return; + } + + state.bookmarks.forEach(bookmark => { + const tile = createTile( + bookmark.title || bookmark.name || getDomainFromUrl(bookmark.url), + bookmark.url, + 'bookmark' + ); + grid.appendChild(tile); + }); + + updateFocusableElements(); +} + +function renderHistory() { + const list = document.getElementById('historyList'); + if (!list) return; + + list.innerHTML = ''; + + if (state.history.length === 0) { + list.innerHTML = ` +
+ history +

No browsing history

+
+ `; + return; + } + + // Show last 20 items + state.history.slice(0, 20).forEach(url => { + const item = createListItem(getDomainFromUrl(url), url); + list.appendChild(item); + }); + + updateFocusableElements(); +} + +function renderRecentSites() { + const container = document.getElementById('recentSitesScroll'); + if (!container) return; + + container.innerHTML = ''; + + if (state.history.length === 0) { + container.innerHTML = ` +
+ web +

Start browsing to see recent sites

+
+ `; + return; + } + + // Show last 10 unique domains + const seenDomains = new Set(); + const uniqueSites = []; + + for (const url of state.history) { + const domain = getDomainFromUrl(url); + if (!seenDomains.has(domain)) { + seenDomains.add(domain); + uniqueSites.push({ url, domain }); + if (uniqueSites.length >= 10) break; + } + } + + uniqueSites.forEach(site => { + const card = createScrollCard(site.domain, site.url); + container.appendChild(card); + }); + + updateFocusableElements(); +} + +function createTile(title, url, icon) { + const tile = document.createElement('div'); + tile.className = 'tile'; + tile.dataset.focusable = ''; + tile.tabIndex = 0; + tile.dataset.url = url; + + tile.innerHTML = ` +
+ ${icon} +
+
${escapeHtml(title)}
+
${getDomainFromUrl(url)}
+ `; + + tile.addEventListener('click', () => navigateTo(url)); + + return tile; +} + +function createListItem(title, url) { + const item = document.createElement('div'); + item.className = 'list-item'; + item.dataset.focusable = ''; + item.tabIndex = 0; + item.dataset.url = url; + + item.innerHTML = ` +
+ public +
+
+
${escapeHtml(title)}
+
${escapeHtml(url)}
+
+
+ A +
+ `; + + item.addEventListener('click', () => navigateTo(url)); + + return item; +} + +function createScrollCard(title, url) { + const card = document.createElement('div'); + card.className = 'scroll-card'; + card.dataset.focusable = ''; + card.tabIndex = 0; + card.dataset.url = url; + + card.innerHTML = ` +
+ public +
+
${escapeHtml(title)}
+
Recently visited
+ `; + + card.addEventListener('click', () => navigateTo(url)); + + return card; +} + +// ============================================================================= +// ACTIONS +// ============================================================================= + +function performSearch(query) { + if (!query.trim()) return; + + // Check if it's a URL + let url = query.trim(); + if (isUrl(url)) { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + navigateTo(url); + } else { + // Search with default engine (Google) + navigateTo(`https://www.google.com/search?q=${encodeURIComponent(query)}`); + } +} + +function navigateTo(url) { + console.log('[BigPicture] Navigating to:', url); + + // Create or reuse webview for browsing + const container = document.getElementById('webview-container'); + if (!container) return; + + // Hide content and show webview + document.querySelectorAll('.bp-section').forEach(s => s.classList.remove('active')); + container.classList.remove('hidden'); + + // Remove existing webview if any + const existingWebview = container.querySelector('webview'); + if (existingWebview) { + existingWebview.remove(); + } + + // Create new webview + const webview = document.createElement('webview'); + webview.src = url; + webview.style.width = '100%'; + webview.style.height = '100%'; + webview.style.border = 'none'; + webview.preload = '../preload.js'; + webview.partition = 'persist:main'; + webview.allowpopups = true; + webview.webpreferences = 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'; + + container.appendChild(webview); + state.currentWebview = webview; + + // Enable virtual cursor for webview interaction + enableCursor(); + + // Switch section to browse + switchSection('browse'); + + // Update focusable elements to include webview controls + setTimeout(() => { + updateFocusableElements(); + }, 100); +} + +function exitBigPictureMode() { + console.log('[BigPicture] Exiting Big Picture Mode'); + + if (ipcRenderer) { + ipcRenderer.send('exit-bigpicture'); + } else if (window.opener) { + window.opener.postMessage({ type: 'exit-bigpicture' }, '*'); + window.close(); + } +} + +function handleSettingsAction(action) { + switch (action) { + case 'theme': + showToast('Theme settings coming soon'); + break; + case 'privacy': + showToast('Privacy settings coming soon'); + break; + case 'display': + showToast('Display settings coming soon'); + break; + case 'exit-bigpicture': + exitBigPictureMode(); + break; + default: + console.log('[BigPicture] Unknown settings action:', action); + } +} + +// ============================================================================= +// UTILITIES +// ============================================================================= + +function isUrl(str) { + // Simple URL detection + return /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/.test(str) || + str.includes('.com') || + str.includes('.org') || + str.includes('.net') || + str.includes('.io') || + str.startsWith('browser://'); +} + +// ============================================================================= +// VIRTUAL CURSOR (for webview interaction) +// ============================================================================= + +function createCursorElement() { + if (state.cursorElement) return; + + const cursor = document.createElement('div'); + cursor.id = 'virtual-cursor'; + cursor.className = 'virtual-cursor'; + cursor.innerHTML = ` + + + +
+ `; + document.body.appendChild(cursor); + state.cursorElement = cursor; +} + +function enableCursor() { + if (!state.cursorElement) { + createCursorElement(); + } + + const container = document.getElementById('webview-container'); + if (container) { + const rect = container.getBoundingClientRect(); + state.cursorX = rect.left + rect.width / 2; + state.cursorY = rect.top + rect.height / 2; + } else { + state.cursorX = window.innerWidth / 2; + state.cursorY = window.innerHeight / 2; + } + + state.cursorEnabled = true; + updateCursorPosition(); + state.cursorElement.classList.add('active'); + + // Show cursor hint + showToast('🎮 Right stick: Move cursor | RT: Click | LT: Right-click | B: Back'); +} + +function disableCursor() { + state.cursorEnabled = false; + if (state.cursorElement) { + state.cursorElement.classList.remove('active'); + } +} + +function moveCursor(dx, dy) { + if (!state.cursorEnabled) return; + + const container = document.getElementById('webview-container'); + if (!container) return; + + const rect = container.getBoundingClientRect(); + + // Update cursor position with bounds checking + state.cursorX = Math.max(rect.left, Math.min(rect.right - 10, state.cursorX + dx)); + state.cursorY = Math.max(rect.top, Math.min(rect.bottom - 10, state.cursorY + dy)); + + updateCursorPosition(); +} + +function updateCursorPosition() { + if (!state.cursorElement) return; + + state.cursorElement.style.left = `${state.cursorX}px`; + state.cursorElement.style.top = `${state.cursorY}px`; +} + +function virtualClick(rightClick = false) { + if (!state.currentWebview || !state.cursorEnabled) return; + + const container = document.getElementById('webview-container'); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + + // Calculate position relative to webview + const x = state.cursorX - containerRect.left; + const y = state.cursorY - containerRect.top; + + // Show click animation + if (state.cursorElement) { + state.cursorElement.classList.add('clicking'); + setTimeout(() => state.cursorElement.classList.remove('clicking'), 150); + } + + // Send mouse event to webview + try { + const webContents = state.currentWebview; + + // Use executeJavaScript to simulate click at coordinates + const clickScript = rightClick ? ` + (function() { + const el = document.elementFromPoint(${x}, ${y}); + if (el) { + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: ${x}, + clientY: ${y}, + button: 2 + }); + el.dispatchEvent(event); + } + })(); + ` : ` + (function() { + const el = document.elementFromPoint(${x}, ${y}); + if (el) { + // Try to focus if it's an input + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.contentEditable === 'true') { + el.focus(); + } + // Simulate full click sequence + const rect = el.getBoundingClientRect(); + const events = ['mousedown', 'mouseup', 'click']; + events.forEach(type => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + view: window, + clientX: ${x}, + clientY: ${y}, + button: 0 + }); + el.dispatchEvent(event); + }); + // Also try clicking directly for links and buttons + if (el.click) el.click(); + } + })(); + `; + + webContents.executeJavaScript(clickScript).catch(err => { + console.log('[BigPicture] Click injection error:', err); + }); + } catch (err) { + console.log('[BigPicture] Virtual click error:', err); + } +} + +function scrollWebview(amount) { + if (!state.currentWebview) return; + + try { + state.currentWebview.executeJavaScript(`window.scrollBy(0, ${amount})`); + } catch (err) { + console.log('[BigPicture] Scroll error:', err); + } +} + +// ============================================================================= +// UTILITIES +// ============================================================================= + +function getDomainFromUrl(url) { + try { + if (url.startsWith('browser://')) { + return url.replace('browser://', '').split('/')[0]; + } + const hostname = new URL(url).hostname; + return hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function showToast(message) { + // Remove existing toast + const existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => toast.remove(), 3000); +} + +function playNavSound() { + if (!CONFIG.NAV_SOUND_ENABLED) return; + + // Simple beep using Web Audio API + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.frequency.value = 800; + oscillator.type = 'sine'; + gainNode.gain.value = 0.05; + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.03); + } catch (e) { + // Audio not available + } +} + +function playSelectSound() { + if (!CONFIG.NAV_SOUND_ENABLED) return; + + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.frequency.value = 1200; + oscillator.type = 'sine'; + gainNode.gain.value = 0.08; + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.05); + } catch (e) { + // Audio not available + } +} + +// ============================================================================= +// IPC HANDLERS +// ============================================================================= + +if (ipcRenderer) { + // Listen for theme changes + ipcRenderer.on('theme-changed', (theme) => { + if (theme && theme.colors) { + applyTheme(theme); + } + }); +} + +function applyTheme(theme) { + if (!theme || !theme.colors) return; + + const root = document.documentElement; + + if (theme.colors.bg) root.style.setProperty('--bp-bg', theme.colors.bg); + if (theme.colors.darkPurple) root.style.setProperty('--bp-surface', theme.colors.darkPurple); + if (theme.colors.primary) { + root.style.setProperty('--bp-primary', theme.colors.primary); + root.style.setProperty('--bp-primary-glow', `${theme.colors.primary}66`); + } + if (theme.colors.accent) { + root.style.setProperty('--bp-accent', theme.colors.accent); + root.style.setProperty('--bp-accent-glow', `${theme.colors.accent}4d`); + } + if (theme.colors.text) root.style.setProperty('--bp-text', theme.colors.text); +} + +console.log('[BigPicture] Module loaded'); diff --git a/renderer/index.html b/renderer/index.html index 7af7e81..6e3038a 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -62,6 +62,7 @@