/** * Big Picture Mode - Controller-friendly UI for Steam Deck / Console * Supports gamepad navigation, on-screen keyboard, and touch input */ const ipcRenderer = window.electronAPI; // ============================================================================= // SCROLL NORMALIZATION (consistent scroll speed across all sites) // ============================================================================= const SCROLL_NORMALIZATION_CSS = ` /* Disable smooth scrolling behavior that some sites force */ *, *::before, *::after { scroll-behavior: auto !important; } html, body { scroll-behavior: auto !important; } `; const SCROLL_NORMALIZATION_JS = ` (function() { if (window.__nebulaScrollNormalized) return; window.__nebulaScrollNormalized = true; // Consistent scroll amount in pixels per wheel delta unit const SCROLL_SPEED = 100; // Intercept wheel events to normalize scroll speed document.addEventListener('wheel', function(e) { // Don't interfere if modifier keys are pressed (zoom, horizontal scroll, etc.) if (e.ctrlKey || e.metaKey || e.altKey) return; // Get the scroll target let target = e.target; let scrollable = null; // Find the nearest scrollable element while (target && target !== document.body && target !== document.documentElement) { const style = window.getComputedStyle(target); const overflowY = style.overflowY; const overflowX = style.overflowX; if ((overflowY === 'auto' || overflowY === 'scroll') && target.scrollHeight > target.clientHeight) { scrollable = target; break; } if ((overflowX === 'auto' || overflowX === 'scroll') && target.scrollWidth > target.clientWidth && e.shiftKey) { scrollable = target; break; } target = target.parentElement; } // If no scrollable container found, use the document if (!scrollable) { scrollable = document.scrollingElement || document.documentElement || document.body; } // Calculate normalized scroll delta // deltaMode: 0 = pixels, 1 = lines, 2 = pages let deltaY = e.deltaY; let deltaX = e.deltaX; if (e.deltaMode === 1) { // Line mode - multiply by line height approximation deltaY *= SCROLL_SPEED; deltaX *= SCROLL_SPEED; } else if (e.deltaMode === 2) { // Page mode - multiply by viewport height deltaY *= window.innerHeight; deltaX *= window.innerWidth; } else { // Pixel mode - normalize to consistent speed // Clamp the delta to prevent extremely fast scrolling from some sites const sign = deltaY > 0 ? 1 : -1; deltaY = sign * Math.min(Math.abs(deltaY), SCROLL_SPEED * 3); const signX = deltaX > 0 ? 1 : -1; deltaX = signX * Math.min(Math.abs(deltaX), SCROLL_SPEED * 3); } // Apply scroll e.preventDefault(); scrollable.scrollBy({ top: deltaY, left: e.shiftKey ? deltaX : 0, behavior: 'auto' }); }, { passive: false, capture: true }); })(); `; // Function to apply scroll normalization to a webview function applyScrollNormalization(webview) { try { webview.insertCSS(SCROLL_NORMALIZATION_CSS); webview.executeJavaScript(SCROLL_NORMALIZATION_JS); console.log('[BigPicture] Applied scroll normalization to webview'); } catch (err) { console.warn('[BigPicture] Failed to apply scroll normalization:', err); } } // ============================================================================= // 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')); } // History section buttons const clearHistoryBtn = document.getElementById('clearHistoryBtn'); if (clearHistoryBtn) { clearHistoryBtn.addEventListener('click', clearHistory); } const refreshHistoryBtn = document.getElementById('refreshHistoryBtn'); if (refreshHistoryBtn) { refreshHistoryBtn.addEventListener('click', async () => { await loadHistory(); showToast('History refreshed'); }); } // 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); } } } } function goForward() { // If viewing a website, go forward in browsing history if (state.currentSection === 'browse' && state.currentWebview) { if (state.currentWebview.canGoForward()) { state.currentWebview.goForward(); } } } // ============================================================================= // GAMEPAD SUPPORT // ============================================================================= function initGamepadSupport() { if (!navigator.getGamepads) { console.warn('[BigPicture] Gamepad API not available in this environment'); return; } // Note: On Linux (and some controllers like handheld integrated gamepads), // the `gamepadconnected` event may not fire until the first button press, // or at all. We rely on continuous polling for robustness. window.addEventListener('gamepadconnected', (e) => { console.log('[BigPicture] Gamepad connected:', e.gamepad?.id || 'unknown'); // Prefer the first connected controller as the active one. if (state.gamepadIndex === null) { state.gamepadConnected = true; state.gamepadIndex = e.gamepad.index; showToast('Controller connected'); } }); window.addEventListener('gamepaddisconnected', (e) => { console.log('[BigPicture] Gamepad disconnected:', e.gamepad?.id || 'unknown'); // If the active controller disconnected, clear it; polling will auto-select another. if (state.gamepadIndex === e.gamepad.index) { state.gamepadConnected = false; state.gamepadIndex = null; showToast('Controller disconnected'); } }); // Initial scan (covers controllers that are already connected at load). refreshActiveGamepad(true); // Start polling for gamepad input requestAnimationFrame(pollGamepad); } function getFirstConnectedGamepad(gamepads) { if (!gamepads) return null; for (let i = 0; i < gamepads.length; i++) { const gp = gamepads[i]; if (gp) return gp; } return null; } function refreshActiveGamepad(isInitial = false) { const gamepads = navigator.getGamepads(); // If we have an index, verify it still points to a real gamepad. let active = null; if (state.gamepadIndex !== null) { active = gamepads[state.gamepadIndex] || null; } // Fallback: pick the first connected controller. if (!active) { active = getFirstConnectedGamepad(gamepads); } if (active) { const changed = !state.gamepadConnected || state.gamepadIndex !== active.index; state.gamepadConnected = true; state.gamepadIndex = active.index; if (changed && !isInitial) { console.log('[BigPicture] Active gamepad selected:', active.id); showToast('Controller connected'); } } else { if (state.gamepadConnected) { state.gamepadConnected = false; state.gamepadIndex = null; if (!isInitial) { showToast('Controller disconnected'); } } state.gamepadConnected = false; state.gamepadIndex = null; } return { gamepads, active }; } function pollGamepad() { const { active } = refreshActiveGamepad(false); if (active) { handleGamepadInput(active); } 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) - Go back in webview / clear OSK if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) { if (state.oskVisible) { clearOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goBack(); } state.lastInput.lb = true; } else if (!gamepad.buttons[4]?.pressed) { state.lastInput.lb = false; } // RB button (usually index 5) - Go forward in webview / submit OSK if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) { if (state.oskVisible) { submitOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goForward(); } 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(); initSettings(); } 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 { if (ipcRenderer && ipcRenderer.invoke) { state.history = await ipcRenderer.invoke('load-site-history') || []; } else { // Fallback to localStorage 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 = []; } } // Save a site to history async function saveToHistory(url) { if (!url || url.startsWith('browser://')) return; try { if (ipcRenderer && ipcRenderer.invoke) { await ipcRenderer.invoke('save-site-history-entry', url); // Refresh history after saving await loadHistory(); } else { // Fallback to localStorage let history = state.history; history = history.filter(item => item !== url); history.unshift(url); if (history.length > 100) history = history.slice(0, 100); localStorage.setItem('siteHistory', JSON.stringify(history)); state.history = history; renderHistory(); renderRecentSites(); } } catch (err) { console.error('[BigPicture] Failed to save history:', err); } } // Clear all browsing history async function clearHistory() { try { if (ipcRenderer && ipcRenderer.invoke) { await ipcRenderer.invoke('clear-site-history'); } else { localStorage.removeItem('siteHistory'); } state.history = []; renderHistory(); renderRecentSites(); showToast('History cleared'); } catch (err) { console.error('[BigPicture] Failed to clear history:', err); showToast('Failed to clear 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

