From 5c837aecd8fb8f33138e574fc7988cd2c5867ac5 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Sat, 27 Dec 2025 23:09:45 +1300 Subject: [PATCH] Add Big Picture Mode for Steam Deck and controllers Introduces a new Big Picture Mode with a controller-friendly, console-style UI optimized for Steam Deck and handheld devices. Adds new renderer files (HTML, CSS, JS) for the mode, updates main and preload scripts to support window management and IPC for Big Picture Mode, and documents features and usage in BIG_PICTURE_MODE.md. Updates settings and navigation to allow launching and exiting Big Picture Mode. --- documentation/BIG_PICTURE_MODE.md | 120 +++ main.js | 137 +++ preload.js | 14 + renderer/bigpicture.css | 1428 ++++++++++++++++++++++++++++ renderer/bigpicture.html | 311 ++++++ renderer/bigpicture.js | 1452 +++++++++++++++++++++++++++++ renderer/index.html | 1 + renderer/script.js | 14 + renderer/settings.css | 29 + renderer/settings.html | 12 + renderer/settings.js | 43 + renderer/style.css | 12 + 12 files changed, 3573 insertions(+) create mode 100644 documentation/BIG_PICTURE_MODE.md create mode 100644 renderer/bigpicture.css create mode 100644 renderer/bigpicture.html create mode 100644 renderer/bigpicture.js 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 +
+
+
+
+
+ + +
+
+
+ + gamepad + + Navigate +
+
+ A + Select +
+
+ B + Back +
+
+ Y + Search +
+
+ + Menu +
+
+
+ + + + + + +
+ + + + 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 @@