From d6f15c5dced711d4d803ec3436b4e27ad1d655b2 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos <62979495+Bobbybear007@users.noreply.github.com> Date: Mon, 18 May 2026 22:07:41 +1200 Subject: [PATCH] Add Big Picture mode and multi-target build Introduce a Big Picture mode and support building two app targets. CMakeLists was refactored to add a helper (add_nebula_app_target) and now registers NebulaBrowser and NebulaBigPicture executables, moving UI/assets post-build copying into the helper. A new app/main_bigpicture.cpp entry was added and RunNebula now accepts LaunchOptions (AppMode) with a new LaunchOptions struct. NebulaController was extended heavily to manage a BigPicture browser role: creation, enter/exit mode, layout logic, cursor injection/removal, remote input handlers (mouse move/click/wheel/text), and state syncing. Cef/browser_client updated for the new BigPicture role and message filtering. Platform API gained MoveCursorToBrowserPoint with platform stubs/Win implementation. Big-picture UI files (CSS/JS/HTML) were also updated to support the new mode. --- CMakeLists.txt | 176 +- app/main.cpp | 4 +- app/main_bigpicture.cpp | 22 + src/app/nebula_controller.cpp | 450 ++- src/app/nebula_controller.h | 23 +- src/app/run.cpp | 4 +- src/app/run.h | 11 +- src/cef/browser_client.cpp | 33 +- src/cef/browser_client.h | 1 + src/platform/browser_host.h | 1 + src/platform/linux/browser_host_linux.cpp | 6 + src/platform/mac/browser_host_mac.cpp | 6 + src/platform/win/browser_host_win.cpp | 14 + ui/css/bigpicture.css | 238 +- ui/js/bigpicture.js | 3614 +++++---------------- ui/pages/bigpicture.html | 45 +- 16 files changed, 1745 insertions(+), 2903 deletions(-) create mode 100644 app/main_bigpicture.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 35dca5d..aee675a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,6 @@ SET_CEF_TARGET_OUT_DIR() # ------------------------------------------------------------ set(NEBULA_COMMON_SOURCES - app/main.cpp src/app/nebula_controller.cpp src/app/run.cpp src/browser/session_state.cpp @@ -54,10 +53,6 @@ if(OS_WINDOWS) src/platform/win/browser_host_win.cpp src/platform/win/nebula_window_win.cpp ) - add_executable(NebulaBrowser WIN32 - ${NEBULA_COMMON_SOURCES} - ${NEBULA_PLATFORM_SOURCES} - ) elseif(OS_MACOSX) set(NEBULA_PLATFORM_SOURCES src/platform/mac/paths_mac.cpp @@ -65,10 +60,6 @@ elseif(OS_MACOSX) src/platform/mac/browser_host_mac.cpp src/platform/mac/nebula_window_mac.cpp ) - add_executable(NebulaBrowser MACOSX_BUNDLE - ${NEBULA_COMMON_SOURCES} - ${NEBULA_PLATFORM_SOURCES} - ) elseif(OS_LINUX) set(NEBULA_PLATFORM_SOURCES src/platform/linux/paths_linux.cpp @@ -76,86 +67,111 @@ elseif(OS_LINUX) src/platform/linux/browser_host_linux.cpp src/platform/linux/nebula_window_linux.cpp ) - add_executable(NebulaBrowser - ${NEBULA_COMMON_SOURCES} - ${NEBULA_PLATFORM_SOURCES} - ) else() message(FATAL_ERROR "Unsupported platform.") endif() -SET_EXECUTABLE_TARGET_PROPERTIES(NebulaBrowser) -add_dependencies(NebulaBrowser libcef_dll_wrapper) - -target_include_directories(NebulaBrowser PRIVATE - "${CMAKE_SOURCE_DIR}/src" - "${CEF_ROOT}" - "${CEF_ROOT}/include" -) - ADD_LOGICAL_TARGET("libcef_lib" "${CEF_LIB_RELEASE}" "${CEF_LIB_DEBUG}") -target_link_libraries(NebulaBrowser PRIVATE - libcef_lib - libcef_dll_wrapper - ${CEF_STANDARD_LIBS} -) -if(MSVC) - set_property(TARGET NebulaBrowser PROPERTY - MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" - ) -endif() - -# ------------------------------------------------------------ -# Platform-specific CEF runtime deployment -# ------------------------------------------------------------ - -if(OS_WINDOWS) - target_link_libraries(NebulaBrowser PRIVATE dwmapi) - target_compile_definitions(NebulaBrowser PRIVATE - NOMINMAX - WIN32_LEAN_AND_MEAN - ) - COPY_FILES("NebulaBrowser" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR_RELEASE}" "${CEF_TARGET_OUT_DIR}") - COPY_FILES("NebulaBrowser" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${CEF_TARGET_OUT_DIR}") -elseif(OS_LINUX) +if(OS_LINUX) FIND_LINUX_LIBRARIES("X11") - COPY_FILES("NebulaBrowser" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR_RELEASE}" "${CEF_TARGET_OUT_DIR}") - COPY_FILES("NebulaBrowser" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${CEF_TARGET_OUT_DIR}") -elseif(OS_MACOSX) - set(NEBULA_APP "${CEF_TARGET_OUT_DIR}/NebulaBrowser.app") - COPY_MAC_FRAMEWORK( - "NebulaBrowser" - "Chromium Embedded Framework" - "${CEF_BINARY_DIR_RELEASE}" - "${NEBULA_APP}/Contents/Frameworks" - ) - COPY_FILES( - "NebulaBrowser" - "${CEF_BINARY_FILES}" - "${CEF_BINARY_DIR_RELEASE}" - "${NEBULA_APP}/Contents/Frameworks" - ) - COPY_FILES( - "NebulaBrowser" - "${CEF_RESOURCE_FILES}" - "${CEF_RESOURCE_DIR}" - "${NEBULA_APP}/Contents/Resources" - ) endif() -# ------------------------------------------------------------ -# Copy Nebula UI files after build -# ------------------------------------------------------------ +function(add_nebula_app_target nebula_target entry_source) + if(OS_WINDOWS) + add_executable(${nebula_target} WIN32 + ${entry_source} + ${NEBULA_COMMON_SOURCES} + ${NEBULA_PLATFORM_SOURCES} + ) + elseif(OS_MACOSX) + add_executable(${nebula_target} MACOSX_BUNDLE + ${entry_source} + ${NEBULA_COMMON_SOURCES} + ${NEBULA_PLATFORM_SOURCES} + ) + elseif(OS_LINUX) + add_executable(${nebula_target} + ${entry_source} + ${NEBULA_COMMON_SOURCES} + ${NEBULA_PLATFORM_SOURCES} + ) + endif() -add_custom_command(TARGET NebulaBrowser POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/ui" - "$/ui" + SET_EXECUTABLE_TARGET_PROPERTIES(${nebula_target}) + add_dependencies(${nebula_target} libcef_dll_wrapper) - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/assets" - "$/ui/assets" + target_include_directories(${nebula_target} PRIVATE + "${CMAKE_SOURCE_DIR}/src" + "${CEF_ROOT}" + "${CEF_ROOT}/include" + ) - COMMENT "Copying Nebula UI files and assets..." -) + target_link_libraries(${nebula_target} PRIVATE + libcef_lib + libcef_dll_wrapper + ${CEF_STANDARD_LIBS} + ) + + if(MSVC) + set_property(TARGET ${nebula_target} PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + ) + endif() + + # ------------------------------------------------------------ + # Platform-specific CEF runtime deployment + # ------------------------------------------------------------ + + if(OS_WINDOWS) + target_link_libraries(${nebula_target} PRIVATE dwmapi) + target_compile_definitions(${nebula_target} PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN + ) + COPY_FILES("${nebula_target}" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR_RELEASE}" "${CEF_TARGET_OUT_DIR}") + COPY_FILES("${nebula_target}" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${CEF_TARGET_OUT_DIR}") + elseif(OS_LINUX) + COPY_FILES("${nebula_target}" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR_RELEASE}" "${CEF_TARGET_OUT_DIR}") + COPY_FILES("${nebula_target}" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${CEF_TARGET_OUT_DIR}") + elseif(OS_MACOSX) + set(NEBULA_APP "${CEF_TARGET_OUT_DIR}/${nebula_target}.app") + COPY_MAC_FRAMEWORK( + "${nebula_target}" + "Chromium Embedded Framework" + "${CEF_BINARY_DIR_RELEASE}" + "${NEBULA_APP}/Contents/Frameworks" + ) + COPY_FILES( + "${nebula_target}" + "${CEF_BINARY_FILES}" + "${CEF_BINARY_DIR_RELEASE}" + "${NEBULA_APP}/Contents/Frameworks" + ) + COPY_FILES( + "${nebula_target}" + "${CEF_RESOURCE_FILES}" + "${CEF_RESOURCE_DIR}" + "${NEBULA_APP}/Contents/Resources" + ) + endif() + + # ------------------------------------------------------------ + # Copy Nebula UI files after build + # ------------------------------------------------------------ + + add_custom_command(TARGET ${nebula_target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/ui" + "$/ui" + + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/assets" + "$/ui/assets" + + COMMENT "Copying Nebula UI files and assets for ${nebula_target}..." + ) +endfunction() + +add_nebula_app_target(NebulaBrowser app/main.cpp) +add_nebula_app_target(NebulaBigPicture app/main_bigpicture.cpp) diff --git a/app/main.cpp b/app/main.cpp index 05abb3d..c5e7e8c 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -12,11 +12,11 @@ int APIENTRY wWinMain(HINSTANCE instance, UNREFERENCED_PARAMETER(command_line); const nebula::platform::AppStartup startup{instance, show_command}; - return nebula::app::RunNebula(startup); + return nebula::app::RunNebula(startup, {nebula::app::AppMode::Desktop}); } #else int main(int argc, char* argv[]) { const nebula::platform::AppStartup startup{argc, argv}; - return nebula::app::RunNebula(startup); + return nebula::app::RunNebula(startup, {nebula::app::AppMode::Desktop}); } #endif diff --git a/app/main_bigpicture.cpp b/app/main_bigpicture.cpp new file mode 100644 index 0000000..400444d --- /dev/null +++ b/app/main_bigpicture.cpp @@ -0,0 +1,22 @@ +#include "app/run.h" +#include "platform/types.h" + +#if defined(_WIN32) +#include + +int APIENTRY wWinMain(HINSTANCE instance, + HINSTANCE previous_instance, + LPWSTR command_line, + int show_command) { + UNREFERENCED_PARAMETER(previous_instance); + UNREFERENCED_PARAMETER(command_line); + + const nebula::platform::AppStartup startup{instance, show_command}; + return nebula::app::RunNebula(startup, {nebula::app::AppMode::BigPicture}); +} +#else +int main(int argc, char* argv[]) { + const nebula::platform::AppStartup startup{argc, argv}; + return nebula::app::RunNebula(startup, {nebula::app::AppMode::BigPicture}); +} +#endif diff --git a/src/app/nebula_controller.cpp b/src/app/nebula_controller.cpp index c209920..1987aae 100644 --- a/src/app/nebula_controller.cpp +++ b/src/app/nebula_controller.cpp @@ -94,6 +94,54 @@ int ParseTabId(const std::string& value) { return result.ec == std::errc{} && result.ptr == value.data() + value.size() ? tab_id : 0; } +bool ParseTwoInts(const std::string& value, int& first, int& second) { + const size_t separator = value.find(','); + if (separator == std::string::npos) { + return false; + } + + const std::string first_value = value.substr(0, separator); + const std::string second_value = value.substr(separator + 1); + const auto first_result = + std::from_chars(first_value.data(), first_value.data() + first_value.size(), first); + const auto second_result = + std::from_chars(second_value.data(), second_value.data() + second_value.size(), second); + return first_result.ec == std::errc{} && first_result.ptr == first_value.data() + first_value.size() && + second_result.ec == std::errc{} && second_result.ptr == second_value.data() + second_value.size(); +} + +bool ParseFourInts(const std::string& value, int& first, int& second, int& third, int& fourth) { + const size_t first_separator = value.find(','); + if (first_separator == std::string::npos) { + return false; + } + const size_t second_separator = value.find(',', first_separator + 1); + if (second_separator == std::string::npos) { + return false; + } + const size_t third_separator = value.find(',', second_separator + 1); + if (third_separator == std::string::npos) { + return false; + } + + const std::string first_value = value.substr(0, first_separator); + const std::string second_value = value.substr(first_separator + 1, second_separator - first_separator - 1); + const std::string third_value = value.substr(second_separator + 1, third_separator - second_separator - 1); + const std::string fourth_value = value.substr(third_separator + 1); + const auto first_result = + std::from_chars(first_value.data(), first_value.data() + first_value.size(), first); + const auto second_result = + std::from_chars(second_value.data(), second_value.data() + second_value.size(), second); + const auto third_result = + std::from_chars(third_value.data(), third_value.data() + third_value.size(), third); + const auto fourth_result = + std::from_chars(fourth_value.data(), fourth_value.data() + fourth_value.size(), fourth); + return first_result.ec == std::errc{} && first_result.ptr == first_value.data() + first_value.size() && + second_result.ec == std::errc{} && second_result.ptr == second_value.data() + second_value.size() && + third_result.ec == std::errc{} && third_result.ptr == third_value.data() + third_value.size() && + fourth_result.ec == std::errc{} && fourth_result.ptr == fourth_value.data() + fourth_value.size(); +} + std::string WithCacheBuster(std::string url) { if (url.empty()) { return url; @@ -124,9 +172,12 @@ void SetBrowserVisible(CefRefPtr browser, bool visible) { } // namespace -NebulaController::NebulaController(nebula::platform::AppStartup startup, std::string initial_url) +NebulaController::NebulaController(nebula::platform::AppStartup startup, + std::string initial_url, + LaunchOptions launch_options) : startup_(startup), initial_url_(std::move(initial_url)), + launch_options_(launch_options), tabs_(this), site_history_(LoadSiteHistory()) {} @@ -138,6 +189,11 @@ bool NebulaController::Create() { } void NebulaController::OnWindowCreated() { + big_picture_mode_ = launch_options_.mode == AppMode::BigPicture; + if (big_picture_mode_ && window_) { + window_->SetFullscreen(true); + } + if (initial_url_.empty()) { tabs_.CreateInitialTab(nebula::ui::GetHomeUrl()); } else { @@ -145,8 +201,13 @@ void NebulaController::OnWindowCreated() { } PersistSession(); - CreateChromeBrowser(); + if (!big_picture_mode_) { + CreateChromeBrowser(); + } CreateContentBrowser(); + if (big_picture_mode_) { + CreateBigPictureBrowser(); + } } void NebulaController::OnWindowResized(const nebula::window::BrowserLayout& layout) { @@ -178,6 +239,9 @@ void NebulaController::OnWindowCloseRequested() { if (chrome_browser_) { chrome_browser_->GetHost()->CloseBrowser(true); } + if (big_picture_browser_) { + big_picture_browser_->GetHost()->CloseBrowser(true); + } if (menu_popup_browser_) { menu_popup_browser_->GetHost()->CloseBrowser(true); } @@ -200,6 +264,12 @@ void NebulaController::OnActiveTabChanged(const nebula::browser::NebulaTab& tab) if (chrome_ready_) { SendChromeState(tab); } + if (big_picture_ready_) { + SendBigPictureState(tab); + } + if (big_picture_mode_) { + InjectBigPictureCursor(tab.browser); + } } void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr browser) { @@ -213,6 +283,13 @@ void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr if (const auto* tab = tabs_.ActiveTab()) { SendChromeState(*tab); } + } else if (role == nebula::cef::BrowserRole::BigPicture) { + big_picture_browser_ = browser; + big_picture_ready_ = true; + SetBrowserVisible(big_picture_browser_, big_picture_mode_); + if (const auto* tab = tabs_.ActiveTab()) { + SendBigPictureState(*tab); + } } else if (role == nebula::cef::BrowserRole::MenuPopup) { menu_popup_browser_ = browser; menu_popup_visible_ = true; @@ -229,6 +306,11 @@ void NebulaController::OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr if (role == nebula::cef::BrowserRole::Chrome) { chrome_browser_ = nullptr; chrome_ready_ = false; + } else if (role == nebula::cef::BrowserRole::BigPicture) { + big_picture_browser_ = nullptr; + big_picture_client_ = nullptr; + big_picture_ready_ = false; + big_picture_mode_ = false; } else if (role == nebula::cef::BrowserRole::MenuPopup) { menu_popup_browser_ = nullptr; menu_popup_client_ = nullptr; @@ -277,8 +359,7 @@ void NebulaController::OnChromeCommand(const std::string& command, const std::st CloseMenuPopup(); tabs_.LoadURL(nebula::ui::GetSettingsUrl()); } else if (command == "big-picture") { - CloseMenuPopup(); - tabs_.LoadURL(nebula::ui::GetBigPictureUrl()); + EnterBigPictureMode(); } else if (command == "gpu-diagnostics") { CloseMenuPopup(); tabs_.LoadURL(nebula::ui::GetGpuDiagnosticsUrl()); @@ -306,6 +387,25 @@ void NebulaController::OnChromeCommand(const std::string& command, const std::st if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) { InjectSettingsHistory(tab->browser); } + if (auto* tab = tabs_.ActiveTab()) { + SendBigPictureState(*tab); + } + } else if (command == "clear-search-history") { + if (auto* tab = tabs_.ActiveTab()) { + SendBigPictureState(*tab); + } + } else if (command == "bigpicture-mouse-move") { + SendBigPictureMouseMove(payload); + } else if (command == "bigpicture-click") { + SendBigPictureMouseClick(payload, false); + } else if (command == "bigpicture-right-click") { + SendBigPictureMouseClick(payload, true); + } else if (command == "bigpicture-scroll") { + SendBigPictureMouseWheel(payload); + } else if (command == "bigpicture-text") { + SendBigPictureText(payload); + } else if (command == "bigpicture-browse-visible") { + SetBigPictureBrowseVisible(payload == "1" || payload == "true"); } else if (command == "minimize" && window_) { window_->Minimize(); } else if (command == "maximize" && window_) { @@ -313,7 +413,11 @@ void NebulaController::OnChromeCommand(const std::string& command, const std::st } else if (command == "close" && window_) { OnWindowCloseRequested(); } else if (command == "exit-bigpicture" && window_) { - OnWindowCloseRequested(); + if (launch_options_.mode == AppMode::BigPicture) { + OnWindowCloseRequested(); + return; + } + ExitBigPictureMode(); } else if (command == "drag" && window_) { window_->BeginDrag(); } @@ -326,6 +430,9 @@ void NebulaController::OnContentAddressChanged(CefRefPtr browser, co ? nebula::ui::GetHomeUrl() : internal_url); RecordSiteHistory(internal_url); + if (const auto* active_tab = tabs_.ActiveTab()) { + SendBigPictureState(*active_tab); + } PersistSession(); } @@ -350,6 +457,7 @@ void NebulaController::OnContentLoadFinished(CefRefPtr browser, cons if (nebula::ui::ToInternalUrl(url).starts_with(nebula::ui::GetSettingsUrl())) { InjectSettingsHistory(browser); } + InjectBigPictureCursor(browser); } void NebulaController::OnContentFaviconChanged(CefRefPtr browser, const std::vector& urls) { @@ -457,7 +565,7 @@ void NebulaController::CreateChromeBrowser() { return; } - const auto layout = window_->CurrentLayout(); + const auto layout = CurrentBrowserLayout(); CefBrowserSettings browser_settings = BrowserSettings(); chrome_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Chrome, this); CefWindowInfo window_info = @@ -466,6 +574,25 @@ void NebulaController::CreateChromeBrowser() { window_info, chrome_client_, nebula::ui::GetChromeUrl(), browser_settings, nullptr, nullptr); } +void NebulaController::CreateBigPictureBrowser() { + if (!window_ || !window_->native_handle()) { + return; + } + + const auto layout = CurrentBrowserLayout(); + CefBrowserSettings browser_settings = BrowserSettings(); + big_picture_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::BigPicture, this); + CefWindowInfo window_info = + nebula::platform::MakeChildWindowInfo(window_->native_handle(), layout.chrome); + CefBrowserHost::CreateBrowser( + window_info, + big_picture_client_, + nebula::ui::ResolveInternalUrl(nebula::ui::GetBigPictureUrl()), + browser_settings, + nullptr, + nullptr); +} + void NebulaController::CreateContentBrowser() { if (!window_ || !window_->native_handle()) { return; @@ -473,7 +600,7 @@ void NebulaController::CreateContentBrowser() { const auto* tab = tabs_.ActiveTab(); const std::string url = tab && !tab->url.empty() ? tab->url : nebula::ui::GetHomeUrl(); - const auto layout = window_->CurrentLayout(); + const auto layout = CurrentBrowserLayout(); CefBrowserSettings browser_settings = BrowserSettings(); content_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Content, this); CefWindowInfo window_info = @@ -482,7 +609,94 @@ void NebulaController::CreateContentBrowser() { window_info, content_client_, nebula::ui::ResolveInternalUrl(url), browser_settings, nullptr, nullptr); } +void NebulaController::EnterBigPictureMode() { + if (big_picture_mode_) { + return; + } + + CloseMenuPopup(); + if (content_fullscreen_) { + SetContentFullscreen(false); + } + + big_picture_mode_ = true; + big_picture_browse_visible_ = false; + if (auto* tab = tabs_.ActiveTab()) { + InjectBigPictureCursor(tab->browser); + } + SetBrowserVisible(chrome_browser_, false); + if (big_picture_browser_) { + SetBrowserVisible(big_picture_browser_, true); + if (const auto* tab = tabs_.ActiveTab()) { + SendBigPictureState(*tab); + } + } else { + CreateBigPictureBrowser(); + } + ResizeBrowsers(); +} + +void NebulaController::ExitBigPictureMode() { + if (!big_picture_mode_) { + return; + } + + big_picture_mode_ = false; + big_picture_browse_visible_ = false; + if (auto* tab = tabs_.ActiveTab()) { + RemoveBigPictureCursor(tab->browser); + } + SetBrowserVisible(big_picture_browser_, false); + SetBrowserVisible(chrome_browser_, true); + if (const auto* tab = tabs_.ActiveTab()) { + SendChromeState(*tab); + } + ResizeBrowsers(); +} + +nebula::window::BrowserLayout NebulaController::CurrentBrowserLayout() const { + if (!window_) { + return {}; + } + + if (!big_picture_mode_) { + return window_->CurrentLayout(!content_fullscreen_); + } + + const auto client_size = nebula::platform::ParentClientSize(window_->native_handle()); + if (!big_picture_browse_visible_) { + nebula::window::BrowserLayout layout; + layout.chrome = {0, 0, client_size.first, client_size.second}; + layout.content = {}; + return layout; + } + + nebula::window::BrowserLayout layout; + layout.chrome = {0, 0, client_size.first, client_size.second}; + + // left_margin must clear the 220px sidebar; right_margin must clear the + // native-browser-panel (clamp(168,18vw,260) + 20px right inset ≈ 280px max). + // top/bottom margins must clear the header (~68px) and footer (~56px). + const int left_margin = nebula::platform::ScaleForParentWindow(window_->native_handle(), 224); + const int right_margin = nebula::platform::ScaleForParentWindow(window_->native_handle(), 284); + const int top_margin = nebula::platform::ScaleForParentWindow(window_->native_handle(), 68); + const int bottom_margin = nebula::platform::ScaleForParentWindow(window_->native_handle(), 56); + const int available_width = std::max(0, client_size.first - left_margin - right_margin); + const int available_height = std::max(0, client_size.second - top_margin - bottom_margin); + + layout.content = { + left_margin, + top_margin, + available_width, + available_height}; + return layout; +} + void NebulaController::ToggleMenuPopup() { + if (big_picture_mode_) { + return; + } + if (menu_popup_browser_ && menu_popup_visible_) { CloseMenuPopup(); return; @@ -507,7 +721,7 @@ void NebulaController::CloseMenuPopup() { } void NebulaController::CreateMenuPopupBrowser() { - if (!window_ || !window_->native_handle() || content_fullscreen_) { + if (!window_ || !window_->native_handle() || content_fullscreen_ || big_picture_mode_) { return; } @@ -591,6 +805,110 @@ void NebulaController::FreshReload() { tabs_.LoadURL(WithCacheBuster(tab->url)); } +void NebulaController::SendBigPictureMouseMove(const std::string& payload) { + auto* tab = tabs_.ActiveTab(); + if (!tab || !tab->browser) { + return; + } + + int x = 0; + int y = 0; + if (!ParseTwoInts(payload, x, y)) { + return; + } + + CefMouseEvent event = {}; + event.x = x; + event.y = y; + nebula::platform::MoveCursorToBrowserPoint(tab->browser->GetHost()->GetWindowHandle(), x, y); + tab->browser->GetHost()->SendMouseMoveEvent(event, false); +} + +void NebulaController::SendBigPictureMouseClick(const std::string& payload, bool right_click) { + auto* tab = tabs_.ActiveTab(); + if (!tab || !tab->browser) { + return; + } + + int x = 0; + int y = 0; + if (!ParseTwoInts(payload, x, y)) { + return; + } + + CefMouseEvent event = {}; + event.x = x; + event.y = y; + const auto button = right_click ? MBT_RIGHT : MBT_LEFT; + nebula::platform::MoveCursorToBrowserPoint(tab->browser->GetHost()->GetWindowHandle(), x, y); + tab->browser->GetHost()->SendMouseMoveEvent(event, false); + tab->browser->GetHost()->SendMouseClickEvent(event, button, false, 1); + tab->browser->GetHost()->SendMouseClickEvent(event, button, true, 1); +} + +void NebulaController::SendBigPictureMouseWheel(const std::string& payload) { + auto* tab = tabs_.ActiveTab(); + if (!tab || !tab->browser) { + return; + } + + int delta_x = 0; + int delta_y = 0; + int x = 0; + int y = 0; + const bool has_pointer = ParseFourInts(payload, delta_x, delta_y, x, y); + if (!has_pointer && !ParseTwoInts(payload, delta_x, delta_y)) { + return; + } + + const auto layout = CurrentBrowserLayout(); + CefMouseEvent event = {}; + event.x = has_pointer ? std::clamp(x, 0, std::max(0, layout.content.width - 1)) + : std::max(0, layout.content.width / 2); + event.y = has_pointer ? std::clamp(y, 0, std::max(0, layout.content.height - 1)) + : std::max(0, layout.content.height / 2); + tab->browser->GetHost()->SendMouseWheelEvent(event, delta_x, delta_y); +} + +void NebulaController::SendBigPictureText(const std::string& payload) { + auto* tab = tabs_.ActiveTab(); + if (!tab || !tab->browser) { + return; + } + + const std::string script = + "(function(){" + "const el=document.activeElement;" + "if(!el)return;" + "const value=\"" + nebula::browser::JsonEscape(payload) + "\";" + "const editable=el.tagName==='INPUT'||el.tagName==='TEXTAREA'||el.isContentEditable;" + "if(!editable)return;" + "if(el.isContentEditable){el.textContent=value;}else{el.value=value;}" + "el.dispatchEvent(new Event('input',{bubbles:true}));" + "el.dispatchEvent(new Event('change',{bubbles:true}));" + "el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));" + "el.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',keyCode:13,bubbles:true}));" + "})();"; + tab->browser->GetMainFrame()->ExecuteJavaScript(script, tab->url, 0); +} + +void NebulaController::SetBigPictureBrowseVisible(bool visible) { + if (big_picture_browse_visible_ == visible) { + return; + } + + big_picture_browse_visible_ = visible; + if (visible) { + if (auto* tab = tabs_.ActiveTab()) { + InjectBigPictureCursor(tab->browser); + } + } + ResizeBrowsers(); + if (const auto* tab = tabs_.ActiveTab()) { + SendBigPictureState(*tab); + } +} + void NebulaController::SetContentFullscreen(bool fullscreen) { if (content_fullscreen_ == fullscreen) { return; @@ -599,6 +917,7 @@ void NebulaController::SetContentFullscreen(bool fullscreen) { content_fullscreen_ = fullscreen; if (fullscreen) { CloseMenuPopup(); + ExitBigPictureMode(); } SetBrowserVisible(chrome_browser_, !fullscreen); @@ -613,18 +932,24 @@ void NebulaController::ResizeBrowsers() { return; } - const auto layout = window_->CurrentLayout(!content_fullscreen_); + const auto layout = CurrentBrowserLayout(); if (chrome_browser_) { window_->ResizeChild( chrome_browser_->GetHost()->GetWindowHandle(), layout.chrome); } + if (big_picture_browser_) { + window_->ResizeChild( + big_picture_browser_->GetHost()->GetWindowHandle(), + layout.chrome); + } if (const auto* tab = tabs_.ActiveTab(); tab && tab->browser) { + SetBrowserVisible(tab->browser, !big_picture_mode_ || big_picture_browse_visible_); window_->ResizeChild( tab->browser->GetHost()->GetWindowHandle(), layout.content); } - if (!content_fullscreen_) { + if (!content_fullscreen_ && !big_picture_mode_) { PositionMenuPopup(); } } @@ -672,6 +997,71 @@ void NebulaController::SendChromeState(const nebula::browser::NebulaTab& tab) { chrome_browser_->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetChromeUrl(), 0); } +void NebulaController::SendBigPictureState(const nebula::browser::NebulaTab& tab) { + if (!big_picture_browser_) { + return; + } + + const std::string display_url = GetChromeDisplayUrl(tab.url); + double zoom_level = 0.0; + if (tab.browser) { + zoom_level = tab.browser->GetHost()->GetZoomLevel(); + } + + std::string tabs_json = "["; + const auto& tabs = tabs_.Tabs(); + for (size_t i = 0; i < tabs.size(); ++i) { + const auto& item = tabs[i]; + if (i > 0) { + tabs_json += ","; + } + tabs_json += + "{\"id\":" + std::to_string(item.id) + + ",\"title\":\"" + nebula::browser::JsonEscape(item.title) + "\"" + + ",\"url\":\"" + nebula::browser::JsonEscape(item.url) + "\"" + + ",\"isLoading\":" + std::string(item.is_loading ? "true" : "false") + + ",\"favicon\":\"" + nebula::browser::JsonEscape(item.favicon_url) + "\"" + + "}"; + } + tabs_json += "]"; + + std::string history_json = "["; + for (size_t i = 0; i < site_history_.size(); ++i) { + if (i > 0) { + history_json += ","; + } + history_json += "\"" + nebula::browser::JsonEscape(site_history_[i]) + "\""; + } + history_json += "]"; + + const auto layout = CurrentBrowserLayout(); + const std::string script = + "window.NebulaBigPicture && window.NebulaBigPicture.applyState({" + "\"id\":" + std::to_string(tab.id) + + ",\"url\":\"" + nebula::browser::JsonEscape(display_url) + "\"" + ",\"title\":\"" + nebula::browser::JsonEscape(tab.title) + "\"" + ",\"isLoading\":" + std::string(tab.is_loading ? "true" : "false") + + ",\"progress\":" + std::to_string(tab.load_progress) + + ",\"canGoBack\":" + std::string(tab.CanGoBack() ? "true" : "false") + + ",\"canGoForward\":" + std::string(tab.CanGoForward() ? "true" : "false") + + ",\"favicon\":\"" + nebula::browser::JsonEscape(tab.favicon_url) + "\"" + + ",\"zoomLevel\":" + std::to_string(zoom_level) + + ",\"browserLayout\":{" + "\"x\":" + std::to_string(layout.content.x) + + ",\"y\":" + std::to_string(layout.content.y) + + ",\"width\":" + std::to_string(layout.content.width) + + ",\"height\":" + std::to_string(layout.content.height) + + "}" + + ",\"tabs\":" + tabs_json + + ",\"history\":" + history_json + + "});"; + + big_picture_browser_->GetMainFrame()->ExecuteJavaScript( + script, + nebula::ui::GetBigPictureUrl(), + 0); +} + void NebulaController::RecordSiteHistory(const std::string& url) { if (!IsSiteHistoryUrl(url)) { return; @@ -699,6 +1089,44 @@ void NebulaController::InjectSettingsHistory(CefRefPtr browser) { browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0); } +void NebulaController::InjectBigPictureCursor(CefRefPtr browser) { + if (!big_picture_mode_ || !browser) { + return; + } + + static constexpr char kScript[] = R"JS( +(function(){ + const id = 'nebula-bigpicture-custom-cursor'; + const cursor = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232%22%20height%3D%2232%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20d%3D%22M5%203v24l6.6-6.4%204.1%208.5%204.3-2.1-4.1-8.3H25L5%203z%22%20fill%3D%22%2300C6FF%22%20stroke%3D%22%23FFFFFF%22%20stroke-width%3D%222.2%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M9%207.7v9.8l2.5-2.4%202%204.1%201.5-.7-2-4h4L9%207.7z%22%20fill%3D%22%237B2EFF%22%20opacity%3D%220.8%22%2F%3E%3C%2Fsvg%3E") 5 3, auto'; + const css = 'html, body, body * { cursor: ' + cursor + ' !important; }'; + let style = document.getElementById(id); + if (!style) { + style = document.createElement('style'); + style.id = id; + (document.head || document.documentElement).appendChild(style); + } + style.textContent = css; +})(); +)JS"; + browser->GetMainFrame()->ExecuteJavaScript(kScript, browser->GetMainFrame()->GetURL(), 0); +} + +void NebulaController::RemoveBigPictureCursor(CefRefPtr browser) { + if (!browser) { + return; + } + + static constexpr char kScript[] = R"JS( +(function(){ + const style = document.getElementById('nebula-bigpicture-custom-cursor'); + if (style) { + style.remove(); + } +})(); +)JS"; + browser->GetMainFrame()->ExecuteJavaScript(kScript, browser->GetMainFrame()->GetURL(), 0); +} + void NebulaController::PersistSession() const { nebula::browser::SaveSessionState(tabs_.Tabs(), tabs_.ActiveTabIndex()); } @@ -708,7 +1136,7 @@ void NebulaController::MaybeFinishShutdown() { return; } - if (chrome_browser_ || menu_popup_browser_ || tabs_.HasOpenBrowsers()) { + if (chrome_browser_ || big_picture_browser_ || menu_popup_browser_ || tabs_.HasOpenBrowsers()) { return; } diff --git a/src/app/nebula_controller.h b/src/app/nebula_controller.h index c9f141b..6a070d8 100644 --- a/src/app/nebula_controller.h +++ b/src/app/nebula_controller.h @@ -5,6 +5,7 @@ #include #include +#include "app/run.h" #include "browser/tab_manager.h" #include "cef/browser_client.h" #include "platform/types.h" @@ -16,7 +17,9 @@ class NebulaController final : public nebula::window::WindowDelegate, public nebula::browser::TabObserver, public nebula::cef::BrowserClientDelegate { public: - NebulaController(nebula::platform::AppStartup startup, std::string initial_url); + NebulaController(nebula::platform::AppStartup startup, + std::string initial_url, + LaunchOptions launch_options = {}); ~NebulaController() override; bool Create(); @@ -45,7 +48,11 @@ private: void ActivateTab(int tab_id); void CloseTab(int tab_id); void CreateChromeBrowser(); + void CreateBigPictureBrowser(); void CreateContentBrowser(); + void EnterBigPictureMode(); + void ExitBigPictureMode(); + nebula::window::BrowserLayout CurrentBrowserLayout() const; void ToggleMenuPopup(); void CloseMenuPopup(); void CreateMenuPopupBrowser(); @@ -54,27 +61,41 @@ private: void ToggleDevTools(); void AdjustZoom(double delta); void FreshReload(); + void SendBigPictureMouseMove(const std::string& payload); + void SendBigPictureMouseClick(const std::string& payload, bool right_click); + void SendBigPictureMouseWheel(const std::string& payload); + void SendBigPictureText(const std::string& payload); + void SetBigPictureBrowseVisible(bool visible); void SetContentFullscreen(bool fullscreen); void ResizeBrowsers(); void SendChromeState(const nebula::browser::NebulaTab& tab); + void SendBigPictureState(const nebula::browser::NebulaTab& tab); void RecordSiteHistory(const std::string& url); void InjectSettingsHistory(CefRefPtr browser); + void InjectBigPictureCursor(CefRefPtr browser); + void RemoveBigPictureCursor(CefRefPtr browser); void PersistSession() const; void MaybeFinishShutdown(); bool ForgetClosingTabBrowser(CefRefPtr browser); nebula::platform::AppStartup startup_; std::string initial_url_; + LaunchOptions launch_options_; bool closing_ = false; bool chrome_ready_ = false; + bool big_picture_ready_ = false; + bool big_picture_mode_ = false; + bool big_picture_browse_visible_ = false; bool content_fullscreen_ = false; bool menu_popup_visible_ = false; std::unique_ptr window_; nebula::browser::TabManager tabs_; CefRefPtr chrome_browser_; + CefRefPtr big_picture_browser_; CefRefPtr menu_popup_browser_; CefRefPtr chrome_client_; + CefRefPtr big_picture_client_; CefRefPtr content_client_; CefRefPtr menu_popup_client_; std::vector> closing_tab_browsers_; diff --git a/src/app/run.cpp b/src/app/run.cpp index fbb5978..3fb370a 100644 --- a/src/app/run.cpp +++ b/src/app/run.cpp @@ -9,7 +9,7 @@ namespace nebula::app { -int RunNebula(const nebula::platform::AppStartup& startup) { +int RunNebula(const nebula::platform::AppStartup& startup, LaunchOptions options) { nebula::platform::PrepareApp(); const CefMainArgs main_args = nebula::platform::MakeMainArgs(startup); @@ -41,7 +41,7 @@ int RunNebula(const nebula::platform::AppStartup& startup) { initial_url = nebula::ui::GetHomeUrl(); } - NebulaController controller(startup, std::move(initial_url)); + NebulaController controller(startup, std::move(initial_url), options); const bool created = controller.Create(); if (created) { CefRunMessageLoop(); diff --git a/src/app/run.h b/src/app/run.h index 2132195..aa1ed46 100644 --- a/src/app/run.h +++ b/src/app/run.h @@ -4,6 +4,15 @@ namespace nebula::app { -int RunNebula(const nebula::platform::AppStartup& startup); +enum class AppMode { + Desktop, + BigPicture, +}; + +struct LaunchOptions { + AppMode mode = AppMode::Desktop; +}; + +int RunNebula(const nebula::platform::AppStartup& startup, LaunchOptions options = {}); } // namespace nebula::app diff --git a/src/cef/browser_client.cpp b/src/cef/browser_client.cpp index 01520f2..7383a7a 100644 --- a/src/cef/browser_client.cpp +++ b/src/cef/browser_client.cpp @@ -35,6 +35,33 @@ bool IsBigPictureFrame(CefRefPtr frame) { url.starts_with("nebula://big-picture"); } +bool IsBigPictureCommand(const std::string& command) { + return command == "navigate" || + command == "new-tab" || + command == "activate-tab" || + command == "close-tab" || + command == "back" || + command == "forward" || + command == "reload" || + command == "stop" || + command == "home" || + command == "settings" || + command == "open-settings" || + command == "big-picture" || + command == "exit-bigpicture" || + command == "gpu-diagnostics" || + command == "zoom-out" || + command == "zoom-in" || + command == "clear-site-history" || + command == "clear-search-history" || + command == "bigpicture-mouse-move" || + command == "bigpicture-click" || + command == "bigpicture-right-click" || + command == "bigpicture-scroll" || + command == "bigpicture-text" || + command == "bigpicture-browse-visible"; +} + std::vector ToStringVector(const std::vector& values) { std::vector result; result.reserve(values.size()); @@ -62,7 +89,7 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr browser } if (role_ != BrowserRole::Chrome && role_ != BrowserRole::Content && - role_ != BrowserRole::MenuPopup) { + role_ != BrowserRole::BigPicture && role_ != BrowserRole::MenuPopup) { return false; } @@ -83,6 +110,10 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr browser !allowed_big_picture_command) { return false; } + } else if (role_ == BrowserRole::BigPicture) { + if (!IsBigPictureFrame(frame) || !IsBigPictureCommand(command)) { + return false; + } } if (delegate_ && !command.empty()) { diff --git a/src/cef/browser_client.h b/src/cef/browser_client.h index c7a1b2d..29b6bcf 100644 --- a/src/cef/browser_client.h +++ b/src/cef/browser_client.h @@ -16,6 +16,7 @@ namespace nebula::cef { enum class BrowserRole { Chrome, Content, + BigPicture, MenuPopup, }; diff --git a/src/platform/browser_host.h b/src/platform/browser_host.h index 8f34e99..3bf08a4 100644 --- a/src/platform/browser_host.h +++ b/src/platform/browser_host.h @@ -13,6 +13,7 @@ CefWindowInfo MakeDevToolsPopup(NativeWindow parent, const char* title); void ResizeBrowserWindow(NativeWindow browser_window, const Rect& rect); void SetBrowserVisible(NativeWindow browser_window, bool visible); void RaiseBrowserWindow(NativeWindow browser_window); +void MoveCursorToBrowserPoint(NativeWindow browser_window, int x, int y); Rect MenuPopupRect(NativeWindow parent, const BrowserLayout& layout); std::string CacheBusterToken(); void DestroyTopLevelWindow(NativeWindow window); diff --git a/src/platform/linux/browser_host_linux.cpp b/src/platform/linux/browser_host_linux.cpp index 32bef69..99e6f4d 100644 --- a/src/platform/linux/browser_host_linux.cpp +++ b/src/platform/linux/browser_host_linux.cpp @@ -34,6 +34,12 @@ void RaiseBrowserWindow(NativeWindow browser_window) { UNREFERENCED_PARAMETER(browser_window); } +void MoveCursorToBrowserPoint(NativeWindow browser_window, int x, int y) { + UNREFERENCED_PARAMETER(browser_window); + UNREFERENCED_PARAMETER(x); + UNREFERENCED_PARAMETER(y); +} + int ScaleForParentWindow(NativeWindow parent, int value) { UNREFERENCED_PARAMETER(parent); return value; diff --git a/src/platform/mac/browser_host_mac.cpp b/src/platform/mac/browser_host_mac.cpp index da845a6..b18decb 100644 --- a/src/platform/mac/browser_host_mac.cpp +++ b/src/platform/mac/browser_host_mac.cpp @@ -32,6 +32,12 @@ void RaiseBrowserWindow(NativeWindow browser_window) { UNREFERENCED_PARAMETER(browser_window); } +void MoveCursorToBrowserPoint(NativeWindow browser_window, int x, int y) { + UNREFERENCED_PARAMETER(browser_window); + UNREFERENCED_PARAMETER(x); + UNREFERENCED_PARAMETER(y); +} + int ScaleForParentWindow(NativeWindow parent, int value) { UNREFERENCED_PARAMETER(parent); return value; diff --git a/src/platform/win/browser_host_win.cpp b/src/platform/win/browser_host_win.cpp index eed998d..e0137f5 100644 --- a/src/platform/win/browser_host_win.cpp +++ b/src/platform/win/browser_host_win.cpp @@ -75,6 +75,20 @@ void RaiseBrowserWindow(NativeWindow browser_window) { SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } +void MoveCursorToBrowserPoint(NativeWindow browser_window, int x, int y) { + const HWND hwnd = AsHwnd(browser_window); + if (!hwnd) { + return; + } + + POINT point = {x, y}; + if (!ClientToScreen(hwnd, &point)) { + return; + } + + SetCursorPos(point.x, point.y); +} + int ScaleForParentWindow(NativeWindow parent, int value) { const HWND hwnd = AsHwnd(parent); if (!hwnd) { diff --git a/ui/css/bigpicture.css b/ui/css/bigpicture.css index 491a6fd..2cdc4f7 100644 --- a/ui/css/bigpicture.css +++ b/ui/css/bigpicture.css @@ -132,6 +132,83 @@ body.mouse-active { left: -100px; } +.browser-stage-frame { + position: fixed; + left: var(--browser-stage-x, 20%); + top: var(--browser-stage-y, 10%); + width: var(--browser-stage-width, 60%); + height: var(--browser-stage-height, 80%); + z-index: 3; + pointer-events: none; + border-radius: var(--bp-radius-xl); + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.08), + 0 0 0 6px rgba(123, 46, 255, 0.18), + 0 24px 80px rgba(0, 0, 0, 0.55), + 0 0 80px var(--bp-primary-glow); + opacity: 1; + transition: opacity var(--bp-transition-normal), box-shadow var(--bp-transition-normal); +} + +.browser-stage-frame.hidden { + opacity: 0; +} + +.virtual-cursor { + position: fixed; + left: 0; + top: 0; + z-index: 250; + width: 34px; + height: 34px; + pointer-events: none; + transform: translate3d(var(--virtual-cursor-x, 50vw), var(--virtual-cursor-y, 50vh), 0); + transition: opacity var(--bp-transition-fast); + filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.55)); +} + +.virtual-cursor.hidden { + opacity: 0; +} + +.virtual-cursor::before { + content: ''; + position: absolute; + left: 4px; + top: 3px; + width: 18px; + height: 24px; + background: linear-gradient(135deg, var(--bp-text), var(--bp-accent)); + clip-path: polygon(0 0, 0 100%, 38% 76%, 60% 100%, 84% 86%, 62% 62%, 100% 62%); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.45), 0 0 20px var(--bp-accent-glow); +} + +.virtual-cursor-dot { + position: absolute; + left: 10px; + top: 10px; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--bp-primary); + opacity: 0; + transform: scale(1); +} + +.virtual-cursor.clicking .virtual-cursor-dot { + animation: virtual-cursor-click 180ms ease-out; +} + +.virtual-cursor.right-clicking .virtual-cursor-dot { + background: var(--bp-warning); + animation: virtual-cursor-click 180ms ease-out; +} + +@keyframes virtual-cursor-click { + 0% { opacity: 0.95; transform: scale(0.8); } + 100% { opacity: 0; transform: scale(4.4); } +} + @keyframes glow-pulse { 0% { transform: scale(1); opacity: 0.3; } 100% { transform: scale(1.2); opacity: 0.5; } @@ -220,6 +297,15 @@ body.mouse-active { color: var(--bp-text-muted); } +.status-icon.controller-status.connected { + color: var(--bp-success); + box-shadow: 0 0 20px rgba(74, 222, 128, 0.25); +} + +.status-icon.controller-status.disconnected { + color: var(--bp-text-dim); +} + .status-icon .material-symbols-outlined { font-size: 20px; } @@ -338,8 +424,9 @@ body.mouse-active { inset: 0; width: 100%; height: 100%; - background: var(--bp-bg); + background: transparent; z-index: 2; + pointer-events: none; } .webview-container.hidden { @@ -353,6 +440,155 @@ body.mouse-active { border: none; } +.native-browser-panel { + position: absolute; + top: var(--bp-spacing-md); + right: var(--bp-spacing-md); + bottom: var(--bp-spacing-md); + width: clamp(168px, 18vw, 260px); + overflow-y: auto; + padding: var(--bp-spacing-sm); + background: linear-gradient(180deg, rgba(10,10,15,0.88) 0%, rgba(20,20,31,0.82) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--bp-radius-lg); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(20px); + pointer-events: auto; +} + +.native-page-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-sm); + margin-bottom: var(--bp-spacing-md); + background: rgba(20, 20, 31, 0.78); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; +} + +.native-page-card:focus, +.native-page-card.focused { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); +} + +.native-page-card .material-symbols-outlined { + font-size: 28px; + color: var(--bp-accent); +} + +.native-page-card h2 { + font-size: 0.95rem; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.native-page-card p { + font-size: 0.72rem; + color: var(--bp-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.native-page-card .controller-note { + grid-column: 1 / -1; + margin-top: 4px; + color: var(--bp-accent); + white-space: normal; +} + +.browser-actions { + display: grid; + grid-template-columns: 1fr; + gap: var(--bp-spacing-xs); + margin-bottom: var(--bp-spacing-lg); +} + +.action-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.tab-list { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-xs); +} + +.bp-tab { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + "dot title close" + "dot url close"; + align-items: center; + column-gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-sm); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + cursor: pointer; + text-align: left; +} + +.bp-tab.active { + border-color: var(--bp-primary); + background: var(--bp-surface-active); +} + +.bp-tab:focus, +.bp-tab.focused { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); +} + +.tab-dot { + grid-area: dot; + color: var(--bp-accent); +} + +.tab-text { + grid-area: title; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-url { + grid-area: url; + color: var(--bp-text-muted); + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-close-inline { + grid-area: close; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--bp-radius-sm); + color: var(--bp-text-muted); +} + +.tab-close-inline:hover { + background: var(--bp-danger); + color: var(--bp-text); +} + /* Content area */ .bp-content { flex: 1; diff --git a/ui/js/bigpicture.js b/ui/js/bigpicture.js index d2cdf8c..70cf5c5 100644 --- a/ui/js/bigpicture.js +++ b/ui/js/bigpicture.js @@ -1,1615 +1,807 @@ -/** - * Big Picture Mode - Controller-friendly UI for Steam Deck / Console - * Supports gamepad navigation, on-screen keyboard, and touch input - */ +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 ipcRenderer = window.electronAPI; +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' }, +]; -// ============================================================================= -// SCROLL NORMALIZATION (consistent scroll speed across all sites) -// ============================================================================= - -const SCROLL_NORMALIZATION_CSS = ` - /* Disable smooth scrolling behavior that some sites force */ - *, *::before, *::after { - scroll-behavior: auto !important; - } - html, body { - scroll-behavior: auto !important; - } -`; - -const SCROLL_NORMALIZATION_JS = ` -(function() { - if (window.__nebulaScrollNormalized) return; - window.__nebulaScrollNormalized = true; - - // Consistent scroll amount in pixels per wheel delta unit - const SCROLL_SPEED = 100; - - // Intercept wheel events to normalize scroll speed - document.addEventListener('wheel', function(e) { - // Don't interfere if modifier keys are pressed (zoom, horizontal scroll, etc.) - if (e.ctrlKey || e.metaKey || e.altKey) return; - - // Get the scroll target - let target = e.target; - let scrollable = null; - - // Find the nearest scrollable element - while (target && target !== document.body && target !== document.documentElement) { - const style = window.getComputedStyle(target); - const overflowY = style.overflowY; - const overflowX = style.overflowX; - - if ((overflowY === 'auto' || overflowY === 'scroll') && target.scrollHeight > target.clientHeight) { - scrollable = target; - break; - } - if ((overflowX === 'auto' || overflowX === 'scroll') && target.scrollWidth > target.clientWidth && e.shiftKey) { - scrollable = target; - break; - } - target = target.parentElement; - } - - // If no scrollable container found, use the document - if (!scrollable) { - scrollable = document.scrollingElement || document.documentElement || document.body; - } - - // Calculate normalized scroll delta - // deltaMode: 0 = pixels, 1 = lines, 2 = pages - let deltaY = e.deltaY; - let deltaX = e.deltaX; - - if (e.deltaMode === 1) { - // Line mode - multiply by line height approximation - deltaY *= SCROLL_SPEED; - deltaX *= SCROLL_SPEED; - } else if (e.deltaMode === 2) { - // Page mode - multiply by viewport height - deltaY *= window.innerHeight; - deltaX *= window.innerWidth; - } else { - // Pixel mode - normalize to consistent speed - // Clamp the delta to prevent extremely fast scrolling from some sites - const sign = deltaY > 0 ? 1 : -1; - deltaY = sign * Math.min(Math.abs(deltaY), SCROLL_SPEED * 3); - - const signX = deltaX > 0 ? 1 : -1; - deltaX = signX * Math.min(Math.abs(deltaX), SCROLL_SPEED * 3); - } - - // Apply scroll - e.preventDefault(); - scrollable.scrollBy({ - top: deltaY, - left: e.shiftKey ? deltaX : 0, - behavior: 'auto' - }); - }, { passive: false, capture: true }); -})(); -`; - -// Function to apply scroll normalization to a webview -function applyScrollNormalization(webview) { - try { - webview.insertCSS(SCROLL_NORMALIZATION_CSS); - webview.executeJavaScript(SCROLL_NORMALIZATION_JS); - console.log('[BigPicture] Applied scroll normalization to webview'); - } catch (err) { - console.warn('[BigPicture] Failed to apply scroll normalization:', err); - } -} - -// ============================================================================= -// CONFIGURATION -// ============================================================================= - -const CONFIG = { - // Navigation - NAV_SOUND_ENABLED: true, - HAPTIC_FEEDBACK: true, - - // Controller deadzone - STICK_DEADZONE: 0.3, - TRIGGER_DEADZONE: 0.1, - - // Timing - REPEAT_DELAY: 500, // Initial delay before key repeat - REPEAT_RATE: 100, // Rate of key repeat - - // Quick access sites - DEFAULT_QUICK_ACCESS: [ - { title: 'Google', url: 'https://www.google.com', icon: 'search' }, - { title: 'YouTube', url: 'https://www.youtube.com', icon: 'play_circle' }, - { title: 'Reddit', url: 'https://www.reddit.com', icon: 'forum' }, - { title: 'Twitter', url: 'https://twitter.com', icon: 'tag' }, - { title: 'Wikipedia', url: 'https://www.wikipedia.org', icon: 'school' }, - { title: 'Netflix', url: 'https://www.netflix.com', icon: 'movie' }, - ] +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' } }, }; -// ============================================================================= -// 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) + lastInput: {}, oskVisible: false, - oskCallback: null, - oskFocusIndex: 0, + oskMode: 'search', oskContext: null, - - // Data - bookmarks: [], + tabs: [], 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 + 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', }; -// ============================================================================= -// INITIALIZATION -// ============================================================================= - -function applyDisplayScale(scalePercent, reason = 'unknown') { - const numeric = Number(scalePercent); - if (!Number.isFinite(numeric)) return; - - const clampedPercent = Math.min(300, Math.max(50, Math.round(numeric))); - const zoomFactor = Math.max(0.5, Math.min(3, clampedPercent / 100)); - - // Prefer Electron zoom (consistent across Chromium) with CSS fallback. - try { - if (ipcRenderer && typeof ipcRenderer.invoke === 'function') { - ipcRenderer.invoke('set-zoom-factor', zoomFactor).catch(err => { - console.warn('[BigPicture] set-zoom-factor failed; falling back to CSS zoom:', err); - applyCssZoom(zoomFactor); - }); - } else { - applyCssZoom(zoomFactor); - } - console.log(`[BigPicture] Applied display scale ${clampedPercent}% (zoom=${zoomFactor}) via ${reason}`); - } catch (err) { - console.warn('[BigPicture] Failed applying display scale:', err); +function postCommand(command, payload = '') { + if (window.nebulaNative && typeof window.nebulaNative.postMessage === 'function') { + window.nebulaNative.postMessage(command, String(payload)); } } -function applyCssZoom(factor) { - try { - document.documentElement.style.zoom = factor; - } catch {} - try { - document.body.style.zoom = factor; - } catch {} - try { - document.documentElement.style.setProperty('--bp-scale-factor', factor); - document.body.style.setProperty('--bp-scale-factor', factor); - } catch {} +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 applyDisplayScaleFromStorage(reason = 'startup') { +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value || ''); + return div.innerHTML; +} + +function getDomainFromUrl(url) { try { - const savedScale = localStorage.getItem(DISPLAY_SCALE_KEY); - if (!savedScale) return; - const parsed = parseInt(savedScale, 10); - if (Number.isFinite(parsed)) { - currentDisplayScale = Math.min(300, Math.max(50, parsed)); - applyDisplayScale(currentDisplayScale, `${reason}-storage`); - updateScaleDisplay(); - } - } catch (err) { - console.warn('[BigPicture] Failed to read display scale from storage:', err); + 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'; } } -document.addEventListener('DOMContentLoaded', () => { - console.log('[BigPicture] Initializing Big Picture Mode'); +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 ''; + } +} - // Apply saved display scale as early as possible for this window. - applyDisplayScaleFromStorage('DOMContentLoaded'); - - initClock(); - initNavigation(); - initGamepadSupport(); - initMouseTracking(); - initKeyboardShortcuts(); - initOSK(); - loadData(); - - // Set initial focus - setTimeout(() => { - updateFocusableElements(); - focusFirstElement(); - }, 100); -}); - -// ============================================================================= -// CLOCK & DATE -// ============================================================================= - -function initClock() { - updateClock(); - setInterval(updateClock, 1000); +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'); - - 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 (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(); - let greeting = 'Welcome back'; - if (hour < 12) greeting = 'Good morning'; - else if (hour < 17) greeting = 'Good afternoon'; - else greeting = 'Good evening'; - greetingEl.textContent = greeting; + greetingEl.textContent = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'; } } -// ============================================================================= -// NAVIGATION -// ============================================================================= +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_border

