Files
NebulaBrowser/renderer/bigpicture.js
T
andrew 8a2b7ee5e9 Enhance Big Picture Mode OSK and webview input support
Adds native input event injection for webviews via IPC, improves the on-screen keyboard (OSK) UI with a blinking cursor and label, and enables seamless text entry into webview input fields. Also refines virtual cursor click handling for better compatibility with complex sites and video players.
2025-12-28 10:35:59 +13:00

1888 lines
56 KiB
JavaScript

/**
* 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 = `<span class="material-symbols-outlined">add</span>`;
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 = `
<div class="empty-state">
<span class="material-symbols-outlined">bookmark_border</span>
<p>No bookmarks yet</p>
</div>
`;
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 = `
<div class="empty-state">
<span class="material-symbols-outlined">history</span>
<p>No browsing history</p>
</div>
`;
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 = `
<div class="empty-state">
<span class="material-symbols-outlined">web</span>
<p>Start browsing to see recent sites</p>
</div>
`;
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 = `
<div class="tile-icon">
<span class="material-symbols-outlined">${icon}</span>
</div>
<div class="tile-title">${escapeHtml(title)}</div>
<div class="tile-url">${getDomainFromUrl(url)}</div>
`;
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 = `
<div class="list-item-icon">
<span class="material-symbols-outlined">public</span>
</div>
<div class="list-item-content">
<div class="list-item-title">${escapeHtml(title)}</div>
<div class="list-item-meta">${escapeHtml(url)}</div>
</div>
<div class="list-item-action">
<span class="key-hint">A</span>
</div>
`;
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 = `
<div class="scroll-card-preview">
<span class="material-symbols-outlined" style="font-size: 48px; color: var(--bp-text-dim); display: flex; align-items: center; justify-content: center; height: 100%;">public</span>
</div>
<div class="scroll-card-title">${escapeHtml(title)}</div>
<div class="scroll-card-meta">Recently visited</div>
`;
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 = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35Z"
fill="white" stroke="black" stroke-width="1.5"/>
</svg>
<div class="cursor-click-indicator"></div>
`;
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');