Files
NebulaBrowser/renderer/bigpicture.js
T
andrew 3d538a09f9 Add sidebar toggle and fullscreen webview support
Implements a sidebar hidden state and toggle functionality for fullscreen webview mode, including new CSS for sidebar transitions and a menu hint. Updates gamepad input handling to allow toggling the sidebar and scrolling in webview mode, and restricts focusable elements to the sidebar and header when browsing. Improves user experience when navigating and interacting with web content in fullscreen.
2025-12-27 23:27:03 +13:00

1523 lines
42 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,
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');
if (!overlay || !input) return;
state.oskVisible = true;
state.oskMode = mode;
overlay.classList.remove('hidden');
// Clear input
input.value = '';
// Update focusable elements to only include OSK keys
updateFocusableElements();
// Focus first key
setTimeout(() => {
const firstKey = overlay.querySelector('.osk-key');
if (firstKey) {
const index = state.focusableElements.indexOf(firstKey);
if (index !== -1) {
state.focusIndex = index;
focusElement(firstKey);
} else {
firstKey.focus();
}
}
}, 100);
}
function closeOSK() {
const overlay = document.getElementById('osk-overlay');
if (!overlay) return;
state.oskVisible = false;
overlay.classList.add('hidden');
// Return focus to main content
setTimeout(() => {
updateFocusableElements();
focusFirstInContent();
}, 100);
}
function appendToOSK(char) {
const input = document.getElementById('osk-input');
if (input) {
input.value += char;
}
}
function backspaceOSK() {
const input = document.getElementById('osk-input');
if (input && input.value.length > 0) {
input.value = input.value.slice(0, -1);
playNavSound();
}
}
function clearOSK() {
const input = document.getElementById('osk-input');
if (input) {
input.value = '';
playNavSound();
}
}
function submitOSK() {
const input = document.getElementById('osk-input');
if (!input || !input.value.trim()) return;
const value = input.value.trim();
if (state.oskMode === 'search') {
performSearch(value);
}
closeOSK();
}
function handleOSKKeyboard(e) {
if (e.key === 'Escape') {
e.preventDefault();
closeOSK();
} else if (e.key === 'Enter') {
e.preventDefault();
submitOSK();
} else if (e.key === 'Backspace') {
backspaceOSK();
} else if (e.key.length === 1) {
appendToOSK(e.key);
}
}
// =============================================================================
// DATA LOADING
// =============================================================================
async function loadData() {
await loadBookmarks();
await loadHistory();
renderQuickAccess();
}
async function loadBookmarks() {
try {
if (ipcRenderer && ipcRenderer.invoke) {
state.bookmarks = await ipcRenderer.invoke('load-bookmarks') || [];
} else {
// Fallback to localStorage
const stored = localStorage.getItem('bookmarks');
state.bookmarks = stored ? JSON.parse(stored) : [];
}
renderBookmarks();
} catch (err) {
console.error('[BigPicture] Failed to load bookmarks:', err);
state.bookmarks = [];
}
}
async function loadHistory() {
try {
const stored = localStorage.getItem('siteHistory');
state.history = stored ? JSON.parse(stored) : [];
renderHistory();
renderRecentSites();
} catch (err) {
console.error('[BigPicture] Failed to load history:', err);
state.history = [];
}
}
// =============================================================================
// RENDERING
// =============================================================================
function renderQuickAccess() {
const grid = document.getElementById('quickAccessGrid');
if (!grid) return;
grid.innerHTML = '';
CONFIG.DEFAULT_QUICK_ACCESS.forEach(site => {
const tile = createTile(site.title, site.url, site.icon);
grid.appendChild(tile);
});
// Add "Add" tile
const addTile = document.createElement('div');
addTile.className = 'tile add-tile';
addTile.dataset.focusable = '';
addTile.tabIndex = 0;
addTile.innerHTML = `<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;
// Enable virtual cursor for webview interaction
enableCursor();
// Switch section to browse
switchSection('browse');
// Update focusable elements to include webview controls
setTimeout(() => {
updateFocusableElements();
}, 100);
}
function exitBigPictureMode() {
console.log('[BigPicture] Exiting Big Picture Mode');
if (ipcRenderer) {
ipcRenderer.send('exit-bigpicture');
} else if (window.opener) {
window.opener.postMessage({ type: 'exit-bigpicture' }, '*');
window.close();
}
}
function handleSettingsAction(action) {
switch (action) {
case 'theme':
showToast('Theme settings coming soon');
break;
case 'privacy':
showToast('Privacy settings coming soon');
break;
case 'display':
showToast('Display settings coming soon');
break;
case 'exit-bigpicture':
exitBigPictureMode();
break;
default:
console.log('[BigPicture] Unknown settings action:', action);
}
}
// =============================================================================
// UTILITIES
// =============================================================================
function isUrl(str) {
// Simple URL detection
return /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/.test(str) ||
str.includes('.com') ||
str.includes('.org') ||
str.includes('.net') ||
str.includes('.io') ||
str.startsWith('browser://');
}
// =============================================================================
// VIRTUAL CURSOR (for webview interaction)
// =============================================================================
function createCursorElement() {
if (state.cursorElement) return;
const cursor = document.createElement('div');
cursor.id = 'virtual-cursor';
cursor.className = 'virtual-cursor';
cursor.innerHTML = `
<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 = state.cursorX - containerRect.left;
const y = state.cursorY - containerRect.top;
// Show click animation
if (state.cursorElement) {
state.cursorElement.classList.add('clicking');
setTimeout(() => state.cursorElement.classList.remove('clicking'), 150);
}
// Send mouse event to webview
try {
const webContents = state.currentWebview;
// Use executeJavaScript to simulate click at coordinates
const clickScript = rightClick ? `
(function() {
const el = document.elementFromPoint(${x}, ${y});
if (el) {
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: ${x},
clientY: ${y},
button: 2
});
el.dispatchEvent(event);
}
})();
` : `
(function() {
const el = document.elementFromPoint(${x}, ${y});
if (el) {
// Try to focus if it's an input
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.contentEditable === 'true') {
el.focus();
}
// Simulate full click sequence
const rect = el.getBoundingClientRect();
const events = ['mousedown', 'mouseup', 'click'];
events.forEach(type => {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
clientX: ${x},
clientY: ${y},
button: 0
});
el.dispatchEvent(event);
});
// Also try clicking directly for links and buttons
if (el.click) el.click();
}
})();
`;
webContents.executeJavaScript(clickScript).catch(err => {
console.log('[BigPicture] Click injection error:', err);
});
} catch (err) {
console.log('[BigPicture] Virtual click error:', err);
}
}
function scrollWebview(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');