const SEARCH_URL = 'https://www.google.com/search?q=';
const BOOKMARKS_KEY = 'nebula-bigpicture-bookmarks';
const DISPLAY_SCALE_KEY = 'nebula-display-scale';
const POINTER_DEADZONE = 0.14;
const POINTER_BASE_SPEED = 7;
const POINTER_ACCELERATION = 24;
const PAGE_SCROLL_SPEED = 80;
const 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: 'Wikipedia', url: 'https://www.wikipedia.org', icon: 'school' },
{ title: 'Netflix', url: 'https://www.netflix.com', icon: 'movie' },
{ title: 'GitHub', url: 'https://github.com', icon: 'code' },
];
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' } },
};
const state = {
currentSection: 'home',
focusedElement: null,
focusableElements: [],
focusIndex: 0,
gamepadIndex: null,
lastInput: {},
oskVisible: false,
oskMode: 'search',
oskContext: null,
tabs: [],
history: [],
bookmarks: [],
pointer: {
x: 500,
y: 400,
maxX: 1000,
maxY: 800,
active: false,
},
browser: {
id: 1,
url: '',
title: 'New Tab',
isLoading: false,
progress: 0,
canGoBack: false,
canGoForward: false,
favicon: '',
},
browserLayout: {
x: 0,
y: 0,
width: 1000,
height: 800,
},
currentDisplayScale: 100,
currentThemeName: 'default',
};
function postCommand(command, payload = '') {
if (window.nebulaNative && typeof window.nebulaNative.postMessage === 'function') {
window.nebulaNative.postMessage(command, String(payload));
}
}
function toNavigationUrl(input) {
const value = (input || '').trim();
if (!value) return null;
if (/^(https?:|file:|data:|blob:|chrome:|nebula:\/\/)/i.test(value)) return value;
if (value.includes('.') && !/\s/.test(value)) return `https://${value}`;
return `${SEARCH_URL}${encodeURIComponent(value)}`;
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value || '');
return div.innerHTML;
}
function getDomainFromUrl(url) {
try {
if (!url) return 'New Tab';
if (url.startsWith('nebula://')) return url.replace('nebula://', '').split('/')[0] || 'Nebula';
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url || 'New Tab';
}
}
function getFaviconUrl(url) {
try {
const parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol)) return '';
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=64`;
} catch {
return '';
}
}
function showToast(message) {
document.querySelector('.toast')?.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function updateClock() {
const now = new Date();
const timeEl = document.getElementById('bp-time');
const dateEl = document.getElementById('bp-date');
const greetingEl = document.getElementById('greeting-text');
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' });
}
if (greetingEl) {
const hour = now.getHours();
greetingEl.textContent = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening';
}
}
function initClock() {
updateClock();
setInterval(updateClock, 1000);
}
function loadBookmarks() {
try {
state.bookmarks = JSON.parse(localStorage.getItem(BOOKMARKS_KEY) || '[]');
} catch {
state.bookmarks = [];
}
}
function saveBookmarks() {
localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(state.bookmarks));
}
function renderQuickAccess() {
const grid = document.getElementById('quickAccessGrid');
if (!grid) return;
grid.innerHTML = '';
DEFAULT_QUICK_ACCESS.forEach(site => grid.appendChild(createTile(site.title, site.url, site.icon)));
grid.appendChild(createActionTile('add', 'Add Bookmark', () => startAddBookmark()));
}
function renderBookmarks() {
const grid = document.getElementById('bookmarksGrid');
if (!grid) return;
grid.innerHTML = '';
if (!state.bookmarks.length) {
grid.innerHTML = '
bookmark_borderNo bookmarks yet
Add bookmarks from Big Picture mode.
';
} else {
state.bookmarks.forEach(bookmark => grid.appendChild(createTile(bookmark.title, bookmark.url, bookmark.icon || getFaviconUrl(bookmark.url), true)));
}
grid.appendChild(createActionTile('bookmark_add', 'Add Bookmark', () => startAddBookmark()));
}
function renderHistory() {
const list = document.getElementById('historyList');
if (!list) return;
list.innerHTML = '';
if (!state.history.length) {
list.innerHTML = 'historyNo browsing history
';
return;
}
state.history.slice(0, 30).forEach(url => list.appendChild(createListItem(getDomainFromUrl(url), url)));
}
function renderDownloads() {
const list = document.getElementById('downloadsList');
if (!list) return;
list.innerHTML = `
folder_open
Downloads are managed by Chromium
Use desktop mode for detailed download management.
`;
}
function renderBrowseStatus() {
const container = document.getElementById('webview-container');
if (!container) return;
const tabs = state.tabs.length ? state.tabs : [state.browser];
container.innerHTML = `
language
${escapeHtml(state.browser.title || 'Current Page')}
${escapeHtml(state.browser.url || 'nebula://home')}
Right stick moves the on-screen pointer. RT clicks, LT right-clicks, left stick scrolls, D-pad left jumps to the sidebar, and Y opens text input.
Tabs
${tabs.map(tab => renderTabButton(tab)).join('')}
`;
container.querySelectorAll('[data-command]').forEach(button => {
button.addEventListener('click', () => postCommand(button.dataset.command));
});
container.querySelector('[data-action="search"]')?.addEventListener('click', () => openOSK('search', {
labelText: 'Search or enter URL',
initialValue: state.browser.url,
}));
container.querySelector('[data-action="bookmark-current"]')?.addEventListener('click', addBookmarkFromCurrentPage);
container.querySelector('[data-action="focus-current-page"]')?.addEventListener('click', () => showToast('The page is active in the center. Use the controller shortcuts to browse.'));
container.querySelectorAll('[data-tab-id]').forEach(button => {
button.addEventListener('click', event => {
const close = event.target.closest('[data-close-tab]');
if (close) {
postCommand('close-tab', close.dataset.closeTab);
return;
}
postCommand('activate-tab', button.dataset.tabId);
});
});
}
function renderTabButton(tab) {
const active = Number(tab.id) === Number(state.browser.id);
return `
`;
}
function createTile(title, url, icon, preferFavicon = false) {
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.focusable = '';
tile.tabIndex = 0;
tile.dataset.url = url;
const iconUrl = preferFavicon && icon ? icon : getFaviconUrl(url);
const iconHtml = iconUrl
? `
`
: `${escapeHtml(icon || 'public')}`;
tile.innerHTML = `
${iconHtml}
${escapeHtml(title)}
${escapeHtml(getDomainFromUrl(url))}
`;
tile.addEventListener('click', () => navigateTo(url));
return tile;
}
function createActionTile(icon, title, handler) {
const tile = document.createElement('div');
tile.className = 'tile add-tile';
tile.dataset.focusable = '';
tile.tabIndex = 0;
tile.innerHTML = `${icon}${escapeHtml(title)}
`;
tile.addEventListener('click', handler);
return tile;
}
function createListItem(title, url) {
const item = document.createElement('div');
item.className = 'list-item history-item';
item.dataset.focusable = '';
item.tabIndex = 0;
item.innerHTML = `
public
${escapeHtml(title)}
${escapeHtml(url)}
A
`;
item.addEventListener('click', () => navigateTo(url));
return item;
}
function initNavigation() {
document.querySelectorAll('.nav-item[data-section]').forEach(item => {
item.addEventListener('click', () => switchSection(item.dataset.section));
});
document.getElementById('exitBigPicture')?.addEventListener('click', exitBigPictureMode);
document.querySelector('.search-card')?.addEventListener('click', () => openOSK('search'));
document.getElementById('bp-search')?.addEventListener('focus', () => openOSK('search'));
document.getElementById('addBookmarkBtn')?.addEventListener('click', () => startAddBookmark());
document.getElementById('addCurrentBookmarkBtn')?.addEventListener('click', addBookmarkFromCurrentPage);
document.getElementById('bp-exit-desktop')?.addEventListener('click', exitBigPictureMode);
document.getElementById('bp-scale-down')?.addEventListener('click', () => adjustDisplayScale(-10));
document.getElementById('bp-scale-up')?.addEventListener('click', () => adjustDisplayScale(10));
document.querySelectorAll('#bp-clear-history, #bp-clear-history-settings').forEach(button => {
button.addEventListener('click', clearHistory);
});
document.getElementById('bp-clear-search')?.addEventListener('click', () => showToast('Search history cleared'));
document.getElementById('bp-clear-data')?.addEventListener('click', clearHistory);
document.getElementById('bp-github-link')?.addEventListener('click', () => navigateTo('https://github.com/Bobbybear007/NebulaBrowser'));
document.getElementById('bp-copy-diagnostics')?.addEventListener('click', copyDiagnostics);
document.getElementById('launchNebot')?.addEventListener('click', () => showToast('NeBot is available in desktop mode for now'));
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => switchSettingsTab(tab.dataset.settingsTab));
});
document.querySelectorAll('.theme-card').forEach(card => {
card.addEventListener('click', () => selectTheme(card.dataset.theme));
});
}
function switchSection(sectionId) {
document.querySelectorAll('.nav-item[data-section]').forEach(item => {
item.classList.toggle('active', item.dataset.section === sectionId);
});
document.querySelectorAll('.bp-section').forEach(section => {
section.classList.toggle('active', section.id === `section-${sectionId}`);
});
const webviewContainer = document.getElementById('webview-container');
webviewContainer?.classList.toggle('hidden', sectionId !== 'browse');
document.body.classList.toggle('browse-active', sectionId === 'browse');
document.getElementById('browser-stage-frame')?.classList.toggle('hidden', sectionId !== 'browse');
document.getElementById('virtual-cursor')?.classList.toggle('hidden', sectionId !== 'browse');
postCommand('bigpicture-browse-visible', sectionId === 'browse' ? '1' : '0');
state.currentSection = sectionId;
if (sectionId === 'bookmarks') renderBookmarks();
if (sectionId === 'history') renderHistory();
if (sectionId === 'downloads') renderDownloads();
if (sectionId === 'browse') renderBrowseStatus();
setTimeout(() => {
updateFocusableElements();
focusFirstInContent();
updateVirtualCursor();
updateControllerHints();
}, 50);
}
function clampPointer() {
state.pointer.x = Math.max(0, Math.min(state.pointer.maxX, state.pointer.x));
state.pointer.y = Math.max(0, Math.min(state.pointer.maxY, state.pointer.y));
}
function pointerScreenPosition() {
return {
x: state.browserLayout.x + state.pointer.x,
y: state.browserLayout.y + state.pointer.y,
};
}
function updateVirtualCursor() {
clampPointer();
const cursor = document.getElementById('virtual-cursor');
if (!cursor) return;
const visible = state.currentSection === 'browse' && state.browserLayout.width > 0 && state.browserLayout.height > 0;
cursor.classList.toggle('hidden', !visible);
if (!visible) return;
const screen = pointerScreenPosition();
cursor.style.setProperty('--virtual-cursor-x', `${screen.x}px`);
cursor.style.setProperty('--virtual-cursor-y', `${screen.y}px`);
}
function animateCursorClick(rightClick = false) {
const cursor = document.getElementById('virtual-cursor');
if (!cursor) return;
const className = rightClick ? 'right-clicking' : 'clicking';
cursor.classList.remove('clicking', 'right-clicking');
void cursor.offsetWidth;
cursor.classList.add(className);
setTimeout(() => cursor.classList.remove(className), 220);
}
function sendPointerMove() {
clampPointer();
updateVirtualCursor();
postCommand('bigpicture-mouse-move', `${Math.round(state.pointer.x)},${Math.round(state.pointer.y)}`);
}
function clickPage(rightClick = false) {
clampPointer();
updateVirtualCursor();
animateCursorClick(rightClick);
postCommand(
rightClick ? 'bigpicture-right-click' : 'bigpicture-click',
`${Math.round(state.pointer.x)},${Math.round(state.pointer.y)}`
);
}
function scrollPage(deltaX, deltaY) {
clampPointer();
postCommand(
'bigpicture-scroll',
`${Math.round(deltaX)},${Math.round(deltaY)},${Math.round(state.pointer.x)},${Math.round(state.pointer.y)}`
);
}
function updateFocusableElements() {
const root = state.oskVisible
? document.getElementById('osk-overlay')
: document;
state.focusableElements = [...(root?.querySelectorAll('[data-focusable]:not([disabled])') || [])]
.filter(element => element.offsetParent !== null || element === document.activeElement);
}
function focusElement(element) {
if (!element) return;
state.focusedElement?.classList.remove('focused');
element.classList.add('focused');
element.focus({ preventScroll: true });
element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
state.focusedElement = element;
state.focusIndex = state.focusableElements.indexOf(element);
updateControllerHints();
}
function focusFirstElement() {
focusFirstInContent();
}
function focusFirstInContent() {
const activeSection = document.querySelector('.bp-section.active');
const browseFirst = state.currentSection === 'browse'
? document.querySelector('#webview-container [data-focusable]:not([disabled])')
: null;
const first = browseFirst ||
activeSection?.querySelector('[data-focusable]:not([disabled])') ||
document.querySelector('.bp-sidebar [data-focusable]:not([disabled])');
updateFocusableElements();
focusElement(first || state.focusableElements[0]);
}
function navigateFocus(direction) {
updateFocusableElements();
if (!state.focusableElements.length) return;
const current = state.focusedElement || state.focusableElements[0];
const currentRect = current.getBoundingClientRect();
const currentCenter = {
x: currentRect.left + currentRect.width / 2,
y: currentRect.top + currentRect.height / 2,
};
let best = null;
let bestScore = Infinity;
state.focusableElements.forEach(element => {
if (element === current) return;
const rect = element.getBoundingClientRect();
const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
const dx = center.x - currentCenter.x;
const dy = center.y - currentCenter.y;
const valid =
(direction === 'up' && dy < -8) ||
(direction === 'down' && dy > 8) ||
(direction === 'left' && dx < -8) ||
(direction === 'right' && dx > 8);
if (!valid) return;
const primary = direction === 'left' || direction === 'right' ? Math.abs(dx) : Math.abs(dy);
const secondary = direction === 'left' || direction === 'right' ? Math.abs(dy) : Math.abs(dx);
const score = primary + secondary * 2;
if (score < bestScore) {
bestScore = score;
best = element;
}
});
if (best) focusElement(best);
}
function activateFocused() {
state.focusedElement?.click();
}
function focusSidebar() {
updateFocusableElements();
const activeNav = document.querySelector(`.bp-sidebar .nav-item[data-section="${state.currentSection}"]`);
const firstNav = document.querySelector('.bp-sidebar [data-focusable]:not([disabled])');
focusElement(activeNav || firstNav);
showToast('Sidebar focused');
}
function focusBrowsePanel() {
if (state.currentSection !== 'browse') {
switchSection('browse');
return;
}
updateFocusableElements();
const pageCard = document.querySelector('[data-action="focus-current-page"]');
focusElement(pageCard || document.querySelector('#webview-container [data-focusable]:not([disabled])'));
}
function focusedInSidebar() {
return !!state.focusedElement?.closest?.('.bp-sidebar');
}
function updateControllerHints() {
const browseMode = state.currentSection === 'browse' && !state.oskVisible;
const labels = {
navigate: browseMode && !focusedInSidebar() ? 'Left stick scroll, D-pad left sidebar' : 'Navigate',
a: browseMode && !focusedInSidebar() ? 'Select focused UI' : 'Select',
b: browseMode ? 'Page Back' : 'Back',
y: browseMode ? 'Type' : 'Search',
menu: browseMode ? 'View Sidebar' : 'Menu',
};
Object.entries(labels).forEach(([key, value]) => {
const element = document.getElementById(`hint-${key}`);
if (element) element.textContent = value;
});
}
function goBack() {
if (state.oskVisible) {
closeOSK();
return;
}
if (state.currentSection === 'browse') {
postCommand('back');
return;
}
if (state.currentSection !== 'home') {
switchSection('home');
}
}
function initKeyboardShortcuts() {
document.addEventListener('keydown', event => {
if (state.oskVisible) {
handleOSKKeyboard(event);
return;
}
if (event.key === 'ArrowUp') { event.preventDefault(); navigateFocus('up'); }
if (event.key === 'ArrowDown') { event.preventDefault(); navigateFocus('down'); }
if (event.key === 'ArrowLeft') { event.preventDefault(); navigateFocus('left'); }
if (event.key === 'ArrowRight') { event.preventDefault(); navigateFocus('right'); }
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activateFocused(); }
if (event.key === 'Escape' || event.key === 'Backspace') { event.preventDefault(); goBack(); }
});
}
function initGamepadSupport() {
updateControllerStatus(!!activeGamepad());
if (!navigator.getGamepads) return;
window.addEventListener('gamepadconnected', event => {
state.gamepadIndex = event.gamepad.index;
updateControllerStatus(true);
showToast('Controller connected');
});
window.addEventListener('gamepaddisconnected', event => {
if (state.gamepadIndex === event.gamepad.index) state.gamepadIndex = null;
updateControllerStatus(!!activeGamepad());
showToast('Controller disconnected');
});
requestAnimationFrame(pollGamepad);
}
function updateControllerStatus(connected) {
const status = document.getElementById('bp-controller-status');
if (!status) return;
status.classList.toggle('connected', connected);
status.classList.toggle('disconnected', !connected);
status.title = connected ? 'Controller connected' : 'Controller disconnected';
}
function activeGamepad() {
const gamepads = navigator.getGamepads?.() || [];
if (state.gamepadIndex !== null && gamepads[state.gamepadIndex]) return gamepads[state.gamepadIndex];
return [...gamepads].find(Boolean) || null;
}
function pollGamepad() {
const gamepad = activeGamepad();
if (gamepad) handleGamepadInput(gamepad);
requestAnimationFrame(pollGamepad);
}
function pressed(gamepad, index) {
return !!gamepad.buttons[index]?.pressed;
}
function once(gamepad, key, active, handler) {
if (active && !state.lastInput[key]) {
handler();
state.lastInput[key] = true;
} else if (!active) {
state.lastInput[key] = false;
}
}
function handleGamepadInput(gamepad) {
const deadzone = 0.35;
const up = pressed(gamepad, 12) || (gamepad.axes[1] || 0) < -deadzone;
const down = pressed(gamepad, 13) || (gamepad.axes[1] || 0) > deadzone;
const left = pressed(gamepad, 14) || (gamepad.axes[0] || 0) < -deadzone;
const right = pressed(gamepad, 15) || (gamepad.axes[0] || 0) > deadzone;
const browseMode = state.currentSection === 'browse' && !state.oskVisible;
const pageControlMode = browseMode && !focusedInSidebar();
if (pageControlMode) {
const rightX = gamepad.axes[2] || 0;
const rightY = gamepad.axes[3] || 0;
if (Math.abs(rightX) > POINTER_DEADZONE || Math.abs(rightY) > POINTER_DEADZONE) {
const speed = POINTER_BASE_SPEED + Math.min(1, Math.hypot(rightX, rightY)) * POINTER_ACCELERATION;
state.pointer.x += rightX * speed;
state.pointer.y += rightY * speed;
state.pointer.active = true;
sendPointerMove();
}
const scrollX = Math.abs(gamepad.axes[0] || 0) > 0.25 ? gamepad.axes[0] : 0;
const scrollY = Math.abs(gamepad.axes[1] || 0) > 0.25 ? gamepad.axes[1] : 0;
if (scrollX || scrollY) {
scrollPage(scrollX * -PAGE_SCROLL_SPEED, scrollY * -PAGE_SCROLL_SPEED);
}
once(gamepad, 'browse-dpad-left', pressed(gamepad, 14), focusSidebar);
once(gamepad, 'browse-view', pressed(gamepad, 8), focusSidebar);
} else if (browseMode && focusedInSidebar()) {
once(gamepad, 'up', up, () => navigateFocus('up'));
once(gamepad, 'down', down, () => navigateFocus('down'));
once(gamepad, 'left', left, () => navigateFocus('left'));
once(gamepad, 'right', right || pressed(gamepad, 15), focusBrowsePanel);
} else {
once(gamepad, 'up', up, () => navigateFocus('up'));
once(gamepad, 'down', down, () => navigateFocus('down'));
once(gamepad, 'left', left, () => navigateFocus('left'));
once(gamepad, 'right', right, () => navigateFocus('right'));
}
once(gamepad, 'a', pressed(gamepad, 0), activateFocused);
once(gamepad, 'b', pressed(gamepad, 1), goBack);
once(gamepad, 'x', pressed(gamepad, 2), () => state.oskVisible ? backspaceOSK() : postCommand('reload'));
once(gamepad, 'y', pressed(gamepad, 3), () => {
if (state.oskVisible) {
appendToOSK(' ');
} else if (state.currentSection === 'browse') {
openOSK('page-text', { labelText: 'Type into focused page field' });
} else {
openOSK('search');
}
});
once(gamepad, 'lb', pressed(gamepad, 4), () => state.oskVisible ? clearOSK() : postCommand('back'));
once(gamepad, 'rb', pressed(gamepad, 5), () => state.oskVisible ? submitOSK() : postCommand('forward'));
once(gamepad, 'lt', pressed(gamepad, 6), () => pageControlMode ? clickPage(true) : undefined);
once(gamepad, 'rt', pressed(gamepad, 7), () => pageControlMode ? clickPage(false) : undefined);
once(gamepad, 'start', pressed(gamepad, 9), () => switchSection(state.currentSection === 'settings' ? 'home' : 'settings'));
}
function initOSK() {
const keyboard = document.getElementById('osk-keyboard');
if (!keyboard || keyboard.children.length) return;
['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm'].forEach(row => {
const rowEl = document.createElement('div');
rowEl.className = 'osk-row';
[...row].forEach(char => rowEl.appendChild(createOskKey(char)));
keyboard.appendChild(rowEl);
});
const specialRow = document.createElement('div');
specialRow.className = 'osk-row';
['.', '-', '_', '@', '/', ':', '.com'].forEach(char => specialRow.appendChild(createOskKey(char, char === '.com')));
keyboard.appendChild(specialRow);
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);
document.querySelector('.osk-close')?.addEventListener('click', closeOSK);
}
function createOskKey(char, wide = false) {
const key = document.createElement('button');
key.className = `osk-key${wide ? ' wide' : ''}`;
key.textContent = char;
key.dataset.focusable = '';
key.tabIndex = 0;
key.addEventListener('click', () => appendToOSK(char));
return key;
}
function openOSK(mode = 'search', options = {}) {
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;
input.value = options.initialValue || '';
if (label) label.textContent = options.labelText || (mode === 'search' ? 'Search or enter URL' : 'Enter text');
overlay.classList.remove('hidden');
updateOSKCursorPosition();
setTimeout(() => {
updateFocusableElements();
focusElement(overlay.querySelector('.osk-key, [data-focusable]'));
}, 50);
}
function closeOSK() {
state.oskVisible = false;
document.getElementById('osk-overlay')?.classList.add('hidden');
setTimeout(() => {
updateFocusableElements();
focusFirstInContent();
}, 50);
}
function appendToOSK(char) {
const input = document.getElementById('osk-input');
if (!input) return;
input.value += char;
updateOSKCursorPosition();
}
function backspaceOSK() {
const input = document.getElementById('osk-input');
if (!input) return;
input.value = input.value.slice(0, -1);
updateOSKCursorPosition();
}
function clearOSK() {
const input = document.getElementById('osk-input');
if (!input) return;
input.value = '';
updateOSKCursorPosition();
}
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;
measure.textContent = input.value || '';
cursor.style.left = `${32 + measure.offsetWidth}px`;
}
function submitOSK() {
const value = document.getElementById('osk-input')?.value || '';
if (state.oskMode === 'search') {
const target = toNavigationUrl(value);
if (target) navigateTo(target);
} else if (state.oskMode === 'page-text') {
postCommand('bigpicture-text', value);
} else if (state.oskMode === 'bookmark-url') {
const target = toNavigationUrl(value);
if (!target) {
showToast('Enter a URL first');
return;
}
state.oskContext = { url: target };
openOSK('bookmark-title', { labelText: 'Bookmark title', initialValue: getDomainFromUrl(target) });
return;
} else if (state.oskMode === 'bookmark-title') {
const url = state.oskContext?.url;
if (url) addBookmark({ title: value || getDomainFromUrl(url), url });
state.oskContext = null;
}
closeOSK();
}
function handleOSKKeyboard(event) {
if (event.key === 'Escape') { event.preventDefault(); closeOSK(); return; }
if (event.key === 'Enter') { event.preventDefault(); submitOSK(); return; }
if (event.key === 'Backspace') { event.preventDefault(); backspaceOSK(); return; }
if (event.key.length === 1) { event.preventDefault(); appendToOSK(event.key); }
}
function navigateTo(url) {
postCommand('navigate', url);
switchSection('browse');
}
function startAddBookmark() {
state.oskContext = null;
openOSK('bookmark-url', { labelText: 'Bookmark URL' });
}
function addBookmarkFromCurrentPage() {
const url = state.browser.url;
if (!url) {
showToast('No active page to bookmark');
return;
}
addBookmark({ title: state.browser.title || getDomainFromUrl(url), url });
}
function addBookmark(bookmark) {
const existing = state.bookmarks.findIndex(item => item.url === bookmark.url);
const entry = { title: bookmark.title || getDomainFromUrl(bookmark.url), url: bookmark.url, icon: getFaviconUrl(bookmark.url) };
if (existing >= 0) state.bookmarks[existing] = entry;
else state.bookmarks.unshift(entry);
saveBookmarks();
renderBookmarks();
updateFocusableElements();
showToast(existing >= 0 ? 'Bookmark updated' : 'Bookmark added');
}
function clearHistory() {
postCommand('clear-site-history');
state.history = [];
renderBrowseStatus();
renderHistory();
showToast('History cleared');
}
function switchSettingsTab(tabName) {
if (!tabName) return;
document.querySelectorAll('.settings-tab').forEach(tab => tab.classList.toggle('active', tab.dataset.settingsTab === tabName));
document.querySelectorAll('.settings-panel').forEach(panel => panel.classList.toggle('active', panel.id === `settings-panel-${tabName}`));
setTimeout(() => {
updateFocusableElements();
focusFirstInContent();
}, 50);
}
function applyTheme(theme) {
if (!theme?.colors) return;
const root = document.documentElement;
root.style.setProperty('--bp-bg', theme.colors.bg);
root.style.setProperty('--bp-surface', theme.colors.darkPurple);
root.style.setProperty('--bp-primary', theme.colors.primary);
root.style.setProperty('--bp-primary-glow', `${theme.colors.primary}66`);
root.style.setProperty('--bp-accent', theme.colors.accent);
root.style.setProperty('--bp-accent-glow', `${theme.colors.accent}4d`);
root.style.setProperty('--bp-text', theme.colors.text);
}
function selectTheme(themeName) {
const theme = THEMES[themeName];
if (!theme) return;
state.currentThemeName = themeName;
localStorage.setItem('nebula-theme-name', themeName);
localStorage.setItem('browserTheme', JSON.stringify({
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,
},
}));
applyTheme(theme);
highlightActiveTheme();
showToast(`Theme changed to ${theme.name}`);
}
function highlightActiveTheme() {
document.querySelectorAll('.theme-card').forEach(card => card.classList.toggle('active', card.dataset.theme === state.currentThemeName));
}
function loadSavedSettings() {
const savedTheme = localStorage.getItem('nebula-theme-name');
if (savedTheme && THEMES[savedTheme]) {
state.currentThemeName = savedTheme;
applyTheme(THEMES[savedTheme]);
}
const savedScale = parseInt(localStorage.getItem(DISPLAY_SCALE_KEY) || '100', 10);
if (Number.isFinite(savedScale)) {
state.currentDisplayScale = Math.min(300, Math.max(50, savedScale));
applyDisplayScale();
}
highlightActiveTheme();
}
function adjustDisplayScale(delta) {
state.currentDisplayScale = Math.min(300, Math.max(50, state.currentDisplayScale + delta));
localStorage.setItem(DISPLAY_SCALE_KEY, String(state.currentDisplayScale));
applyDisplayScale();
showToast(`Display scale: ${state.currentDisplayScale}%`);
}
function applyDisplayScale() {
const scale = state.currentDisplayScale / 100;
document.documentElement.style.setProperty('--bp-scale-factor', String(scale));
document.body.style.zoom = scale;
const scaleValue = document.getElementById('bp-scale-value');
if (scaleValue) scaleValue.textContent = `${state.currentDisplayScale}%`;
}
async function copyDiagnostics() {
const diagnostics = [
'Nebula Browser Diagnostics',
`Mode: Big Picture`,
`Title: ${state.browser.title}`,
`URL: ${state.browser.url}`,
`Tabs: ${state.tabs.length}`,
`Date: ${new Date().toISOString()}`,
].join('\n');
try {
await navigator.clipboard.writeText(diagnostics);
showToast('Diagnostics copied');
} catch {
showToast(diagnostics);
}
}
function exitBigPictureMode() {
postCommand('exit-bigpicture');
}
function applyState(nextState = {}) {
Object.assign(state.browser, {
id: nextState.id ?? state.browser.id,
url: nextState.url ?? state.browser.url,
title: nextState.title ?? state.browser.title,
isLoading: !!nextState.isLoading,
progress: Number(nextState.progress || 0),
canGoBack: !!nextState.canGoBack,
canGoForward: !!nextState.canGoForward,
favicon: nextState.favicon || '',
});
if (Array.isArray(nextState.tabs)) state.tabs = nextState.tabs;
if (Array.isArray(nextState.history)) state.history = nextState.history;
if (nextState.browserLayout) applyBrowserLayout(nextState.browserLayout);
const search = document.getElementById('bp-search');
if (search && document.activeElement !== search) search.value = state.browser.url || '';
renderBrowseStatus();
renderHistory();
updateFocusableElements();
}
function applyBrowserLayout(layout) {
const width = Number(layout.width) || 0;
const height = Number(layout.height) || 0;
if (width <= 0 || height <= 0) return;
const previousMaxX = state.pointer.maxX;
const previousMaxY = state.pointer.maxY;
state.browserLayout = {
x: Math.max(0, Number(layout.x) || 0),
y: Math.max(0, Number(layout.y) || 0),
width,
height,
};
state.pointer.maxX = width - 1;
state.pointer.maxY = height - 1;
if (previousMaxX <= 0 || previousMaxY <= 0) {
state.pointer.x = state.pointer.maxX / 2;
state.pointer.y = state.pointer.maxY / 2;
} else {
state.pointer.x = Math.min(state.pointer.x, state.pointer.maxX);
state.pointer.y = Math.min(state.pointer.y, state.pointer.maxY);
}
const root = document.documentElement;
root.style.setProperty('--browser-stage-x', `${state.browserLayout.x}px`);
root.style.setProperty('--browser-stage-y', `${state.browserLayout.y}px`);
root.style.setProperty('--browser-stage-width', `${state.browserLayout.width}px`);
root.style.setProperty('--browser-stage-height', `${state.browserLayout.height}px`);
updateVirtualCursor();
}
function initMouseTracking() {
let timeout = null;
document.addEventListener('mousemove', () => {
document.body.classList.add('mouse-active');
clearTimeout(timeout);
timeout = setTimeout(() => document.body.classList.remove('mouse-active'), 3000);
});
document.addEventListener('mouseover', event => {
const focusable = event.target.closest('[data-focusable]');
if (focusable && state.focusableElements.includes(focusable)) focusElement(focusable);
});
}
window.NebulaBigPicture = { applyState, postCommand };
document.addEventListener('DOMContentLoaded', () => {
initClock();
initNavigation();
initKeyboardShortcuts();
initGamepadSupport();
initMouseTracking();
initOSK();
loadBookmarks();
loadSavedSettings();
renderQuickAccess();
renderBookmarks();
renderDownloads();
renderBrowseStatus();
updateVirtualCursor();
updateControllerHints();
setTimeout(focusFirstElement, 100);
});