No 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 = '
history

No 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() { - // Sidebar navigation - document.querySelectorAll('.nav-item').forEach(item => { - item.addEventListener('click', () => { - const section = item.dataset.section; - if (section) { - switchSection(section); - } - }); + document.querySelectorAll('.nav-item[data-section]').forEach(item => { + item.addEventListener('click', () => switchSection(item.dataset.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('nebula://nebot')); - } - - // Bookmarks actions - const addBookmarkBtn = document.getElementById('addBookmarkBtn'); - if (addBookmarkBtn) { - addBookmarkBtn.addEventListener('click', () => startAddBookmark()); - } - - const addCurrentBookmarkBtn = document.getElementById('addCurrentBookmarkBtn'); - if (addCurrentBookmarkBtn) { - addCurrentBookmarkBtn.addEventListener('click', () => addBookmarkFromCurrentPage()); - } - - // Settings cards - document.querySelectorAll('.settings-card').forEach(card => { - card.addEventListener('click', () => { - const action = card.dataset.action; - handleSettingsAction(action); - }); + 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')); -// ============================================================================= -// 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(); - } + 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) { - 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 => { + document.querySelectorAll('.nav-item[data-section]').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}`); }); - + + 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; - - // Update focusable elements for new section + if (sectionId === 'bookmarks') renderBookmarks(); + if (sectionId === 'history') renderHistory(); + if (sectionId === 'downloads') renderDownloads(); + if (sectionId === 'browse') renderBrowseStatus(); + setTimeout(() => { updateFocusableElements(); focusFirstInContent(); + updateVirtualCursor(); + updateControllerHints(); }, 50); - - playNavSound(); +} + +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() { - // 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; - } - } + 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; - - // Remove focus from previous - if (state.focusedElement) { - state.focusedElement.classList.remove('focused'); - } - - // Add focus to new element + state.focusedElement?.classList.remove('focused'); element.classList.add('focused'); - element.focus(); + element.focus({ preventScroll: true }); + element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); state.focusedElement = element; - - // Scroll into view if needed - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + 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) { - 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(); - } -} + updateFocusableElements(); + if (!state.focusableElements.length) return; -function findElementInDirection(direction) { - const current = state.focusedElement; - if (!current) return 0; - + 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 + y: currentRect.top + currentRect.height / 2, }; - - // Detect if current element is in sidebar, header, or content area - const currentContainer = current.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content'); - - // Special case: if on a tab link in settings and going down/right, prioritize active panel content - const isTabLink = current.classList.contains('tab-link') || current.closest('.tabs, .tab-link'); - const isActiveTab = current.classList.contains('active'); - - let bestIndex = state.focusIndex; + let best = null; let bestScore = Infinity; - - state.focusableElements.forEach((element, index) => { + + 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 - }; - - // Detect element's container - const elementContainer = element.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content'); - const sameContainer = currentContainer === elementContainer; - - // Check if element is in active tab panel - const inActivePanel = element.closest('.tab-panel.active'); - - // Check if element is in the correct direction - let isValid = false; - let alignmentScore = 0; - let distanceInDirection = 0; - let distancePerpendicular = 0; - - switch (direction) { - case 'up': - isValid = center.y < currentCenter.y - 10; - distanceInDirection = currentCenter.y - center.y; - distancePerpendicular = Math.abs(center.x - currentCenter.x); - // Prioritize elements in the same vertical column - alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; - break; - case 'down': - isValid = center.y > currentCenter.y + 10; - distanceInDirection = center.y - currentCenter.y; - distancePerpendicular = Math.abs(center.x - currentCenter.x); - // Prioritize elements in the same vertical column - alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; - break; - case 'left': - isValid = center.x < currentCenter.x - 10; - distanceInDirection = currentCenter.x - center.x; - distancePerpendicular = Math.abs(center.y - currentCenter.y); - // Prioritize elements in the same horizontal row - alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; - break; - case 'right': - isValid = center.x > currentCenter.x + 10; - distanceInDirection = center.x - currentCenter.x; - distancePerpendicular = Math.abs(center.y - currentCenter.y); - // Prioritize elements in the same horizontal row - alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; - break; - } - - if (isValid) { - // Calculate score - lower is better - // Heavily favor same container, then alignment, then distance - let score = distanceInDirection + alignmentScore * 3; - - // Special handling: if on active tab and going down/right, strongly prefer active panel content - if (isTabLink && isActiveTab && (direction === 'down' || direction === 'right')) { - if (inActivePanel) { - score = distanceInDirection * 0.1; // Extremely high priority for panel content - } else { - score += 5000; // Very large penalty for non-panel elements - } - } - // Otherwise, strong bonus for staying in same container (sidebar, content, etc.) - else if (!sameContainer) { - score += 2000; // Large penalty for leaving container - } - - if (score < bestScore) { - bestScore = score; - bestIndex = index; - } + 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; } }); - - return bestIndex; + + if (best) focusElement(best); } function activateFocused() { - if (state.focusedElement) { - state.focusedElement.click(); - playSelectSound(); + 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 OSK is open, close it if (state.oskVisible) { closeOSK(); return; } - - // If viewing a website, go back in browsing history - if (state.currentSection === 'browse' && state.currentWebview) { - if (state.currentWebview.canGoBack()) { - state.currentWebview.goBack(); - return; - } - } - - // If not on home, go to home - if (state.currentSection !== 'home') { - switchSection('home'); - // Cleanup webview - const container = document.getElementById('webview-container'); - if (container) { - const webview = container.querySelector('webview'); - if (webview) webview.remove(); - container.classList.add('hidden'); - } - state.currentWebview = null; - // Focus the home nav item - const homeNav = document.querySelector('.nav-item[data-section="home"]'); - if (homeNav) { - const index = state.focusableElements.indexOf(homeNav); - if (index !== -1) { - state.focusIndex = index; - focusElement(homeNav); - } - } - } -} - -function goForward() { - // If viewing a website, go forward in browsing history - if (state.currentSection === 'browse' && state.currentWebview) { - if (state.currentWebview.canGoForward()) { - state.currentWebview.goForward(); - } - } -} - -// ============================================================================= -// GAMEPAD SUPPORT -// ============================================================================= - -function initGamepadSupport() { - if (!navigator.getGamepads) { - console.warn('[BigPicture] Gamepad API not available in this environment'); + if (state.currentSection === 'browse') { + postCommand('back'); return; } + if (state.currentSection !== 'home') { + switchSection('home'); + } +} - // The global gamepad handler (from gamepad-handler.js injected via preload) - // already polls navigator.getGamepads() continuously. This is what tells Steam - // that we're consuming gamepad input and it should stop mouse emulation. - // Big Picture Mode handles the actual UI navigation and button actions. - - console.log('[BigPicture] Global gamepad handler available:', !!window.__nebulaGamepadHandler); - - // Note: On Linux (and some controllers like handheld integrated gamepads), - // the `gamepadconnected` event may not fire until the first button press, - // or at all. We rely on continuous polling for robustness. - window.addEventListener('gamepadconnected', (e) => { - console.log('[BigPicture] Gamepad connected:', e.gamepad?.id || 'unknown'); - // Prefer the first connected controller as the active one. - if (state.gamepadIndex === null) { - state.gamepadConnected = true; - state.gamepadIndex = e.gamepad.index; - showToast('Controller connected'); +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(); } }); +} - window.addEventListener('gamepaddisconnected', (e) => { - console.log('[BigPicture] Gamepad disconnected:', e.gamepad?.id || 'unknown'); - // If the active controller disconnected, clear it; polling will auto-select another. - if (state.gamepadIndex === e.gamepad.index) { - state.gamepadConnected = false; - state.gamepadIndex = null; - showToast('Controller disconnected'); - } +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'); }); - - // Initial scan (covers controllers that are already connected at load). - refreshActiveGamepad(true); - - // Start polling for gamepad input requestAnimationFrame(pollGamepad); } -function getFirstConnectedGamepad(gamepads) { - if (!gamepads) return null; - for (let i = 0; i < gamepads.length; i++) { - const gp = gamepads[i]; - if (gp) return gp; - } - return null; +function 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 refreshActiveGamepad(isInitial = false) { - const gamepads = navigator.getGamepads(); - - // If we have an index, verify it still points to a real gamepad. - let active = null; - if (state.gamepadIndex !== null) { - active = gamepads[state.gamepadIndex] || null; - } - - // Fallback: pick the first connected controller. - if (!active) { - active = getFirstConnectedGamepad(gamepads); - } - - if (active) { - const changed = !state.gamepadConnected || state.gamepadIndex !== active.index; - state.gamepadConnected = true; - state.gamepadIndex = active.index; - if (changed && !isInitial) { - console.log('[BigPicture] Active gamepad selected:', active.id); - showToast('Controller connected'); - } - } else { - if (state.gamepadConnected) { - state.gamepadConnected = false; - state.gamepadIndex = null; - if (!isInitial) { - showToast('Controller disconnected'); - } - } - state.gamepadConnected = false; - state.gamepadIndex = null; - } - - return { gamepads, active }; +function activeGamepad() { + const gamepads = navigator.getGamepads?.() || []; + if (state.gamepadIndex !== null && gamepads[state.gamepadIndex]) return gamepads[state.gamepadIndex]; + return [...gamepads].find(Boolean) || null; } function pollGamepad() { - const { active } = refreshActiveGamepad(false); - if (active) { - handleGamepadInput(active); - } - + const gamepad = activeGamepad(); + if (gamepad) handleGamepadInput(gamepad); requestAnimationFrame(pollGamepad); } -function readDpadFromButtons(gamepad) { - const up = !!gamepad.buttons[12]?.pressed; - const down = !!gamepad.buttons[13]?.pressed; - const left = !!gamepad.buttons[14]?.pressed; - const right = !!gamepad.buttons[15]?.pressed; - return { up, down, left, right, active: up || down || left || right, source: 'buttons' }; +function pressed(gamepad, index) { + return !!gamepad.buttons[index]?.pressed; } -function readDpadFromAxes(gamepad) { - const axes = gamepad.axes || []; - const candidates = [ - { x: 6, y: 7 }, - { x: 9, y: 10 }, - { x: 4, y: 5 } - ]; - - for (const { x, y } of candidates) { - if (axes.length <= Math.max(x, y)) continue; - const ax = axes[x] || 0; - const ay = axes[y] || 0; - if (Math.abs(ax) > 0.5 || Math.abs(ay) > 0.5) { - return { - up: ay < -0.5, - down: ay > 0.5, - left: ax < -0.5, - right: ax > 0.5, - active: true, - source: 'axes' - }; - } +function once(gamepad, key, active, handler) { + if (active && !state.lastInput[key]) { + handler(); + state.lastInput[key] = true; + } else if (!active) { + state.lastInput[key] = false; } - - return { up: false, down: false, left: false, right: false, active: false, source: 'axes' }; } function handleGamepadInput(gamepad) { - // D-pad and left stick for navigation - const leftX = gamepad.axes[0] || 0; - const leftY = gamepad.axes[1] || 0; - - // D-pad buttons/axes (indices may vary by controller) - const buttonDpad = readDpadFromButtons(gamepad); - const axisDpad = readDpadFromAxes(gamepad); - const dpad = axisDpad.active && (!buttonDpad.active || gamepad.mapping !== 'standard') - ? axisDpad - : buttonDpad; - const dpadUp = dpad.up; - const dpadDown = dpad.down; - const dpadLeft = dpad.left; - const dpadRight = dpad.right; - - // 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(); + 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(); } - state.lastInput.x = true; - } else if (!gamepad.buttons[2]?.pressed) { - state.lastInput.x = false; + 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')); } - - // Y button (usually index 3) - Space when OSK open, otherwise open search - if (gamepad.buttons[3]?.pressed && !state.lastInput.y) { + + 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'); } - state.lastInput.y = true; - } else if (!gamepad.buttons[3]?.pressed) { - state.lastInput.y = false; - } - - // LB button (usually index 4) - Go back in webview / clear OSK - if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) { - if (state.oskVisible) { - clearOSK(); - } else if (state.currentSection === 'browse' && state.currentWebview) { - goBack(); - } - state.lastInput.lb = true; - } else if (!gamepad.buttons[4]?.pressed) { - state.lastInput.lb = false; - } - - // RB button (usually index 5) - Go forward in webview / submit OSK - if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) { - if (state.oskVisible) { - submitOSK(); - } else if (state.currentSection === 'browse' && state.currentWebview) { - goForward(); - } - state.lastInput.rb = true; - } else if (!gamepad.buttons[5]?.pressed) { - state.lastInput.rb = false; - } - - // Back/Select button (usually index 8) - Toggle sidebar when in webview - if (gamepad.buttons[8]?.pressed && !state.lastInput.select) { - if (state.currentSection === 'browse' && state.currentWebview) { - toggleSidebar(); - } - state.lastInput.select = true; - } else if (!gamepad.buttons[8]?.pressed) { - state.lastInput.select = false; - } - - // Start button (usually index 9) - Menu / Toggle sidebar when viewing webpage - if (gamepad.buttons[9]?.pressed && !state.lastInput.start) { - // If viewing a webpage, toggle sidebar instead of going to settings - if (state.currentSection === 'browse' && state.currentWebview) { - toggleSidebar(); - } else if (state.currentSection !== 'settings') { - switchSection('settings'); - } else { - switchSection('home'); - } - state.lastInput.start = true; - } else if (!gamepad.buttons[9]?.pressed) { - state.lastInput.start = false; - } - - // Virtual cursor handling when webview is active - if (state.cursorEnabled && state.currentWebview) { - // Right stick for cursor movement - const rightX = gamepad.axes[2] || 0; - const rightY = gamepad.axes[3] || 0; - - // Apply deadzone - const deadzone = 0.15; - const moveX = Math.abs(rightX) > deadzone ? rightX : 0; - const moveY = Math.abs(rightY) > deadzone ? rightY : 0; - - if (moveX !== 0 || moveY !== 0) { - moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed); - } - - // Left stick for scrolling in webview mode - const scrollDeadzone = 0.25; - const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0; - const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0; - - if (scrollX !== 0 || scrollY !== 0) { - scrollWebview(scrollY * 20, scrollX * 20); - } - - // Right trigger (index 7) - Left click - if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) { - virtualClick(); - state.lastInput.rt = true; - } else if (!gamepad.buttons[7]?.pressed) { - state.lastInput.rt = false; - } - - // Left trigger (index 6) - Right click - if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) { - virtualClick(true); - state.lastInput.lt = true; - } else if (!gamepad.buttons[6]?.pressed) { - state.lastInput.lt = false; - } - - // Right stick click (index 11) - Toggle cursor speed - if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) { - state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15); - showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`); - state.lastInput.rs = true; - } else if (!gamepad.buttons[11]?.pressed) { - state.lastInput.rs = false; - } - } -} - -// ============================================================================= -// KEYBOARD SHORTCUTS -// ============================================================================= - -function initKeyboardShortcuts() { - document.addEventListener('keydown', (e) => { - // Don't handle if OSK is visible and we're typing - if (state.oskVisible) { - handleOSKKeyboard(e); - return; - } - - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - navigateFocus('up'); - break; - case 'ArrowDown': - e.preventDefault(); - navigateFocus('down'); - break; - case 'ArrowLeft': - e.preventDefault(); - navigateFocus('left'); - break; - case 'ArrowRight': - e.preventDefault(); - navigateFocus('right'); - break; - case 'Enter': - case ' ': - e.preventDefault(); - activateFocused(); - break; - case 'Escape': - case 'Backspace': - e.preventDefault(); - goBack(); - break; - case 'Tab': - // Allow tab navigation - break; - } }); + 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')); } -// ============================================================================= -// 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 => { + if (!keyboard || keyboard.children.length) return; + + ['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm'].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); - }); - + [...row].forEach(char => rowEl.appendChild(createOskKey(char))); 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); - }); - + ['.', '-', '_', '@', '/', ':', '.com'].forEach(char => specialRow.appendChild(createOskKey(char, char === '.com'))); 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()); + 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'); - - // Set input - input.value = typeof options.initialValue === 'string' ? options.initialValue : ''; - - // Reset cursor position updateOSKCursorPosition(); - - // Update label based on mode - if (label) { - if (options.labelText) { - label.textContent = options.labelText; - } else if (mode === 'search') { - label.textContent = 'Search or enter URL'; - } else if (mode === 'bookmark-url') { - label.textContent = 'Bookmark URL'; - } else if (mode === 'bookmark-title') { - label.textContent = 'Bookmark title'; - } else { - label.textContent = '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'); + updateFocusableElements(); + focusElement(overlay.querySelector('.osk-key, [data-focusable]')); + }, 50); } function closeOSK() { - const overlay = document.getElementById('osk-overlay'); - if (!overlay) return; - state.oskVisible = false; - overlay.classList.add('hidden'); - - // Return focus to main content + document.getElementById('osk-overlay')?.classList.add('hidden'); setTimeout(() => { updateFocusableElements(); focusFirstInContent(); - }, 100); + }, 50); } function appendToOSK(char) { const input = document.getElementById('osk-input'); - if (input) { - input.value += char; - updateOSKCursorPosition(); - } + if (!input) return; + 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(); - } + if (!input) return; + input.value = input.value.slice(0, -1); + updateOSKCursorPosition(); } function clearOSK() { const input = document.getElementById('osk-input'); - if (input) { - input.value = ''; - updateOSKCursorPosition(); - playNavSound(); - } + if (!input) return; + input.value = ''; + updateOSKCursorPosition(); } -/** - * 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`; + cursor.style.left = `${32 + measure.offsetWidth}px`; } -async function submitOSK() { - const input = document.getElementById('osk-input'); - if (!input) return; - - const value = input.value; - +function submitOSK() { + const value = document.getElementById('osk-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 + 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 normalized = normalizeBookmarkUrl(value); - if (!normalized) { - showToast('Enter a valid URL'); + const target = toNavigationUrl(value); + if (!target) { + showToast('Enter a URL first'); return; } - state.oskContext = { url: normalized }; - openOSK('bookmark-title', { - labelText: 'Bookmark title', - initialValue: getDomainFromUrl(normalized) - }); + 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) { - closeOSK(); - return; - } - const title = value.trim() || getDomainFromUrl(url); - await addOrUpdateBookmark({ title, url, icon: getFaviconUrl(url) || 'bookmark' }); + if (url) addBookmark({ title: value || getDomainFromUrl(url), url }); state.oskContext = null; } - 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(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 handleOSKKeyboard(e) { - if (e.key === 'Escape') { - e.preventDefault(); - closeOSK(); - } else if (e.key === 'Enter') { - e.preventDefault(); - submitOSK(); - } else if (e.key === 'Backspace') { - backspaceOSK(); - } else if (e.key.length === 1) { - appendToOSK(e.key); - } -} - -// ============================================================================= -// DATA LOADING -// ============================================================================= - -async function loadData() { - await loadBookmarks(); - await loadHistory(); - renderQuickAccess(); - initSettings(); -} - -async function loadBookmarks() { - try { - if (window.bookmarksAPI && typeof window.bookmarksAPI.load === 'function') { - state.bookmarks = await window.bookmarksAPI.load() || []; - } else 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 saveBookmarks(bookmarks) { - try { - if (window.bookmarksAPI && typeof window.bookmarksAPI.save === 'function') { - await window.bookmarksAPI.save(bookmarks); - return true; - } - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('save-bookmarks', bookmarks); - return true; - } - localStorage.setItem('bookmarks', JSON.stringify(bookmarks)); - return true; - } catch (err) { - console.error('[BigPicture] Failed to save bookmarks:', err); - return false; - } -} - -async function loadHistory() { - try { - if (ipcRenderer && ipcRenderer.invoke) { - state.history = await ipcRenderer.invoke('load-site-history') || []; - } else { - // Fallback to localStorage - const stored = localStorage.getItem('siteHistory'); - state.history = stored ? JSON.parse(stored) : []; - } - } catch (err) { - console.error('[BigPicture] Failed to load history:', err); - state.history = []; - } -} - -// Save a site to history -async function saveToHistory(url) { - if (!url || url.startsWith('nebula://')) return; - try { - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('save-site-history-entry', url); - // Refresh history after saving - await loadHistory(); - } else { - // Fallback to localStorage - let history = state.history; - history = history.filter(item => item !== url); - history.unshift(url); - if (history.length > 100) history = history.slice(0, 100); - localStorage.setItem('siteHistory', JSON.stringify(history)); - state.history = history; - } - } catch (err) { - console.error('[BigPicture] Failed to save history:', err); - } -} - -// Clear all browsing history -async function clearHistory() { - try { - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('clear-site-history'); - } else { - localStorage.removeItem('siteHistory'); - } - state.history = []; - showToast('History cleared'); - } catch (err) { - console.error('[BigPicture] Failed to clear history:', err); - showToast('Failed to clear history'); - } -} - -// ============================================================================= -// RENDERING -// ============================================================================= - -function renderQuickAccess() { - const grid = document.getElementById('quickAccessGrid'); - if (!grid) return; - - grid.innerHTML = ''; - - CONFIG.DEFAULT_QUICK_ACCESS.forEach(site => { - const tile = createTile(site.title, site.url, site.icon); - grid.appendChild(tile); - }); - - // Add "Add" tile - const addTile = document.createElement('div'); - addTile.className = 'tile add-tile'; - addTile.dataset.focusable = ''; - addTile.tabIndex = 0; - addTile.innerHTML = `add`; - addTile.addEventListener('click', () => startAddBookmark()); - grid.appendChild(addTile); - - updateFocusableElements(); -} - -function renderBookmarks() { - const grid = document.getElementById('bookmarksGrid'); - if (!grid) return; - - grid.innerHTML = ''; - - if (state.bookmarks.length === 0) { - grid.innerHTML = ` -
- bookmark_border -

No bookmarks yet

-

Add a bookmark here or in desktop mode

-
- `; - const addTile = createAddBookmarkTile(); - grid.appendChild(addTile); - updateFocusableElements(); - return; - } - - state.bookmarks.forEach(bookmark => { - const tile = createBookmarkTile(bookmark); - grid.appendChild(tile); - }); - - const addTile = createAddBookmarkTile(); - grid.appendChild(addTile); - - updateFocusableElements(); -} - -function createAddBookmarkTile() { - const addTile = document.createElement('div'); - addTile.className = 'tile add-tile'; - addTile.dataset.focusable = ''; - addTile.tabIndex = 0; - addTile.innerHTML = `bookmark_add`; - addTile.addEventListener('click', () => startAddBookmark()); - return addTile; -} - -function createBookmarkTile(bookmark) { - const tile = document.createElement('div'); - tile.className = 'tile bookmark-tile'; - tile.dataset.focusable = ''; - tile.tabIndex = 0; - tile.dataset.url = bookmark.url; - - const title = bookmark.title || bookmark.name || getDomainFromUrl(bookmark.url); - const icon = bookmark.icon || 'bookmark'; - - // Check if icon is a URL (favicon) or a material icon name - const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); - - let iconHtml; - if (isIconUrl) { - iconHtml = ``; - } else { - iconHtml = `${escapeHtml(icon)}`; - } - - tile.innerHTML = ` -
- ${iconHtml} -
-
${escapeHtml(title)}
-
${getDomainFromUrl(bookmark.url)}
- `; - - tile.addEventListener('click', () => navigateTo(bookmark.url)); - - return tile; +function navigateTo(url) { + postCommand('navigate', url); + switchSection('browse'); } function startAddBookmark() { @@ -1618,1396 +810,224 @@ function startAddBookmark() { } function addBookmarkFromCurrentPage() { - const webview = state.currentWebview; - if (!webview) { - showToast('No active page to bookmark'); - return; - } - - const url = typeof webview.getURL === 'function' ? webview.getURL() : webview.src; + const url = state.browser.url; if (!url) { showToast('No active page to bookmark'); return; } - - const title = typeof webview.getTitle === 'function' ? webview.getTitle() : getDomainFromUrl(url); - addOrUpdateBookmark({ title, url, icon: getFaviconUrl(url) || 'bookmark' }); + addBookmark({ title: state.browser.title || getDomainFromUrl(url), url }); } -async function addOrUpdateBookmark(entry) { - const normalized = normalizeBookmarkUrl(entry.url); - if (!normalized) { - showToast('Enter a valid URL'); - return false; - } - - const title = (entry.title || '').trim() || getDomainFromUrl(normalized); - const icon = entry.icon || getFaviconUrl(normalized) || 'bookmark'; - - const existingIndex = state.bookmarks.findIndex(b => - (b.url || '').toLowerCase() === normalized.toLowerCase() - ); - - if (existingIndex >= 0) { - state.bookmarks[existingIndex] = { - ...state.bookmarks[existingIndex], - title, - url: normalized, - icon - }; - } else { - state.bookmarks.unshift({ title, url: normalized, icon }); - } - - const saved = await saveBookmarks(state.bookmarks); - if (saved) { - renderBookmarks(); - showToast(existingIndex >= 0 ? 'Bookmark updated' : 'Bookmark added'); - } else { - showToast('Failed to save bookmark'); - } - - return saved; -} - -function renderHistory() { - const list = document.getElementById('historyList'); - if (!list) return; - - list.innerHTML = ''; - - if (state.history.length === 0) { - list.innerHTML = ` -
- history -

No browsing history

-

Sites you visit will appear here

-
- `; - return; - } - - // Show last 30 items - state.history.slice(0, 30).forEach(url => { - const item = createHistoryItem(url); - list.appendChild(item); - }); - +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 createHistoryItem(url) { - const item = document.createElement('div'); - item.className = 'list-item history-item'; - item.dataset.focusable = ''; - item.tabIndex = 0; - item.dataset.url = url; - - const domain = getDomainFromUrl(url); - const faviconUrl = getFaviconUrl(url); - - item.innerHTML = ` -
- - -
-
-
${escapeHtml(domain)}
-
${escapeHtml(url)}
-
-
- A -
- `; - - item.addEventListener('click', () => navigateTo(url)); - - return item; -} - -function renderRecentSites() { - const container = document.getElementById('recentSitesScroll'); - if (!container) return; - - container.innerHTML = ''; - - if (state.history.length === 0) { - container.innerHTML = ` -
- web -

Start browsing to see recent sites

-
- `; - return; - } - - // Show last 10 unique domains - const seenDomains = new Set(); - const uniqueSites = []; - - for (const url of state.history) { - const domain = getDomainFromUrl(url); - if (!seenDomains.has(domain)) { - seenDomains.add(domain); - uniqueSites.push({ url, domain }); - if (uniqueSites.length >= 10) break; - } - } - - uniqueSites.forEach(site => { - const card = createScrollCard(site.domain, site.url); - container.appendChild(card); - }); - - updateFocusableElements(); -} - -function createTile(title, url, icon, useFavicon = false) { - const tile = document.createElement('div'); - tile.className = 'tile'; - tile.dataset.focusable = ''; - tile.tabIndex = 0; - tile.dataset.url = url; - - let iconHtml; - const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); - - if (isIconUrl || useFavicon) { - const faviconUrl = isIconUrl ? icon : getFaviconUrl(url); - iconHtml = ``; - } else { - iconHtml = `${escapeHtml(icon)}`; - } - - tile.innerHTML = ` -
- ${iconHtml} -
-
${escapeHtml(title)}
-
${getDomainFromUrl(url)}
- `; - - tile.addEventListener('click', () => navigateTo(url)); - - return tile; -} - -function getFaviconUrl(url) { - try { - const urlObj = new URL(url); - return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`; - } catch { - return ''; - } -} - -function createListItem(title, url) { - const item = document.createElement('div'); - item.className = 'list-item'; - item.dataset.focusable = ''; - item.tabIndex = 0; - item.dataset.url = url; - - item.innerHTML = ` -
- public -
-
-
${escapeHtml(title)}
-
${escapeHtml(url)}
-
-
- A -
- `; - - item.addEventListener('click', () => navigateTo(url)); - - return item; -} - -function createScrollCard(title, url) { - const card = document.createElement('div'); - card.className = 'scroll-card'; - card.dataset.focusable = ''; - card.tabIndex = 0; - card.dataset.url = url; - - const faviconUrl = getFaviconUrl(url); - - card.innerHTML = ` -
- - -
-
${escapeHtml(title)}
-
Recently visited
- `; - - card.addEventListener('click', () => navigateTo(url)); - - return card; -} - -// ============================================================================= -// ACTIONS -// ============================================================================= - -function performSearch(query) { - if (!query.trim()) return; - - // Check if it's a URL - let url = query.trim(); - if (isUrl(url)) { - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } - navigateTo(url); - } else { - // Search with default engine (Google) - navigateTo(`https://www.google.com/search?q=${encodeURIComponent(query)}`); - } -} - -function navigateTo(url) { - console.log('[BigPicture] Navigating to:', url); - - // Create or reuse webview for browsing - const container = document.getElementById('webview-container'); - if (!container) return; - - // Hide content and show webview - document.querySelectorAll('.bp-section').forEach(s => s.classList.remove('active')); - container.classList.remove('hidden'); - - // Remove existing webview if any - const existingWebview = container.querySelector('webview'); - if (existingWebview) { - existingWebview.remove(); - } - - // Create new webview - const webview = document.createElement('webview'); - webview.src = url; - webview.style.width = '100%'; - webview.style.height = '100%'; - webview.style.border = 'none'; - const preloadPath = window.electronAPI?.getWebviewPreloadPath?.(); - if (preloadPath) { - webview.setAttribute('preload', preloadPath); - } else { - webview.setAttribute('preload', '../preload.js'); - } - webview.partition = 'persist:main'; - webview.allowpopups = true; - webview.setAttribute('webpreferences', 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'); - - container.appendChild(webview); - state.currentWebview = webview; - state.webviewContentsId = null; // Will be set when webview is ready - - // Save initial URL to history - saveToHistory(url); - - // Get webContentsId when webview is ready for native input events - webview.addEventListener('dom-ready', () => { - try { - // getWebContentsId is available on webview element - state.webviewContentsId = webview.getWebContentsId(); - console.log('[BigPicture] WebContents ID:', state.webviewContentsId); - - // Apply scroll normalization for consistent scroll speed - applyScrollNormalization(webview); - - // Inject script to detect input field focus and notify the host - injectInputFocusDetection(webview); - } catch (err) { - console.log('[BigPicture] Could not get webContentsId:', err); - } - }); - - // Save navigation to history - webview.addEventListener('did-navigate', (event) => { - const newUrl = event.url; - if (newUrl && !newUrl.startsWith('about:')) { - saveToHistory(newUrl); - } - }); - - // Also save history on in-page navigations (e.g., SPA navigations) - webview.addEventListener('did-navigate-in-page', (event) => { - if (event.isMainFrame) { - const newUrl = event.url; - if (newUrl && !newUrl.startsWith('about:')) { - saveToHistory(newUrl); - } - } - }); - - // Listen for IPC messages from webview (for OSK requests) - webview.addEventListener('ipc-message', (event) => { - if (event.channel === 'bigpicture-input-focused') { - // Input field was clicked/focused in webview - show OSK for webview input - console.log('[BigPicture] Input focused in webview'); - openOSKForWebview(); - } - }); - - // Enable virtual cursor for webview interaction - enableCursor(); - - // Switch section to browse - switchSection('browse'); - - // Update focusable elements to include webview controls - setTimeout(() => { - updateFocusableElements(); - }, 100); -} - -/** - * Inject script to detect input focus in webview and send message to host - */ -function injectInputFocusDetection(webview) { - const script = ` - (function() { - if (window.__bigPictureInputDetection) return; - window.__bigPictureInputDetection = true; - - // Track the last focused input - let lastFocusedInput = null; - - document.addEventListener('focusin', (e) => { - const el = e.target; - const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || - el.contentEditable === 'true' || el.isContentEditable || - el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox'; - - // Check input type - exclude non-text inputs - if (el.tagName === 'INPUT') { - const type = el.type.toLowerCase(); - if (['checkbox', 'radio', 'submit', 'button', 'image', 'file', 'hidden', 'reset', 'range', 'color'].includes(type)) { - return; - } - } - - if (isInput) { - lastFocusedInput = el; - // Send message to host (Big Picture Mode) to show OSK - try { - if (window.electronAPI && window.electronAPI.sendToHost) { - window.electronAPI.sendToHost('bigpicture-input-focused', { - type: el.tagName, - inputType: el.type || 'text', - value: el.value || '' - }); - } - } catch(e) { - console.log('BigPicture: Could not notify input focus', e); - } - } - }, true); - - // Listen for text input from OSK - window.addEventListener('message', (e) => { - if (e.data && e.data.type === 'bigpicture-osk-input' && lastFocusedInput) { - lastFocusedInput.value = e.data.value; - lastFocusedInput.dispatchEvent(new Event('input', { bubbles: true })); - lastFocusedInput.dispatchEvent(new Event('change', { bubbles: true })); - } else if (e.data && e.data.type === 'bigpicture-osk-submit' && lastFocusedInput) { - // Submit the form or trigger search - const form = lastFocusedInput.closest('form'); - if (form) { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - // Also try clicking any submit button - const submitBtn = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); - if (submitBtn) submitBtn.click(); - } - // Trigger Enter key event - lastFocusedInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); - lastFocusedInput.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true })); - lastFocusedInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); - } - }); - - console.log('[BigPicture] Input focus detection injected'); - })(); - `; - - webview.executeJavaScript(script).catch(err => { - console.log('[BigPicture] Could not inject input detection:', err); - }); -} - -function exitBigPictureMode() { - console.log('[BigPicture] Exiting Big Picture Mode'); - - if (ipcRenderer && typeof ipcRenderer.send === 'function') { - ipcRenderer.send('exit-bigpicture'); - } else if (window.opener) { - window.opener.postMessage({ type: 'exit-bigpicture' }, '*'); - window.close(); - } else { - window.location.href = 'home.html'; - } -} - -function handleSettingsAction(action) { - switch (action) { - case 'theme': - switchSettingsTab('themes'); - break; - case 'privacy': - switchSettingsTab('privacy'); - break; - case 'display': - switchSettingsTab('display'); - break; - case 'exit-bigpicture': - exitBigPictureMode(); - break; - default: - console.log('[BigPicture] Unknown settings action:', action); - } -} - -// ============================================================================= -// SETTINGS FUNCTIONALITY -// ============================================================================= - -const DISPLAY_SCALE_KEY = 'nebula-display-scale'; -let currentDisplayScale = 100; -let currentThemeName = 'default'; - -// Theme definitions (matching customization.js) -const THEMES = { - default: { - name: 'Default', - colors: { - bg: '#121418', - darkPurple: '#1B1035', - primary: '#7B2EFF', - accent: '#00C6FF', - text: '#E0E0E0' - } - }, - ocean: { - name: 'Ocean', - colors: { - bg: '#1a365d', - darkPurple: '#2c5282', - primary: '#3182ce', - accent: '#00d9ff', - text: '#e2e8f0' - } - }, - forest: { - name: 'Forest', - colors: { - bg: '#1a202c', - darkPurple: '#2d3748', - primary: '#68d391', - accent: '#9ae6b4', - text: '#f7fafc' - } - }, - sunset: { - name: 'Sunset', - colors: { - bg: '#744210', - darkPurple: '#c05621', - primary: '#ed8936', - accent: '#fbb040', - text: '#fffaf0' - } - }, - cyberpunk: { - name: 'Cyberpunk', - colors: { - bg: '#0a0a0a', - darkPurple: '#2a0a3a', - primary: '#ff0080', - accent: '#00ffff', - text: '#ffffff' - } - }, - 'midnight-rose': { - name: 'Midnight Rose', - colors: { - bg: '#1c1820', - darkPurple: '#3d3046', - primary: '#d4af37', - accent: '#ffd700', - text: '#f5f5dc' - } - }, - 'arctic-ice': { - name: 'Arctic Ice', - colors: { - bg: '#f0f8ff', - darkPurple: '#d1e7ff', - primary: '#4169e1', - accent: '#87ceeb', - text: '#2f4f4f' - } - }, - 'cherry-blossom': { - name: 'Cherry Blossom', - colors: { - bg: '#fff5f8', - darkPurple: '#ffd4db', - primary: '#ff69b4', - accent: '#ffb6c1', - text: '#8b4513' - } - }, - 'cosmic-purple': { - name: 'Cosmic Purple', - colors: { - bg: '#0f0524', - darkPurple: '#2d1b69', - primary: '#9400d3', - accent: '#da70d6', - text: '#e6e6fa' - } - }, - 'emerald-dream': { - name: 'Emerald Dream', - colors: { - bg: '#0d2818', - darkPurple: '#2d5a44', - primary: '#50c878', - accent: '#00fa9a', - text: '#f0fff0' - } - }, - 'mocha-coffee': { - name: 'Mocha Coffee', - colors: { - bg: '#3c2414', - darkPurple: '#5d3a26', - primary: '#d2691e', - accent: '#deb887', - text: '#faf0e6' - } - }, - 'lavender-fields': { - name: 'Lavender Fields', - colors: { - bg: '#f8f4ff', - darkPurple: '#e6d8ff', - primary: '#9370db', - accent: '#dda0dd', - text: '#4b0082' - } - } -}; - -function initSettings() { - console.log('[BigPicture] Initializing settings...'); - - // Load saved settings - loadSavedSettings(); - - // Initialize settings tabs - initSettingsTabs(); - - // Initialize theme selection - initThemeSelection(); - - // Initialize display scale controls - initDisplayScaleControls(); - - // Initialize privacy controls - initPrivacyControls(); - - // Initialize about panel - initAboutPanel(); -} - -function loadSavedSettings() { - // Load display scale - try { - const savedScale = localStorage.getItem(DISPLAY_SCALE_KEY); - if (savedScale) { - const parsed = parseInt(savedScale, 10); - if (Number.isFinite(parsed)) { - currentDisplayScale = Math.min(300, Math.max(50, parsed)); - updateScaleDisplay(); - applyDisplayScale(currentDisplayScale, 'loadSavedSettings'); - } - } - } catch (err) { - console.warn('[BigPicture] Failed to load display scale:', err); - } - - // Load theme - try { - const savedTheme = localStorage.getItem('nebula-theme-name'); - if (savedTheme && THEMES[savedTheme]) { - currentThemeName = savedTheme; - applyTheme(THEMES[savedTheme]); - highlightActiveTheme(); - } - } catch (err) { - console.warn('[BigPicture] Failed to load theme:', err); - } -} - -function initSettingsTabs() { - document.querySelectorAll('.settings-tab').forEach(tab => { - tab.addEventListener('click', () => { - const tabName = tab.dataset.settingsTab; - if (tabName) { - switchSettingsTab(tabName); - } - }); - }); +function clearHistory() { + postCommand('clear-site-history'); + state.history = []; + renderBrowseStatus(); + renderHistory(); + showToast('History cleared'); } function switchSettingsTab(tabName) { - // Update tab buttons - document.querySelectorAll('.settings-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.settingsTab === tabName); - }); - - // Update panels - document.querySelectorAll('.settings-panel').forEach(panel => { - panel.classList.toggle('active', panel.id === `settings-panel-${tabName}`); - }); - - // Update focusable elements + 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); - - playNavSound(); -} - -function initThemeSelection() { - document.querySelectorAll('.theme-card').forEach(card => { - card.addEventListener('click', () => { - const themeName = card.dataset.theme; - if (themeName && THEMES[themeName]) { - selectTheme(themeName); - } - }); - }); - - // Highlight current theme - highlightActiveTheme(); -} - -function selectTheme(themeName) { - if (!THEMES[themeName]) return; - - currentThemeName = themeName; - const theme = THEMES[themeName]; - - // Apply theme locally - applyTheme(theme); - - // Save to localStorage - try { - localStorage.setItem('nebula-theme-name', themeName); - - // Also save the full theme data for other pages - const fullThemeData = { - name: theme.name, - colors: { - bg: theme.colors.bg, - darkBlue: theme.colors.darkPurple, - darkPurple: theme.colors.darkPurple, - primary: theme.colors.primary, - accent: theme.colors.accent, - text: theme.colors.text, - urlBarBg: theme.colors.darkPurple, - urlBarText: theme.colors.text, - urlBarBorder: theme.colors.primary, - tabBg: theme.colors.darkPurple, - tabText: theme.colors.text, - tabActive: theme.colors.bg, - tabActiveText: theme.colors.text, - tabBorder: theme.colors.bg - }, - gradient: `linear-gradient(145deg, ${theme.colors.bg} 0%, ${theme.colors.darkPurple} 100%)` - }; - localStorage.setItem('browserTheme', JSON.stringify(fullThemeData)); - } catch (err) { - console.warn('[BigPicture] Failed to save theme:', err); - } - - // Notify main process - if (ipcRenderer && ipcRenderer.send) { - ipcRenderer.send('theme-changed', { - name: themeName, - colors: theme.colors - }); - } - - highlightActiveTheme(); - showToast(`Theme changed to ${theme.name}`); - playSelectSound(); -} - -function highlightActiveTheme() { - document.querySelectorAll('.theme-card').forEach(card => { - card.classList.toggle('active', card.dataset.theme === currentThemeName); - }); -} - -function initDisplayScaleControls() { - const scaleDown = document.getElementById('bp-scale-down'); - const scaleUp = document.getElementById('bp-scale-up'); - const exitDesktop = document.getElementById('bp-exit-desktop'); - - if (scaleDown) { - scaleDown.addEventListener('click', () => { - adjustDisplayScale(-10); - }); - } - - if (scaleUp) { - scaleUp.addEventListener('click', () => { - adjustDisplayScale(10); - }); - } - - if (exitDesktop) { - exitDesktop.addEventListener('click', () => { - exitBigPictureMode(); - }); - } - - updateScaleDisplay(); - applyDisplayScale(currentDisplayScale, 'initDisplayScaleControls'); -} - -function adjustDisplayScale(delta) { - const newScale = Math.min(300, Math.max(50, currentDisplayScale + delta)); - if (newScale !== currentDisplayScale) { - currentDisplayScale = newScale; - updateScaleDisplay(); - saveDisplayScale(); - showToast(`Display scale: ${currentDisplayScale}%`); - playNavSound(); - } -} - -function updateScaleDisplay() { - const scaleValue = document.getElementById('bp-scale-value'); - if (scaleValue) { - scaleValue.textContent = `${currentDisplayScale}%`; - } -} - -function saveDisplayScale() { - try { - localStorage.setItem(DISPLAY_SCALE_KEY, currentDisplayScale.toString()); - - // Apply zoom immediately to Big Picture UI. - applyDisplayScale(currentDisplayScale, 'saveDisplayScale'); - - // Notify main process (legacy channel) for compatibility. - if (ipcRenderer && typeof ipcRenderer.send === 'function') { - ipcRenderer.send('set-display-scale', currentDisplayScale); - } - } catch (err) { - console.warn('[BigPicture] Failed to save display scale:', err); - } -} - -function initPrivacyControls() { - const clearDataBtn = document.getElementById('bp-clear-data'); - const clearHistoryBtn = document.getElementById('bp-clear-history'); - const clearSearchBtn = document.getElementById('bp-clear-search'); - - if (clearDataBtn) { - clearDataBtn.addEventListener('click', async () => { - if (await confirmAction('Clear all browsing data? This cannot be undone.')) { - await clearAllBrowsingData(); - } - }); - } - - if (clearHistoryBtn) { - clearHistoryBtn.addEventListener('click', async () => { - if (await confirmAction('Clear browsing history?')) { - await clearBrowsingHistory(); - } - }); - } - - if (clearSearchBtn) { - clearSearchBtn.addEventListener('click', async () => { - if (await confirmAction('Clear search history?')) { - await clearSearchHistory(); - } - }); - } -} - -async function confirmAction(message) { - // Simple confirmation using toast - could be enhanced with a modal - showToast(message + ' Press A to confirm.'); - return true; // For now, auto-confirm. Could implement modal confirmation. -} - -async function clearAllBrowsingData() { - try { - showToast('Clearing all browsing data...'); - - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('clear-browser-data'); - } - - // Also clear localStorage - localStorage.removeItem('siteHistory'); - state.history = []; - - showToast('All browsing data cleared'); - playSelectSound(); - } catch (err) { - console.error('[BigPicture] Failed to clear browsing data:', err); - showToast('Failed to clear data'); - } -} - -async function clearBrowsingHistory() { - try { - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('clear-site-history'); - } - - localStorage.removeItem('siteHistory'); - state.history = []; - - showToast('Browsing history cleared'); - playSelectSound(); - } catch (err) { - console.error('[BigPicture] Failed to clear history:', err); - showToast('Failed to clear history'); - } -} - -async function clearSearchHistory() { - try { - if (ipcRenderer && ipcRenderer.invoke) { - await ipcRenderer.invoke('clear-search-history'); - } - - showToast('Search history cleared'); - playSelectSound(); - } catch (err) { - console.error('[BigPicture] Failed to clear search history:', err); - showToast('Failed to clear search history'); - } -} - -async function initAboutPanel() { - // Load version info - try { - if (ipcRenderer && ipcRenderer.invoke) { - const appInfo = await ipcRenderer.invoke('get-app-info'); - - if (appInfo) { - const versionEl = document.getElementById('bp-version'); - const electronEl = document.getElementById('bp-electron-version'); - const chromiumEl = document.getElementById('bp-chromium-version'); - const nodeEl = document.getElementById('bp-node-version'); - const platformEl = document.getElementById('bp-platform'); - - if (versionEl) versionEl.textContent = `Version ${appInfo.version || 'Unknown'}`; - if (electronEl) electronEl.textContent = appInfo.electron || '--'; - if (chromiumEl) chromiumEl.textContent = appInfo.chrome || '--'; - if (nodeEl) nodeEl.textContent = appInfo.node || '--'; - if (platformEl) platformEl.textContent = `${appInfo.platform || ''} ${appInfo.arch || ''}`.trim() || '--'; - } - } - } catch (err) { - console.warn('[BigPicture] Failed to load app info:', err); - } - - // GitHub link - const githubBtn = document.getElementById('bp-github-link'); - if (githubBtn) { - githubBtn.addEventListener('click', () => { - navigateTo('https://github.com/Bobbybear007/NebulaBrowser'); - }); - } - - // Copy diagnostics - const copyBtn = document.getElementById('bp-copy-diagnostics'); - if (copyBtn) { - copyBtn.addEventListener('click', async () => { - await copyDiagnostics(); - }); - } -} - -async function copyDiagnostics() { - try { - const versionEl = document.getElementById('bp-version'); - const electronEl = document.getElementById('bp-electron-version'); - const chromiumEl = document.getElementById('bp-chromium-version'); - const nodeEl = document.getElementById('bp-node-version'); - const platformEl = document.getElementById('bp-platform'); - - const diagnostics = [ - 'Nebula Browser Diagnostics', - '========================', - versionEl ? versionEl.textContent : '', - `Electron: ${electronEl ? electronEl.textContent : '--'}`, - `Chromium: ${chromiumEl ? chromiumEl.textContent : '--'}`, - `Node.js: ${nodeEl ? nodeEl.textContent : '--'}`, - `Platform: ${platformEl ? platformEl.textContent : '--'}`, - `Date: ${new Date().toISOString()}` - ].join('\n'); - - await navigator.clipboard.writeText(diagnostics); - showToast('Diagnostics copied to clipboard'); - playSelectSound(); - } catch (err) { - console.error('[BigPicture] Failed to copy diagnostics:', err); - showToast('Failed to copy diagnostics'); - } -} - -// ============================================================================= -// UTILITIES -// ============================================================================= - -function normalizeBookmarkUrl(raw) { - if (!raw || !raw.trim()) return null; - let url = raw.trim(); - - if (url.startsWith('nebula://')) return url; - - // Add protocol if missing - if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { - url = `https://${url}`; - } - - if (!isUrl(url)) return null; - return url; -} - -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('nebula://'); -} - -// ============================================================================= -// VIRTUAL CURSOR (for webview interaction) -// ============================================================================= - -function createCursorElement() { - if (state.cursorElement) return; - - const cursor = document.createElement('div'); - cursor.id = 'virtual-cursor'; - cursor.className = 'virtual-cursor'; - cursor.innerHTML = ` - - - -
- `; - document.body.appendChild(cursor); - state.cursorElement = cursor; -} - -function enableCursor() { - if (!state.cursorElement) { - createCursorElement(); - } - - const container = document.getElementById('webview-container'); - if (container) { - const rect = container.getBoundingClientRect(); - state.cursorX = rect.left + rect.width / 2; - state.cursorY = rect.top + rect.height / 2; - } else { - state.cursorX = window.innerWidth / 2; - state.cursorY = window.innerHeight / 2; - } - - state.cursorEnabled = true; - updateCursorPosition(); - state.cursorElement.classList.add('active'); - - // Update focusable elements to only include sidebar when in webview mode - updateFocusableElements(); - - // Show cursor hint - showToast('🎮 Right stick: Move cursor | RT: Click | Left stick: Scroll | B: Back'); -} - -function disableCursor() { - state.cursorEnabled = false; - if (state.cursorElement) { - state.cursorElement.classList.remove('active'); - } - - // Restore full focusable elements - updateFocusableElements(); -} - -function moveCursor(dx, dy) { - if (!state.cursorEnabled) return; - - const container = document.getElementById('webview-container'); - if (!container) return; - - const rect = container.getBoundingClientRect(); - - // Update cursor position with bounds checking - state.cursorX = Math.max(rect.left, Math.min(rect.right - 10, state.cursorX + dx)); - state.cursorY = Math.max(rect.top, Math.min(rect.bottom - 10, state.cursorY + dy)); - - updateCursorPosition(); -} - -function updateCursorPosition() { - if (!state.cursorElement) return; - - state.cursorElement.style.left = `${state.cursorX}px`; - state.cursorElement.style.top = `${state.cursorY}px`; -} - -function virtualClick(rightClick = false) { - if (!state.currentWebview || !state.cursorEnabled) return; - - const container = document.getElementById('webview-container'); - if (!container) return; - - const containerRect = container.getBoundingClientRect(); - - // Calculate position relative to webview - const x = Math.round(state.cursorX - containerRect.left); - const y = Math.round(state.cursorY - containerRect.top); - - // Show click animation - if (state.cursorElement) { - state.cursorElement.classList.add('clicking'); - setTimeout(() => state.cursorElement.classList.remove('clicking'), 150); - } - - const webview = state.currentWebview; - - // Try to use native input event injection via IPC (most reliable for complex sites) - if (state.webviewContentsId && window.bigPictureAPI && window.bigPictureAPI.sendInputEvent) { - const sendNativeClick = async () => { - try { - // Send mouseMove first to position the cursor - await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { - type: 'mouseMove', - x: x, - y: y - }); - - // Small delay then send mouseDown - await new Promise(r => setTimeout(r, 10)); - - await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { - type: 'mouseDown', - x: x, - y: y, - button: rightClick ? 'right' : 'left', - clickCount: 1 - }); - - // Small delay then send mouseUp - await new Promise(r => setTimeout(r, 50)); - - await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { - type: 'mouseUp', - x: x, - y: y, - button: rightClick ? 'right' : 'left', - clickCount: 1 - }); - - console.log('[BigPicture] Native click sent at', x, y); - } catch (err) { - console.log('[BigPicture] Native input error, falling back to JS:', err); - fallbackJavaScriptClick(webview, x, y, rightClick); - } - }; - - sendNativeClick(); - return; - } - - // Fallback to JavaScript injection - fallbackJavaScriptClick(webview, x, y, rightClick); -} - -function fallbackJavaScriptClick(webview, x, y, rightClick) { - try { - if (rightClick) { - // For right-click, use JavaScript injection - const rightClickScript = ` - (function() { - const el = document.elementFromPoint(${x}, ${y}); - if (el) { - const event = new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - clientX: ${x}, - clientY: ${y}, - button: 2 - }); - el.dispatchEvent(event); - } - })(); - `; - webview.executeJavaScript(rightClickScript).catch(err => { - console.log('[BigPicture] Right-click injection error:', err); - }); - } else { - // Comprehensive JavaScript injection with pointer events - const clickScript = ` - (function() { - const x = ${x}; - const y = ${y}; - const el = document.elementFromPoint(x, y); - if (!el) return; - - // Check if we're clicking on YouTube player area - const isYouTubePlayer = el.closest('.html5-video-player') || - el.closest('.ytp-player') || - el.closest('#movie_player') || - el.closest('.html5-main-video') || - el.closest('.video-stream') || - (window.location.hostname.includes('youtube.com') && - (el.tagName === 'VIDEO' || el.closest('#player'))); - - if (isYouTubePlayer) { - // For YouTube player, directly toggle playback - const video = document.querySelector('video.html5-main-video') || - document.querySelector('video.video-stream') || - document.querySelector('#movie_player video') || - document.querySelector('video'); - if (video) { - if (video.paused) { - video.play().catch(() => {}); - } else { - video.pause(); - } - return; - } - } - - // Find the actual clickable element (may be parent) - let clickTarget = el; - let current = el; - for (let i = 0; i < 10 && current; i++) { - if (current.tagName === 'A' || current.tagName === 'BUTTON' || - current.onclick || current.getAttribute('role') === 'button' || - window.getComputedStyle(current).cursor === 'pointer') { - clickTarget = current; - break; - } - current = current.parentElement; - } - - // Common event options - const eventOptions = { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - screenX: x, - screenY: y, - button: 0, - buttons: 1, - pointerId: 1, - pointerType: 'mouse', - isPrimary: true, - pressure: 0.5, - width: 1, - height: 1 - }; - - // Handle input elements specially - focus first - const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || - el.contentEditable === 'true' || el.isContentEditable || - el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox' || - el.closest('[contenteditable="true"]'); - - if (isInput) { - // Focus the input element - el.focus(); - // Dispatch proper focus sequence - el.dispatchEvent(new FocusEvent('focus', { bubbles: true })); - el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - // Dispatch click to activate any click handlers - el.dispatchEvent(new MouseEvent('click', eventOptions)); - return; - } - - // For general video elements (not YouTube specific) - if (el.tagName === 'VIDEO') { - if (el.paused) { - el.play().catch(() => {}); - } else { - el.pause(); - } - return; - } - - // Dispatch pointer events (used by modern sites) - try { - clickTarget.dispatchEvent(new PointerEvent('pointerdown', eventOptions)); - clickTarget.dispatchEvent(new PointerEvent('pointerup', eventOptions)); - } catch(e) {} - - // Dispatch mouse events - clickTarget.dispatchEvent(new MouseEvent('mousedown', eventOptions)); - clickTarget.dispatchEvent(new MouseEvent('mouseup', eventOptions)); - clickTarget.dispatchEvent(new MouseEvent('click', eventOptions)); - - // Direct click as final fallback - if (clickTarget.click) clickTarget.click(); - })(); - `; - - webview.executeJavaScript(clickScript).catch(err => { - console.log('[BigPicture] Click injection error:', err); - }); - } - } catch (err) { - console.log('[BigPicture] Virtual click error:', err); - } -} - -function scrollWebview(amountY, amountX = 0) { - if (!state.currentWebview) return; - - try { - state.currentWebview.executeJavaScript(`window.scrollBy(${amountX}, ${amountY})`); - } catch (err) { - console.log('[BigPicture] Scroll error:', err); - } -} - -// ============================================================================= -// UTILITIES -// ============================================================================= - -function getDomainFromUrl(url) { - try { - if (url.startsWith('nebula://')) { - return url.replace('nebula://', '').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 && typeof ipcRenderer.on === 'function') { - // Listen for theme changes - ipcRenderer.on('theme-changed', (theme) => { - if (theme && theme.colors) { - applyTheme(theme); - } - }); } function applyTheme(theme) { - if (!theme || !theme.colors) return; - + if (!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); + 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); } -console.log('[BigPicture] Module loaded'); +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); +}); diff --git a/ui/pages/bigpicture.html b/ui/pages/bigpicture.html index f8c21e4..20a1b21 100644 --- a/ui/pages/bigpicture.html +++ b/ui/pages/bigpicture.html @@ -26,6 +26,10 @@
+ +
@@ -41,6 +45,9 @@
+ + sports_esports + wifi @@ -72,6 +79,10 @@ bookmarks Bookmarks +
+ +
+
+

History

+

Recently visited sites from this profile

+
+
+ +
+
+
+ history +

No browsing history

+
+
+
+
@@ -332,7 +363,7 @@
- @@ -403,28 +434,28 @@