/** * 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, // Sidebar visibility (for fullscreen webview) sidebarHidden: false, // OSK (On-Screen Keyboard) oskVisible: false, oskCallback: null, oskFocusIndex: 0, // Data bookmarks: [], history: [], // Mouse tracking mouseTimeout: null, // Webview for browsing currentWebview: null, webviewContentsId: null, // For native input event injection 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); }); }); } // ============================================================================= // SIDEBAR TOGGLE (for fullscreen webview) // ============================================================================= function toggleSidebar() { state.sidebarHidden = !state.sidebarHidden; const sidebar = document.querySelector('.bp-sidebar'); const content = document.querySelector('.bp-content'); const header = document.querySelector('.bp-header'); if (state.sidebarHidden) { sidebar?.classList.add('sidebar-hidden'); content?.classList.add('fullscreen'); header?.classList.add('sidebar-hidden'); showToast('📺 Fullscreen mode | Press ☰ to show sidebar'); } else { sidebar?.classList.remove('sidebar-hidden'); content?.classList.remove('fullscreen'); header?.classList.remove('sidebar-hidden'); showToast('Sidebar restored'); } } function showSidebar() { if (state.sidebarHidden) { toggleSidebar(); } } function switchSection(sectionId) { console.log('[BigPicture] Switching to section:', sectionId); // Restore sidebar when leaving browse section if (sectionId !== 'browse' && state.sidebarHidden) { showSidebar(); } // 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; } } // When in webview mode, only sidebar navigation is available if (state.cursorEnabled && state.currentWebview) { state.focusableElements = [ ...document.querySelectorAll('.bp-sidebar [data-focusable]'), ...document.querySelectorAll('.bp-header [data-focusable]') ]; console.log('[BigPicture] Webview mode - sidebar 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; // When cursor is enabled (viewing a webpage), only D-Pad navigates sidebar // Left stick is ignored for UI navigation in webview mode const inWebviewMode = state.cursorEnabled && state.currentWebview; // Combine inputs - but only use D-Pad when in webview mode const up = inWebviewMode ? dpadUp : (dpadUp || stickUp); const down = inWebviewMode ? dpadDown : (dpadDown || stickDown); const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft); const right = inWebviewMode ? dpadRight : (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) - Always select/activate focused menu item 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; } // Back/Select button (usually index 8) - Toggle sidebar when in webview if (gamepad.buttons[8]?.pressed && !state.lastInput.select) { if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } state.lastInput.select = true; } else if (!gamepad.buttons[8]?.pressed) { state.lastInput.select = false; } // Start button (usually index 9) - Menu / Toggle sidebar when viewing webpage if (gamepad.buttons[9]?.pressed && !state.lastInput.start) { // If viewing a webpage, toggle sidebar instead of going to settings if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } else 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); } // Left stick for scrolling in webview mode const scrollDeadzone = 0.25; const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0; const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0; if (scrollX !== 0 || scrollY !== 0) { scrollWebview(scrollY * 20, scrollX * 20); } // 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; } } } // ============================================================================= // 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'); const label = document.getElementById('osk-label'); if (!overlay || !input) return; state.oskVisible = true; state.oskMode = mode; overlay.classList.remove('hidden'); // Clear input input.value = ''; // Reset cursor position updateOSKCursorPosition(); // Update label based on mode if (label) { label.textContent = mode === 'search' ? 'Search or enter URL' : 'Enter text'; } // 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); } /** * Open OSK for typing into a focused input field in the webview */ function openOSKForWebview() { const overlay = document.getElementById('osk-overlay'); const input = document.getElementById('osk-input'); const label = document.getElementById('osk-label'); if (!overlay || !input) return; state.oskVisible = true; state.oskMode = 'webview'; // Special mode for webview input overlay.classList.remove('hidden'); // Clear input (could optionally preserve current input value) input.value = ''; // Reset cursor position updateOSKCursorPosition(); // Update the label to indicate webview mode if (label) { label.textContent = 'Type your text'; } // 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); showToast('📝 Type and press Submit to enter text'); } 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; updateOSKCursorPosition(); } } function backspaceOSK() { const input = document.getElementById('osk-input'); if (input && input.value.length > 0) { input.value = input.value.slice(0, -1); updateOSKCursorPosition(); playNavSound(); } } function clearOSK() { const input = document.getElementById('osk-input'); if (input) { input.value = ''; updateOSKCursorPosition(); playNavSound(); } } /** * Update the blinking cursor position to follow the text */ function updateOSKCursorPosition() { const input = document.getElementById('osk-input'); const cursor = document.getElementById('osk-cursor'); const measure = document.getElementById('osk-text-measure'); if (!input || !cursor || !measure) return; // Copy the input text to the measure element measure.textContent = input.value || ''; // Get the text width + padding offset const textWidth = measure.offsetWidth; const paddingLeft = 32; // var(--bp-spacing-lg) = 32px // Position cursor right after the text cursor.style.left = `${paddingLeft + textWidth}px`; } function submitOSK() { const input = document.getElementById('osk-input'); if (!input) return; const value = input.value; if (state.oskMode === 'search') { if (!value.trim()) return; performSearch(value.trim()); } else if (state.oskMode === 'webview' && state.currentWebview) { // Send the typed text to the webview's focused input sendTextToWebview(value, true); // true = submit after setting } closeOSK(); } /** * Send typed text from OSK to the focused input field in webview */ function sendTextToWebview(text, submit = false) { if (!state.currentWebview) return; try { // Send the text value to the webview const script = submit ? ` (function() { const activeEl = document.activeElement; if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { activeEl.value = ${JSON.stringify(text)}; activeEl.dispatchEvent(new Event('input', { bubbles: true })); activeEl.dispatchEvent(new Event('change', { bubbles: true })); // Trigger Enter key to submit setTimeout(() => { activeEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); activeEl.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true })); activeEl.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); // Also try form submission const form = activeEl.closest('form'); if (form) { const submitBtn = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); if (submitBtn) submitBtn.click(); } }, 50); } })(); ` : ` (function() { const activeEl = document.activeElement; if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { activeEl.value = ${JSON.stringify(text)}; activeEl.dispatchEvent(new Event('input', { bubbles: true })); } })(); `; state.currentWebview.executeJavaScript(script).catch(err => { console.log('[BigPicture] Send text error:', err); }); } catch (err) { console.log('[BigPicture] sendTextToWebview error:', err); } } 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; state.webviewContentsId = null; // Will be set when webview is ready // Get webContentsId when webview is ready for native input events webview.addEventListener('dom-ready', () => { try { // getWebContentsId is available on webview element state.webviewContentsId = webview.getWebContentsId(); console.log('[BigPicture] WebContents ID:', state.webviewContentsId); // Inject script to detect input field focus and notify the host injectInputFocusDetection(webview); } catch (err) { console.log('[BigPicture] Could not get webContentsId:', err); } }); // Listen for IPC messages from webview (for OSK requests) webview.addEventListener('ipc-message', (event) => { if (event.channel === 'bigpicture-input-focused') { // Input field was clicked/focused in webview - show OSK for webview input console.log('[BigPicture] Input focused in webview'); openOSKForWebview(); } }); // Enable virtual cursor for webview interaction enableCursor(); // Switch section to browse switchSection('browse'); // Update focusable elements to include webview controls setTimeout(() => { updateFocusableElements(); }, 100); } /** * Inject script to detect input focus in webview and send message to host */ function injectInputFocusDetection(webview) { const script = ` (function() { if (window.__bigPictureInputDetection) return; window.__bigPictureInputDetection = true; // Track the last focused input let lastFocusedInput = null; document.addEventListener('focusin', (e) => { const el = e.target; const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.contentEditable === 'true' || el.isContentEditable || el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox'; // Check input type - exclude non-text inputs if (el.tagName === 'INPUT') { const type = el.type.toLowerCase(); if (['checkbox', 'radio', 'submit', 'button', 'image', 'file', 'hidden', 'reset', 'range', 'color'].includes(type)) { return; } } if (isInput) { lastFocusedInput = el; // Send message to host (Big Picture Mode) to show OSK try { if (window.electronAPI && window.electronAPI.sendToHost) { window.electronAPI.sendToHost('bigpicture-input-focused', { type: el.tagName, inputType: el.type || 'text', value: el.value || '' }); } } catch(e) { console.log('BigPicture: Could not notify input focus', e); } } }, true); // Listen for text input from OSK window.addEventListener('message', (e) => { if (e.data && e.data.type === 'bigpicture-osk-input' && lastFocusedInput) { lastFocusedInput.value = e.data.value; lastFocusedInput.dispatchEvent(new Event('input', { bubbles: true })); lastFocusedInput.dispatchEvent(new Event('change', { bubbles: true })); } else if (e.data && e.data.type === 'bigpicture-osk-submit' && lastFocusedInput) { // Submit the form or trigger search const form = lastFocusedInput.closest('form'); if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); // Also try clicking any submit button const submitBtn = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); if (submitBtn) submitBtn.click(); } // Trigger Enter key event lastFocusedInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); lastFocusedInput.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true })); lastFocusedInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); } }); console.log('[BigPicture] Input focus detection injected'); })(); `; webview.executeJavaScript(script).catch(err => { console.log('[BigPicture] Could not inject input detection:', err); }); } 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'); // Update focusable elements to only include sidebar when in webview mode updateFocusableElements(); // Show cursor hint showToast('🎮 Right stick: Move cursor | RT: Click | Left stick: Scroll | B: Back'); } function disableCursor() { state.cursorEnabled = false; if (state.cursorElement) { state.cursorElement.classList.remove('active'); } // Restore full focusable elements updateFocusableElements(); } 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 = Math.round(state.cursorX - containerRect.left); const y = Math.round(state.cursorY - containerRect.top); // Show click animation if (state.cursorElement) { state.cursorElement.classList.add('clicking'); setTimeout(() => state.cursorElement.classList.remove('clicking'), 150); } const webview = state.currentWebview; // Try to use native input event injection via IPC (most reliable for complex sites) if (state.webviewContentsId && window.bigPictureAPI && window.bigPictureAPI.sendInputEvent) { const sendNativeClick = async () => { try { // Send mouseMove first to position the cursor await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { type: 'mouseMove', x: x, y: y }); // Small delay then send mouseDown await new Promise(r => setTimeout(r, 10)); await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { type: 'mouseDown', x: x, y: y, button: rightClick ? 'right' : 'left', clickCount: 1 }); // Small delay then send mouseUp await new Promise(r => setTimeout(r, 50)); await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { type: 'mouseUp', x: x, y: y, button: rightClick ? 'right' : 'left', clickCount: 1 }); console.log('[BigPicture] Native click sent at', x, y); } catch (err) { console.log('[BigPicture] Native input error, falling back to JS:', err); fallbackJavaScriptClick(webview, x, y, rightClick); } }; sendNativeClick(); return; } // Fallback to JavaScript injection fallbackJavaScriptClick(webview, x, y, rightClick); } function fallbackJavaScriptClick(webview, x, y, rightClick) { try { if (rightClick) { // For right-click, use JavaScript injection const rightClickScript = ` (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); } })(); `; webview.executeJavaScript(rightClickScript).catch(err => { console.log('[BigPicture] Right-click injection error:', err); }); } else { // Comprehensive JavaScript injection with pointer events const clickScript = ` (function() { const x = ${x}; const y = ${y}; const el = document.elementFromPoint(x, y); if (!el) return; // Check if we're clicking on YouTube player area const isYouTubePlayer = el.closest('.html5-video-player') || el.closest('.ytp-player') || el.closest('#movie_player') || el.closest('.html5-main-video') || el.closest('.video-stream') || (window.location.hostname.includes('youtube.com') && (el.tagName === 'VIDEO' || el.closest('#player'))); if (isYouTubePlayer) { // For YouTube player, directly toggle playback const video = document.querySelector('video.html5-main-video') || document.querySelector('video.video-stream') || document.querySelector('#movie_player video') || document.querySelector('video'); if (video) { if (video.paused) { video.play().catch(() => {}); } else { video.pause(); } return; } } // Find the actual clickable element (may be parent) let clickTarget = el; let current = el; for (let i = 0; i < 10 && current; i++) { if (current.tagName === 'A' || current.tagName === 'BUTTON' || current.onclick || current.getAttribute('role') === 'button' || window.getComputedStyle(current).cursor === 'pointer') { clickTarget = current; break; } current = current.parentElement; } // Common event options const eventOptions = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: 1, pointerId: 1, pointerType: 'mouse', isPrimary: true, pressure: 0.5, width: 1, height: 1 }; // Handle input elements specially - focus first const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.contentEditable === 'true' || el.isContentEditable || el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox' || el.closest('[contenteditable="true"]'); if (isInput) { // Focus the input element el.focus(); // Dispatch proper focus sequence el.dispatchEvent(new FocusEvent('focus', { bubbles: true })); el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); // Dispatch click to activate any click handlers el.dispatchEvent(new MouseEvent('click', eventOptions)); return; } // For general video elements (not YouTube specific) if (el.tagName === 'VIDEO') { if (el.paused) { el.play().catch(() => {}); } else { el.pause(); } return; } // Dispatch pointer events (used by modern sites) try { clickTarget.dispatchEvent(new PointerEvent('pointerdown', eventOptions)); clickTarget.dispatchEvent(new PointerEvent('pointerup', eventOptions)); } catch(e) {} // Dispatch mouse events clickTarget.dispatchEvent(new MouseEvent('mousedown', eventOptions)); clickTarget.dispatchEvent(new MouseEvent('mouseup', eventOptions)); clickTarget.dispatchEvent(new MouseEvent('click', eventOptions)); // Direct click as final fallback if (clickTarget.click) clickTarget.click(); })(); `; webview.executeJavaScript(clickScript).catch(err => { console.log('[BigPicture] Click injection error:', err); }); } } catch (err) { console.log('[BigPicture] Virtual click error:', err); } } function scrollWebview(amountY, amountX = 0) { if (!state.currentWebview) return; try { state.currentWebview.executeJavaScript(`window.scrollBy(${amountX}, ${amountY})`); } 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');