Add bookmarks in desktop mode to see them here

`; return; } state.bookmarks.forEach(bookmark => { const tile = createBookmarkTile(bookmark); grid.appendChild(tile); }); updateFocusableElements(); } function createBookmarkTile(bookmark) { const tile = document.createElement('div'); tile.className = 'tile bookmark-tile'; tile.dataset.focusable = ''; tile.tabIndex = 0; tile.dataset.url = bookmark.url; const title = bookmark.title || bookmark.name || getDomainFromUrl(bookmark.url); const icon = bookmark.icon || 'bookmark'; // Check if icon is a URL (favicon) or a material icon name const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); let iconHtml; if (isIconUrl) { iconHtml = ``; } else { iconHtml = `${escapeHtml(icon)}`; } tile.innerHTML = `
${iconHtml}
${escapeHtml(title)}
${getDomainFromUrl(bookmark.url)}
`; tile.addEventListener('click', () => navigateTo(bookmark.url)); return tile; } function renderHistory() { const list = document.getElementById('historyList'); if (!list) return; list.innerHTML = ''; if (state.history.length === 0) { list.innerHTML = `
history

No browsing history

Sites you visit will appear here

`; return; } // Show last 30 items state.history.slice(0, 30).forEach(url => { const item = createHistoryItem(url); list.appendChild(item); }); updateFocusableElements(); } function createHistoryItem(url) { const item = document.createElement('div'); item.className = 'list-item history-item'; item.dataset.focusable = ''; item.tabIndex = 0; item.dataset.url = url; const domain = getDomainFromUrl(url); const faviconUrl = getFaviconUrl(url); item.innerHTML = `
${escapeHtml(domain)}
${escapeHtml(url)}
A
`; item.addEventListener('click', () => navigateTo(url)); return item; } 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, useFavicon = false) { const tile = document.createElement('div'); tile.className = 'tile'; tile.dataset.focusable = ''; tile.tabIndex = 0; tile.dataset.url = url; let iconHtml; const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); if (isIconUrl || useFavicon) { const faviconUrl = isIconUrl ? icon : getFaviconUrl(url); iconHtml = ``; } else { iconHtml = `${escapeHtml(icon)}`; } tile.innerHTML = `
${iconHtml}
${escapeHtml(title)}
${getDomainFromUrl(url)}
`; tile.addEventListener('click', () => navigateTo(url)); return tile; } function getFaviconUrl(url) { try { const urlObj = new URL(url); return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`; } catch { return ''; } } 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; const faviconUrl = getFaviconUrl(url); card.innerHTML = `
${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 // Save initial URL to history saveToHistory(url); // 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); // Apply scroll normalization for consistent scroll speed applyScrollNormalization(webview); // Inject script to detect input field focus and notify the host injectInputFocusDetection(webview); } catch (err) { console.log('[BigPicture] Could not get webContentsId:', err); } }); // Save navigation to history webview.addEventListener('did-navigate', (event) => { const newUrl = event.url; if (newUrl && !newUrl.startsWith('about:')) { saveToHistory(newUrl); } }); // Also save history on in-page navigations (e.g., SPA navigations) webview.addEventListener('did-navigate-in-page', (event) => { if (event.isMainFrame) { const newUrl = event.url; if (newUrl && !newUrl.startsWith('about:')) { saveToHistory(newUrl); } } }); // 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': switchSettingsTab('themes'); break; case 'privacy': switchSettingsTab('privacy'); break; case 'display': switchSettingsTab('display'); break; case 'exit-bigpicture': exitBigPictureMode(); break; default: console.log('[BigPicture] Unknown settings action:', action); } } // ============================================================================= // SETTINGS FUNCTIONALITY // ============================================================================= const DISPLAY_SCALE_KEY = 'nebula-display-scale'; let currentDisplayScale = 100; let currentThemeName = 'default'; // Theme definitions (matching customization.js) const THEMES = { default: { name: 'Default', colors: { bg: '#121418', darkPurple: '#1B1035', primary: '#7B2EFF', accent: '#00C6FF', text: '#E0E0E0' } }, ocean: { name: 'Ocean', colors: { bg: '#1a365d', darkPurple: '#2c5282', primary: '#3182ce', accent: '#00d9ff', text: '#e2e8f0' } }, forest: { name: 'Forest', colors: { bg: '#1a202c', darkPurple: '#2d3748', primary: '#68d391', accent: '#9ae6b4', text: '#f7fafc' } }, sunset: { name: 'Sunset', colors: { bg: '#744210', darkPurple: '#c05621', primary: '#ed8936', accent: '#fbb040', text: '#fffaf0' } }, cyberpunk: { name: 'Cyberpunk', colors: { bg: '#0a0a0a', darkPurple: '#2a0a3a', primary: '#ff0080', accent: '#00ffff', text: '#ffffff' } }, 'midnight-rose': { name: 'Midnight Rose', colors: { bg: '#1c1820', darkPurple: '#3d3046', primary: '#d4af37', accent: '#ffd700', text: '#f5f5dc' } }, 'arctic-ice': { name: 'Arctic Ice', colors: { bg: '#f0f8ff', darkPurple: '#d1e7ff', primary: '#4169e1', accent: '#87ceeb', text: '#2f4f4f' } }, 'cherry-blossom': { name: 'Cherry Blossom', colors: { bg: '#fff5f8', darkPurple: '#ffd4db', primary: '#ff69b4', accent: '#ffb6c1', text: '#8b4513' } }, 'cosmic-purple': { name: 'Cosmic Purple', colors: { bg: '#0f0524', darkPurple: '#2d1b69', primary: '#9400d3', accent: '#da70d6', text: '#e6e6fa' } }, 'emerald-dream': { name: 'Emerald Dream', colors: { bg: '#0d2818', darkPurple: '#2d5a44', primary: '#50c878', accent: '#00fa9a', text: '#f0fff0' } }, 'mocha-coffee': { name: 'Mocha Coffee', colors: { bg: '#3c2414', darkPurple: '#5d3a26', primary: '#d2691e', accent: '#deb887', text: '#faf0e6' } }, 'lavender-fields': { name: 'Lavender Fields', colors: { bg: '#f8f4ff', darkPurple: '#e6d8ff', primary: '#9370db', accent: '#dda0dd', text: '#4b0082' } } }; function initSettings() { console.log('[BigPicture] Initializing settings...'); // Load saved settings loadSavedSettings(); // Initialize settings tabs initSettingsTabs(); // Initialize theme selection initThemeSelection(); // Initialize display scale controls initDisplayScaleControls(); // Initialize privacy controls initPrivacyControls(); // Initialize about panel initAboutPanel(); } function loadSavedSettings() { // Load display scale try { const savedScale = localStorage.getItem(DISPLAY_SCALE_KEY); if (savedScale) { currentDisplayScale = parseInt(savedScale, 10); updateScaleDisplay(); } } catch (err) { console.warn('[BigPicture] Failed to load display scale:', err); } // Load theme try { const savedTheme = localStorage.getItem('nebula-theme-name'); if (savedTheme && THEMES[savedTheme]) { currentThemeName = savedTheme; applyTheme(THEMES[savedTheme]); highlightActiveTheme(); } } catch (err) { console.warn('[BigPicture] Failed to load theme:', err); } } function initSettingsTabs() { document.querySelectorAll('.settings-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.settingsTab; if (tabName) { switchSettingsTab(tabName); } }); }); } function switchSettingsTab(tabName) { // Update tab buttons document.querySelectorAll('.settings-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.settingsTab === tabName); }); // Update panels document.querySelectorAll('.settings-panel').forEach(panel => { panel.classList.toggle('active', panel.id === `settings-panel-${tabName}`); }); // Update focusable elements setTimeout(() => { updateFocusableElements(); }, 50); playNavSound(); } function initThemeSelection() { document.querySelectorAll('.theme-card').forEach(card => { card.addEventListener('click', () => { const themeName = card.dataset.theme; if (themeName && THEMES[themeName]) { selectTheme(themeName); } }); }); // Highlight current theme highlightActiveTheme(); } function selectTheme(themeName) { if (!THEMES[themeName]) return; currentThemeName = themeName; const theme = THEMES[themeName]; // Apply theme locally applyTheme(theme); // Save to localStorage try { localStorage.setItem('nebula-theme-name', themeName); // Also save the full theme data for other pages const fullThemeData = { name: theme.name, colors: { bg: theme.colors.bg, darkBlue: theme.colors.darkPurple, darkPurple: theme.colors.darkPurple, primary: theme.colors.primary, accent: theme.colors.accent, text: theme.colors.text, urlBarBg: theme.colors.darkPurple, urlBarText: theme.colors.text, urlBarBorder: theme.colors.primary, tabBg: theme.colors.darkPurple, tabText: theme.colors.text, tabActive: theme.colors.bg, tabActiveText: theme.colors.text, tabBorder: theme.colors.bg }, gradient: `linear-gradient(145deg, ${theme.colors.bg} 0%, ${theme.colors.darkPurple} 100%)` }; localStorage.setItem('browserTheme', JSON.stringify(fullThemeData)); } catch (err) { console.warn('[BigPicture] Failed to save theme:', err); } // Notify main process if (ipcRenderer && ipcRenderer.send) { ipcRenderer.send('theme-changed', { name: themeName, colors: theme.colors }); } highlightActiveTheme(); showToast(`Theme changed to ${theme.name}`); playSelectSound(); } function highlightActiveTheme() { document.querySelectorAll('.theme-card').forEach(card => { card.classList.toggle('active', card.dataset.theme === currentThemeName); }); } function initDisplayScaleControls() { const scaleDown = document.getElementById('bp-scale-down'); const scaleUp = document.getElementById('bp-scale-up'); const exitDesktop = document.getElementById('bp-exit-desktop'); if (scaleDown) { scaleDown.addEventListener('click', () => { adjustDisplayScale(-10); }); } if (scaleUp) { scaleUp.addEventListener('click', () => { adjustDisplayScale(10); }); } if (exitDesktop) { exitDesktop.addEventListener('click', () => { exitBigPictureMode(); }); } updateScaleDisplay(); } function adjustDisplayScale(delta) { const newScale = Math.min(300, Math.max(50, currentDisplayScale + delta)); if (newScale !== currentDisplayScale) { currentDisplayScale = newScale; updateScaleDisplay(); saveDisplayScale(); showToast(`Display scale: ${currentDisplayScale}%`); playNavSound(); } } function updateScaleDisplay() { const scaleValue = document.getElementById('bp-scale-value'); if (scaleValue) { scaleValue.textContent = `${currentDisplayScale}%`; } } function saveDisplayScale() { try { localStorage.setItem(DISPLAY_SCALE_KEY, currentDisplayScale.toString()); // Notify main process to update zoom level if (ipcRenderer && ipcRenderer.send) { ipcRenderer.send('set-display-scale', currentDisplayScale); } } catch (err) { console.warn('[BigPicture] Failed to save display scale:', err); } } function initPrivacyControls() { const clearDataBtn = document.getElementById('bp-clear-data'); const clearHistoryBtn = document.getElementById('bp-clear-history'); const clearSearchBtn = document.getElementById('bp-clear-search'); if (clearDataBtn) { clearDataBtn.addEventListener('click', async () => { if (await confirmAction('Clear all browsing data? This cannot be undone.')) { await clearAllBrowsingData(); } }); } if (clearHistoryBtn) { clearHistoryBtn.addEventListener('click', async () => { if (await confirmAction('Clear browsing history?')) { await clearBrowsingHistory(); } }); } if (clearSearchBtn) { clearSearchBtn.addEventListener('click', async () => { if (await confirmAction('Clear search history?')) { await clearSearchHistory(); } }); } } async function confirmAction(message) { // Simple confirmation using toast - could be enhanced with a modal showToast(message + ' Press A to confirm.'); return true; // For now, auto-confirm. Could implement modal confirmation. } async function clearAllBrowsingData() { try { showToast('Clearing all browsing data...'); if (ipcRenderer && ipcRenderer.invoke) { await ipcRenderer.invoke('clear-browser-data'); } // Also clear localStorage localStorage.removeItem('siteHistory'); state.history = []; renderHistory(); renderRecentSites(); showToast('All browsing data cleared'); playSelectSound(); } catch (err) { console.error('[BigPicture] Failed to clear browsing data:', err); showToast('Failed to clear data'); } } async function clearBrowsingHistory() { try { if (ipcRenderer && ipcRenderer.invoke) { await ipcRenderer.invoke('clear-site-history'); } localStorage.removeItem('siteHistory'); state.history = []; renderHistory(); renderRecentSites(); showToast('Browsing history cleared'); playSelectSound(); } catch (err) { console.error('[BigPicture] Failed to clear history:', err); showToast('Failed to clear history'); } } async function clearSearchHistory() { try { if (ipcRenderer && ipcRenderer.invoke) { await ipcRenderer.invoke('clear-search-history'); } showToast('Search history cleared'); playSelectSound(); } catch (err) { console.error('[BigPicture] Failed to clear search history:', err); showToast('Failed to clear search history'); } } async function initAboutPanel() { // Load version info try { if (ipcRenderer && ipcRenderer.invoke) { const appInfo = await ipcRenderer.invoke('get-app-info'); if (appInfo) { const versionEl = document.getElementById('bp-version'); const electronEl = document.getElementById('bp-electron-version'); const chromiumEl = document.getElementById('bp-chromium-version'); const nodeEl = document.getElementById('bp-node-version'); const platformEl = document.getElementById('bp-platform'); if (versionEl) versionEl.textContent = `Version ${appInfo.version || 'Unknown'}`; if (electronEl) electronEl.textContent = appInfo.electron || '--'; if (chromiumEl) chromiumEl.textContent = appInfo.chrome || '--'; if (nodeEl) nodeEl.textContent = appInfo.node || '--'; if (platformEl) platformEl.textContent = `${appInfo.platform || ''} ${appInfo.arch || ''}`.trim() || '--'; } } } catch (err) { console.warn('[BigPicture] Failed to load app info:', err); } // GitHub link const githubBtn = document.getElementById('bp-github-link'); if (githubBtn) { githubBtn.addEventListener('click', () => { navigateTo('https://github.com/Bobbybear007/NebulaBrowser'); }); } // Copy diagnostics const copyBtn = document.getElementById('bp-copy-diagnostics'); if (copyBtn) { copyBtn.addEventListener('click', async () => { await copyDiagnostics(); }); } } async function copyDiagnostics() { try { const versionEl = document.getElementById('bp-version'); const electronEl = document.getElementById('bp-electron-version'); const chromiumEl = document.getElementById('bp-chromium-version'); const nodeEl = document.getElementById('bp-node-version'); const platformEl = document.getElementById('bp-platform'); const diagnostics = [ 'Nebula Browser Diagnostics', '========================', versionEl ? versionEl.textContent : '', `Electron: ${electronEl ? electronEl.textContent : '--'}`, `Chromium: ${chromiumEl ? chromiumEl.textContent : '--'}`, `Node.js: ${nodeEl ? nodeEl.textContent : '--'}`, `Platform: ${platformEl ? platformEl.textContent : '--'}`, `Date: ${new Date().toISOString()}` ].join('\n'); await navigator.clipboard.writeText(diagnostics); showToast('Diagnostics copied to clipboard'); playSelectSound(); } catch (err) { console.error('[BigPicture] Failed to copy diagnostics:', err); showToast('Failed to copy diagnostics'); } } // ============================================================================= // 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');