diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml index 7be0e25f..a151b327 100644 --- a/.github/workflows/host-tests.yml +++ b/.github/workflows/host-tests.yml @@ -41,7 +41,8 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends \ - g++ cmake ninja-build libsdl2-dev libgl1-mesa-dev libcurl4-openssl-dev xvfb xauth + g++ cmake ninja-build pkg-config python3 imagemagick libjansson-dev \ + libsdl2-dev libgl1-mesa-dev libcurl4-openssl-dev xvfb xauth - name: Build native UI layout test run: | diff --git a/README.md b/README.md index 35d792fd..c897f8da 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ Install and setup guidance lives in the [OpenNOW documentation](https://opennow.zortos.me). Nintendo Switch installation requires custom firmware and Homebrew Menu. +> [!IMPORTANT] +> Overclock the Switch for the best streaming performance. At stock clocks the +> stream hovers between 45 and 50 FPS instead of a steady 60 FPS. + > [!NOTE] > The application currently retains the inherited `SwitchNOW.nro` filename and > `sdmc:/switch/SwitchNOW/` data directory for compatibility. Renaming either diff --git a/app/src/StreamView.cpp b/app/src/StreamView.cpp index 13b9aad0..fee4ec92 100644 --- a/app/src/StreamView.cpp +++ b/app/src/StreamView.cpp @@ -31,12 +31,12 @@ StreamView::StreamView( const std::vector& ice_servers, const opennow::GfnClient& client, const opennow::AuthSession& auth, - const std::string& game_title) + const std::string& game_title, + const opennow::StreamSettings& stream_settings) : client_(client) , auth_(auth) , cloud_session_id_(session_id) , game_title_(game_title) { - const auto stream_settings = opennow::LoadStreamSettings(); debug_diagnostics_ = stream_settings.debug_diagnostics; stats_overlay_enabled_ = stream_settings.stats_overlay_enabled; stream_codec_ = stream_settings.codec; @@ -66,7 +66,8 @@ StreamView::StreamView( session_id, media_ip, media_port, - ice_servers); + ice_servers, + stream_settings); session_->start(); network_monitor_.start(); RefreshNetworkInfo(stream_started_at_); @@ -82,6 +83,7 @@ StreamView::StreamView( } StreamView::~StreamView() { + RecordLifecycleEvent("destroy"); network_monitor_.request_stop(); opennow::SetSensitiveInputLoggingSuppressed(false); std::fill(nte_credentials_.password.begin(), nte_credentials_.password.end(), '\0'); @@ -123,6 +125,7 @@ void StreamView::onFocusLost() { void StreamView::ExitStream() { if (exit_requested_) return; + RecordLifecycleEvent("exit"); exit_requested_ = true; network_monitor_.request_stop(); if (session_) @@ -139,6 +142,30 @@ void StreamView::ExitStream() { } } +void StreamView::RecordLifecycleEvent(const char* event) { + if (!debug_diagnostics_ || !session_) + return; + + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - stream_started_at_).count(); + const auto network = network_monitor_.snapshot(); + const auto health = session_->get_transport_health(); + const auto video = session_->get_video_performance(); + session_->record_ui_event( + std::string("stream_lifecycle event=") + event + + " reason=" + std::to_string(static_cast(stream_end_reason_)) + + " elapsedMs=" + std::to_string(elapsed) + + " internetConnected=" + std::to_string(network.internet_connected) + + " peerCompleted=" + std::to_string(health.peer_completed) + + " peerTerminal=" + std::to_string(static_cast(health.peer_terminal)) + + " signalingConnected=" + std::to_string(health.signaling_connected) + + " videoStarted=" + std::to_string(health.video_started) + + " accessUnitIdleMs=" + std::to_string(health.video_idle.count()) + + " accessUnits=" + std::to_string(video.access_units) + + " decodedFrames=" + std::to_string(video.decoded_frames) + + " presentedFrames=" + std::to_string(video.presented_frames)); +} + void StreamView::StopCloudSessionAsync() { if (cloud_stop_requested_ || cloud_session_id_.empty()) return; @@ -414,7 +441,8 @@ brls::View* StreamView::create( const std::vector& ice_servers, const opennow::GfnClient& client, const opennow::AuthSession& auth, - const std::string& game_title) { + const std::string& game_title, + const opennow::StreamSettings& settings) { return new StreamView(signaling_url, jwt_token, session_id, media_ip, media_port, - ice_servers, client, auth, game_title); + ice_servers, client, auth, game_title, settings); } diff --git a/app/src/StreamView.hpp b/app/src/StreamView.hpp index 5aa5fd8b..2c6627dc 100644 --- a/app/src/StreamView.hpp +++ b/app/src/StreamView.hpp @@ -8,6 +8,8 @@ #include "nte_credentials.hpp" #include "stream_end_policy.hpp" #include "stream_overlay_policy.hpp" +#include "stream_overlay_view.hpp" +#include "stream_settings.hpp" #include "webrtc_session.hpp" #include @@ -33,7 +35,8 @@ class StreamView : public brls::Box { const std::vector& ice_servers, const opennow::GfnClient& client, const opennow::AuthSession& auth, - const std::string& game_title); + const std::string& game_title, + const opennow::StreamSettings& settings); ~StreamView() override; void draw(NVGcontext* vg, float x, float y, float width, float height, brls::Style style, brls::FrameContext* ctx) override; @@ -49,11 +52,13 @@ class StreamView : public brls::Box { const std::vector& ice_servers, const opennow::GfnClient& client, const opennow::AuthSession& auth, - const std::string& game_title); + const std::string& game_title, + const opennow::StreamSettings& settings); private: void ExitStream(); void StopCloudSessionAsync(); + void RecordLifecycleEvent(const char* event); void DrawDebugOverlay(NVGcontext* vg, float x, float y, float width); void DrawPreparingStream(NVGcontext* vg, float x, float y, float width, float height); void UpdatePerformanceCounter(); @@ -141,6 +146,9 @@ class StreamView : public brls::Box { bool debug_diagnostics_ = false; bool stats_overlay_enabled_ = false; bool stream_overlay_visible_ = false; + std::unique_ptr stream_overlay_view_; + std::chrono::steady_clock::time_point stream_overlay_next_sample_ {}; + std::int64_t stream_overlay_elapsed_seconds_ = -1; bool stream_overlay_b_was_down_ = false; bool overlay_chord_latched_ = false; opennow::input::OverlayChordState overlay_chord_state_; diff --git a/app/src/catalog_tab.cpp b/app/src/catalog_tab.cpp index c1987e85..c1fc3d3a 100644 --- a/app/src/catalog_tab.cpp +++ b/app/src/catalog_tab.cpp @@ -3,11 +3,14 @@ #include "app_state.hpp" #include "game_browser_header.hpp" #include "game_detail_view.hpp" +#include "game_detail_policy.hpp" #include "game_card_view.hpp" #include "game_grid_navigation.hpp" #include "ui_action_guard.hpp" #include "ui_helpers.hpp" #include "localization.hpp" +#include "ui_theme.hpp" +#include "ui_text_policy.hpp" #include #include @@ -26,9 +29,7 @@ constexpr std::array kCatalogSortModes = {"A-Z", "Store", "Publi brls::Label* MakeParagraph(const std::string& text, float bottom_margin = 16.0f) { - auto* label = new brls::Label(); - label->setText(Tr(text)); - label->setFontSize(18); + auto* label = ui::MakeLabel(Tr(text), 18, ui::Muted()); label->setMarginBottom(bottom_margin); return label; } @@ -66,16 +67,25 @@ bool MatchesStoreFilter(const PublicGame& game, size_t filter_index) CatalogTab::CatalogTab() : brls::Box(brls::Axis::COLUMN) { - setPadding(18, 32, 18, 32); - setBackgroundColor(nvgRGB(16, 16, 20)); + setId("catalog"); + setPadding(10, 36, 10, 36); + setBackgroundColor(ui::Ground()); + interface_language_ = GetInterfaceLanguage(); search_button_ = ui::MakeGameBrowserActionButton("Y Search"); filter_button_ = ui::MakeGameBrowserActionButton("ZL All stores"); sort_button_ = ui::MakeGameBrowserActionButton("ZR A-Z"); more_button_ = ui::MakeGameBrowserActionButton("X More / Refresh"); + search_button_->setId("catalog-search"); + filter_button_->setId("catalog-filter"); + sort_button_->setId("catalog-sort"); + more_button_->setId("catalog-more"); search_button_->setStyle(&brls::BUTTONSTYLE_HIGHLIGHT); toolbar_buttons_ = {search_button_, filter_button_, sort_button_, more_button_}; - addView(ui::MakeGameBrowserHeader("Store", toolbar_buttons_)); + auto* header = ui::MakeGameBrowserHeader("Store", toolbar_buttons_); + header->setMarginLeft(4); + header->setMarginRight(4); + addView(header); search_button_->registerClickAction([this](brls::View*) { return RunUiAction("catalog.search.button", [this]() { BeginSearch(); }); @@ -91,16 +101,22 @@ CatalogTab::CatalogTab() }); status_label_ = MakeParagraph("Loading the supported GeForce NOW catalog...", 8.0f); + status_label_->setId("catalog-status"); status_label_->setFontSize(14); - status_label_->setTextColor(nvgRGB(132, 139, 151)); + status_label_->setWidthPercentage(100); + status_label_->setMaxHeight(64); + status_label_->setMarginLeft(4); + status_label_->setMarginRight(4); addView(status_label_); - scrolling_frame_ = new brls::ScrollingFrame(); + scrolling_frame_ = new ui::FadingScrollFrame(); scrolling_frame_->setGrow(1.0f); + scrolling_frame_->setScrollingIndicatorVisible(false); scrolling_frame_->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); list_container_ = new brls::Box(brls::Axis::COLUMN); - list_container_->setPadding(0, 0, 32, 0); + list_container_->setId("catalog-list"); + list_container_->setPadding(4, 4, 32, 4); scrolling_frame_->setContentView(list_container_); addView(scrolling_frame_); @@ -137,10 +153,11 @@ void CatalogTab::BeginSearch() if (loading_) return; const auto alive = alive_; + const auto generation = AppState::Instance().session_generation(); brls::Application::giveFocus(search_button_); brls::Application::getImeManager()->openForText( - [this, alive](std::string text) { - if (!alive->load()) + [this, alive, generation](std::string text) { + if (!alive->load() || AppState::Instance().session_generation() != generation) return; RunUiAction("catalog.search.result", [this, text = std::move(text)]() mutable { if (loading_) @@ -161,6 +178,18 @@ void CatalogTab::willAppear(bool resetState) { brls::Box::willAppear(resetState); + if (interface_language_ != GetInterfaceLanguage()) + { + interface_language_ = GetInterfaceLanguage(); + if (auto* heading = dynamic_cast(getView("browser-heading"))) + heading->setText(Tr("Store")); + search_button_->setText("Y " + Tr("Search")); + more_button_->setText("X " + Tr("More / Refresh")); + search_button_->setWidth(ui::ToolbarButtonWidth(search_button_->getText())); + more_button_->setWidth(ui::ToolbarButtonWidth(more_button_->getText())); + RebuildList(); + } + const auto& state = AppState::Instance(); if (state.session_generation() != catalog_session_generation_) { @@ -187,6 +216,12 @@ void CatalogTab::ReloadCatalog(bool append) { if (loading_) return; + const auto& auth_state = AppState::Instance(); + if (auth_state.HasSession() && auth_state.session()->reauthentication_required) + { + status_label_->setText(Tr("Reconnect this account from Settings > Account before refreshing the catalog.")); + return; + } if (AppState::Instance().session_generation() != catalog_session_generation_) { @@ -286,6 +321,10 @@ void CatalogTab::RebuildList() current_focus == previous_page_button_; MoveFocusBeforeDestroy(list_container_, search_button_); + setLastFocusedView(search_button_); + WireVerticalGridNavigation({toolbar_buttons_}); + if (paging_container_) + WireVerticalGridNavigation({{previous_page_button_, load_more_button_}}); DetachPagingButton(); list_container_->clearViews(); card_rows_.clear(); @@ -326,27 +365,29 @@ void CatalogTab::RebuildList() page_index_ = total_pages - 1; const size_t page_start = std::min(filtered_count_, page_index_ * kInitialVisibleGameLimit); const size_t page_end = std::min(filtered_count_, page_start + kInitialVisibleGameLimit); - std::string status = "Loaded " + std::to_string(games_.size()); + std::string status = Tr("Loaded") + " " + std::to_string(games_.size()); if (catalog_.page().total_count) - status += " of " + std::to_string(*catalog_.page().total_count); - status += " supported games."; + status += " " + Tr("of") + " " + std::to_string(*catalog_.page().total_count); + status += " " + Tr("supported games") + "."; if (catalog_.page().next_cursor) - status += " More available online. Filters and sorting apply to loaded games."; - status += " Filter: " + std::string(kStoreFilters[store_filter_index_]) + "."; - status += " Sort: " + std::string(kCatalogSortModes[sort_mode_index_]) + "."; + status += " " + Tr("More available online. Filters and sorting apply to loaded games."); if (!search_query_.empty()) - status += " Found " + std::to_string(filtered_count_) + " matches."; + status += " " + TrFormat("Found {0} matches.", {std::to_string(filtered_count_)}); if (filtered_count_ > 0) - status += " Showing " + std::to_string(page_start + 1) + "-" + - std::to_string(page_end) + " of " + std::to_string(filtered_count_) + - " (page " + std::to_string(page_index_ + 1) + "/" + - std::to_string(total_pages) + ")."; + status += " " + TrFormat("Showing {0}-{1} of {2} (page {3}/{4}).", { + std::to_string(page_start + 1), std::to_string(page_end), std::to_string(filtered_count_), + std::to_string(page_index_ + 1), std::to_string(total_pages)}); else - status += " Press X to refresh."; + status += " " + Tr("Press X to refresh."); status_label_->setText(status); + const auto& state = AppState::Instance(); + if (state.HasSession() && state.session()->reauthentication_required) + status_label_->setText(Tr("Reconnect this account from Settings > Account before refreshing the catalog.")); filter_button_->setText("ZL " + Tr(kStoreFilters[store_filter_index_])); sort_button_->setText("ZR " + Tr(kCatalogSortModes[sort_mode_index_])); + filter_button_->setWidth(ui::ToolbarButtonWidth(filter_button_->getText())); + sort_button_->setWidth(ui::ToolbarButtonWidth(sort_button_->getText())); if (filtered_indices.empty()) { @@ -362,6 +403,9 @@ void CatalogTab::RebuildList() for (size_t start = page_start; start < page_end; start += kCardsPerRow) { auto* row = new brls::Box(brls::Axis::ROW); + row->setId("catalog-row-" + std::to_string((start - page_start) / kCardsPerRow)); + row->setWidth(1200); + row->setShrink(0); std::vector card_row; const size_t end = std::min(start + kCardsPerRow, page_end); @@ -372,16 +416,16 @@ void CatalogTab::RebuildList() GameCardDisplay display; display.title = game.title; - display.subtitle = game.store; - display.badge = game.is_in_library - ? "In library" - : (game.publisher.empty() ? "GFN catalog" : game.publisher); + display.subtitle = game_detail::IsUnknownMetadata(game.store) ? "" : game.store; + display.badge = game_detail::IsUnknownMetadata(game.publisher) ? "" : game.publisher; + display.in_library = game.is_in_library; display.image_url = game.image_url; auto* card = new GameCardView(display, [this, index]() { OpenGameDialog(nullptr, index); }); - card->setMarginRight(i + 1 < end ? 24.0f : 0.0f); + card->setId("catalog-card-" + game.id); + card->setMarginRight(i + 1 < end ? 25.0f : 0.0f); if (!first_card_) first_card_ = card; @@ -404,6 +448,8 @@ void CatalogTab::RebuildList() brls::Application::giveFocus(first_card_); else if (first_card_ && !brls::Application::getCurrentFocus()) brls::Application::giveFocus(first_card_); + if (first_card_) + setLastFocusedView(first_card_); } void CatalogTab::EnsurePagingButton() @@ -416,6 +462,7 @@ void CatalogTab::EnsurePagingButton() paging_container_->setMarginBottom(24); previous_page_button_ = new brls::Button(); + previous_page_button_->setId("catalog-previous"); previous_page_button_->setStyle(&brls::BUTTONSTYLE_BORDERED); previous_page_button_->setMarginRight(12); previous_page_button_->setGrow(1.0f); @@ -427,6 +474,7 @@ void CatalogTab::EnsurePagingButton() paging_container_->addView(previous_page_button_); load_more_button_ = new brls::Button(); + load_more_button_->setId("catalog-next"); load_more_button_->setStyle(&brls::BUTTONSTYLE_PRIMARY); load_more_button_->setGrow(1.0f); load_more_button_->registerClickAction([this](brls::View*) { @@ -482,9 +530,15 @@ void CatalogTab::HandlePagingButton() load_more_pending_ = true; brls::Application::giveFocus(more_button_); const auto alive = alive_; - brls::sync([this, alive]() { + const auto generation = AppState::Instance().session_generation(); + brls::sync([this, alive, generation]() { if (!alive->load()) return; + if (AppState::Instance().session_generation() != generation) + { + load_more_pending_ = false; + return; + } const std::string before = "page=" + std::to_string(page_index_ + 1) + " filtered=" + std::to_string(filtered_count_); @@ -523,9 +577,15 @@ void CatalogTab::HandlePreviousPage() load_more_pending_ = true; brls::Application::giveFocus(more_button_); const auto alive = alive_; - brls::sync([this, alive]() { + const auto generation = AppState::Instance().session_generation(); + brls::sync([this, alive, generation]() { if (!alive->load()) return; + if (AppState::Instance().session_generation() != generation) + { + load_more_pending_ = false; + return; + } --page_index_; RebuildList(); if (first_card_) @@ -538,6 +598,8 @@ void CatalogTab::HandlePreviousPage() void CatalogTab::CycleStoreFilter() { + if (load_more_pending_) + return; MoveFocusBeforeDestroy(list_container_, filter_button_); store_filter_index_ = (store_filter_index_ + 1) % kStoreFilters.size(); page_index_ = 0; @@ -547,6 +609,8 @@ void CatalogTab::CycleStoreFilter() void CatalogTab::CycleSortMode() { + if (load_more_pending_) + return; MoveFocusBeforeDestroy(list_container_, sort_button_); sort_mode_index_ = (sort_mode_index_ + 1) % kCatalogSortModes.size(); page_index_ = 0; @@ -561,6 +625,8 @@ bool CatalogTab::OpenGameDialog(brls::View* view, size_t index) if (index >= games_.size()) return false; + if (AppState::Instance().session_generation() != catalog_session_generation_) + return false; brls::Application::pushActivity(new brls::Activity(new GameDetailView( client_, diff --git a/app/src/catalog_tab.hpp b/app/src/catalog_tab.hpp index 8895b60d..ea68748a 100644 --- a/app/src/catalog_tab.hpp +++ b/app/src/catalog_tab.hpp @@ -57,6 +57,7 @@ class CatalogTab : public brls::Box bool paging_button_attached_ = false; std::shared_ptr alive_ = std::make_shared(true); std::string search_query_ = ""; + std::string interface_language_; size_t filtered_count_ = 0; size_t page_index_ = 0; size_t store_filter_index_ = 0; diff --git a/app/src/cloud_launch_internal.hpp b/app/src/cloud_launch_internal.hpp new file mode 100644 index 00000000..75540069 --- /dev/null +++ b/app/src/cloud_launch_internal.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "gfn_client.hpp" +#include "stream_settings.hpp" +#include + +namespace opennow +{ +void PresentCloudStream(const SessionInfo& info, const GfnClient& client, + const AuthSession& auth, const std::string& title, + const StreamSettings& settings); +} diff --git a/app/src/game_browser_header.cpp b/app/src/game_browser_header.cpp index f9174335..b40c301e 100644 --- a/app/src/game_browser_header.cpp +++ b/app/src/game_browser_header.cpp @@ -2,6 +2,7 @@ #include "localization.hpp" #include "ui_text_policy.hpp" +#include "ui_theme.hpp" namespace opennow::ui { @@ -30,17 +31,11 @@ brls::Box* MakeGameBrowserHeader( heading->setGrow(1.0f); heading->setAlignItems(brls::AlignItems::CENTER); - auto* accent = new brls::Rectangle(); - accent->setWidth(4); - accent->setHeight(30); - accent->setMarginRight(12); - accent->setColor(nvgRGB(88, 217, 138)); - heading->addView(accent); - - auto* label = new brls::Label(); + auto* label = new StyledLabel(FontRole::Heading); + label->setId("browser-heading"); label->setText(Tr(title)); label->setFontSize(28); - label->setTextColor(nvgRGB(248, 249, 251)); + label->setTextColor(Text()); heading->addView(label); header->addView(heading); diff --git a/app/src/game_card_view.cpp b/app/src/game_card_view.cpp index 0ddc9bad..283e798f 100644 --- a/app/src/game_card_view.cpp +++ b/app/src/game_card_view.cpp @@ -1,71 +1,61 @@ #include "game_card_view.hpp" #include "cover_image_cache.hpp" +#include "localization.hpp" +#include "ui_theme.hpp" + +#include #include namespace opennow { -namespace -{ - -std::string BuildSubtitle(const GameCardDisplay& display) -{ - if (display.badge.empty()) - return display.subtitle; - - if (display.subtitle.empty()) - return display.badge; - - return display.subtitle + " | " + display.badge; -} - -brls::Label* MakeLabel(const std::string& text, float size, NVGcolor color) -{ - auto* label = new brls::Label(); - label->setText(text); - label->setFontSize(size); - label->setTextColor(color); - label->setSingleLine(true); - return label; -} - -} // namespace - GameCardView::GameCardView(GameCardDisplay display, ClickHandler click_handler) : brls::Box(brls::Axis::COLUMN) , display_(std::move(display)) , click_handler_(std::move(click_handler)) { - setWidth(224); - setHeight(178); - setPadding(6, 6, 7, 6); - setMarginRight(10); - setMarginBottom(10); - setCornerRadius(10); - setBorderThickness(2); - setShadowType(brls::ShadowType::GENERIC); + setWidth(220); + setHeight(210); + setShrink(0); + setPadding(8, 8, 8, 8); + setMarginBottom(12); + setCornerRadius(12); + setBorderThickness(1); setFocusable(true); - // Keep the card surface dark, but retain Borealis' animated focus outline. - // The branded border color alone is too subtle at TV viewing distance. setHideHighlightBackground(true); - setHighlightPadding(2); - setHighlightCornerRadius(12); + setHighlightPadding(3); + setHighlightCornerRadius(14); image_ = new CachedImage(); - image_->setWidth(212); - image_->setHeight(112); + image_->setWidth(204); + image_->setHeight(114); + image_->setShrink(0); image_->setCornerRadius(7); - image_->setMarginBottom(6); + image_->setMarginBottom(10); image_->setScalingType(brls::ImageScalingType::FILL); addView(image_); - title_label_ = MakeLabel(display_.title, 16.0f, nvgRGB(245, 246, 248)); - title_label_->setMarginBottom(3); + title_label_ = ui::MakeLabel(display_.title, 18, ui::Text(), ui::FontRole::Heading); + title_label_->setId("catalog-card-title"); + title_label_->setWidthPercentage(100); + title_label_->setHeight(26); + title_label_->setSingleLine(true); addView(title_label_); - subtitle_label_ = MakeLabel(BuildSubtitle(display_), 12.0f, nvgRGB(88, 217, 138)); + subtitle_label_ = ui::MakeLabel(display_.subtitle, 14, ui::Muted()); + subtitle_label_->setId("catalog-card-store"); + subtitle_label_->setWidthPercentage(100); + subtitle_label_->setHeight(20); + subtitle_label_->setSingleLine(true); addView(subtitle_label_); + auto* badge = ui::MakeLabel(display_.in_library ? "● " + Tr("In library") : display_.badge, + 12, display_.in_library ? ui::Green() : ui::Muted()); + badge->setId("catalog-card-badge"); + badge->setWidthPercentage(100); + badge->setHeight(18); + badge->setSingleLine(true); + addView(badge); UpdateChrome(false); LoadImage(); @@ -77,6 +67,8 @@ GameCardView::GameCardView(GameCardDisplay display, ClickHandler click_handler) return true; }); + addGestureRecognizer(new brls::TapGestureRecognizer(this)); + updateActionHint(brls::BUTTON_A, Tr("View game")); } void GameCardView::onFocusGained() @@ -93,8 +85,8 @@ void GameCardView::onFocusLost() void GameCardView::UpdateChrome(bool focused) { - setBackgroundColor(focused ? nvgRGB(26, 42, 34) : nvgRGB(21, 21, 24)); - setBorderColor(focused ? nvgRGB(88, 217, 138) : nvgRGB(42, 42, 48)); + setBackgroundColor(focused ? ui::Raised() : ui::Ground()); + setBorderColor(focused ? ui::Green() : nvgRGBA(42, 45, 51, 0)); } void GameCardView::LoadImage() diff --git a/app/src/game_card_view.hpp b/app/src/game_card_view.hpp index 5f8e5598..cbcc88bb 100644 --- a/app/src/game_card_view.hpp +++ b/app/src/game_card_view.hpp @@ -16,6 +16,7 @@ struct GameCardDisplay std::string subtitle; std::string badge; std::string image_url; + bool in_library = false; }; class GameCardView : public brls::Box diff --git a/app/src/game_detail_view.cpp b/app/src/game_detail_view.cpp index 93d282be..4b099b15 100644 --- a/app/src/game_detail_view.cpp +++ b/app/src/game_detail_view.cpp @@ -4,7 +4,6 @@ #include "cover_image_cache.hpp" #include "game_detail_policy.hpp" #include "home_shortcut.hpp" -#include "membership_tier_style.hpp" #include "nte_credentials.hpp" #include "ui_helpers.hpp" #include "localization.hpp" @@ -23,24 +22,33 @@ namespace opennow namespace { -brls::Label* MakeLabel(const std::string& text, float size, NVGcolor color, float bottom_margin = 10.0f) +class CoverHero final : public CachedImage { - auto* label = new brls::Label(); - label->setText(Tr(text)); - label->setFontSize(size); - label->setTextColor(color); - label->setMarginBottom(bottom_margin); - return label; -} + public: + brls::View* hitTest(brls::Point) override { return nullptr; } + + void draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style style, brls::FrameContext* ctx) override + { + CachedImage::draw(vg, x, y, width, height, style, ctx); + nvgBeginPath(vg); + nvgRect(vg, x, y, width, height); + nvgFillPaint(vg, nvgLinearGradient(vg, x + width * 0.25f, y, + x + width * 0.85f, y, ui::Ground(), nvgRGBA(11, 12, 14, 60))); + nvgFill(vg); + nvgBeginPath(vg); + nvgRect(vg, x, y, width, height); + nvgFillPaint(vg, nvgLinearGradient(vg, x, y + height * 0.6f, + x, y + height, nvgRGBA(11, 12, 14, 0), nvgRGBA(11, 12, 14, 160))); + nvgFill(vg); + } +}; std::string PrimaryStore(const GameInfo& game) { if (!game.available_stores.empty()) return game_detail::DisplayStore(game.available_stores.front()); - if (!game_detail::IsUnknownMetadata(game.publisher)) - return game.publisher; - return "GeForce NOW"; } @@ -119,6 +127,7 @@ GameDetailView::GameDetailView(const GfnClient& client, GameDetailData data) : brls::Box(brls::Axis::COLUMN) , client_(client) , data_(std::move(data)) + , account_generation_(AppState::Instance().session_generation()) { const std::string saved_variant = client_.LoadLauncherPreference(ActiveUserId(), data_.game_id); for (size_t i = 0; i < data_.variants.size(); ++i) @@ -133,134 +142,180 @@ GameDetailView::GameDetailView(const GfnClient& client, GameDetailData data) selected_variant_index_ = i; } - setPadding(28, 40, 28, 40); - setBackgroundColor(nvgRGB(16, 16, 20)); - - auto* header = new brls::Header(); - header->setTitle(data_.title); - header->setSubtitle(data_.subtitle); + setId("detail"); + setBackgroundColor(ui::Ground()); + + auto* header = new brls::Box(brls::Axis::ROW); + header->setId("detail/header"); + header->setHeight(76); + header->setShrink(0); + header->setPadding(0, 48, 0, 48); + header->setAlignItems(brls::AlignItems::CENTER); + header->setBorderThickness(1); + header->setBorderColor(ui::Rule()); + auto* logo = new brls::Image(); + logo->setWidth(56); + logo->setHeight(32); + logo->setShrink(0); + logo->setMarginRight(28); + logo->setImageFromRes("img/opennow-logo-mark-small.png"); + header->addView(logo); + auto* route = ui::MakeLabel(Tr(data_.owned ? "Library" : "Store") + " /", 18, ui::Muted()); + route->setSingleLine(true); + route->setMaxWidthPercentage(30); + route->setMarginRight(14); + header->addView(route); + auto* breadcrumb = ui::MakeLabel(data_.title, 18, ui::Text(), ui::FontRole::Medium); + breadcrumb->setSingleLine(true); + breadcrumb->setGrow(1); + breadcrumb->setShrink(1); + header->addView(breadcrumb); addView(header); auto* content = new brls::Box(brls::Axis::ROW); - content->setGrow(1.0f); - content->setMarginTop(16); - - auto* poster_column = new brls::Box(brls::Axis::COLUMN); - poster_column->setWidth(310); - poster_column->setMarginRight(28); - - auto* poster = new CachedImage(); - poster->setWidth(310); - poster->setHeight(380); - poster->setCornerRadius(14); - poster->setScalingType(brls::ImageScalingType::FILL); - poster->setMarginBottom(16); - SetCachedCoverImage(poster, data_.image_url); - poster_column->addView(poster); - - auto* play_button = new brls::Button(); - play_button->setStyle(&brls::BUTTONSTYLE_PRIMARY); - play_button->setText(Tr(data_.owned ? "Play on GeForce NOW" : "Play from Store")); - play_button->setHeight(52); - play_button->setCornerRadius(10); - play_button->registerClickAction([this](brls::View* view) { - (void)view; - Play(); - return true; - }); - poster_column->addView(play_button); - - store_button_ = new brls::Button(); - store_button_->setMarginTop(10); - store_button_->setHeight(44); - store_button_->setCornerRadius(10); - store_button_->registerClickAction([this](brls::View*) { + content->setId("detail/content"); + content->setGrow(1); + content->setShrink(1); + content->setClipsToBounds(true); + + auto* hero = new CoverHero(); + hero->setId("detail/cover"); + hero->setPositionType(brls::PositionType::ABSOLUTE); + hero->setPositionTop(0); + hero->setPositionLeft(0); + hero->setWidthPercentage(100); + hero->setHeightPercentage(100); + hero->setScalingType(brls::ImageScalingType::FILL); + SetCachedCoverImage(hero, data_.image_url); + content->addView(hero); + + auto* left = new brls::Box(brls::Axis::COLUMN); + left->setWidth(640); + left->setShrink(0); + left->setPadding(28, 32, 24, 48); + + std::string metadata = game_detail::DisplayStore(data_.stores); + if (!data_.membership_tier_label.empty()) + metadata += " · " + data_.membership_tier_label; + if (!game_detail::IsUnknownMetadata(data_.publisher)) + metadata += " · " + data_.publisher; + auto* metadata_label = ui::MakeLabel(metadata, 14, ui::Muted()); + metadata_label->setId("detail/metadata"); + metadata_label->setSingleLine(true); + metadata_label->setShrink(0); + metadata_label->setMarginBottom(8); + left->addView(metadata_label); + + auto* title = ui::MakeLabel(data_.title, 44, ui::Text(), ui::FontRole::Display); + title->setId("detail/title"); + title->setSingleLine(true); + title->setShrink(0); + title->setMarginBottom(4); + left->addView(title); + + auto* description = ui::MakeLabel( + data_.description.empty() ? Tr("No description is available yet for this title.") : data_.description, + 18, ui::Muted()); + description->setSingleLine(false); + auto* description_frame = new brls::ScrollingFrame(); + description_frame->setId("detail/description"); + description_frame->setHeight(72); + description_frame->setShrink(0); + description_frame->setScrollingIndicatorVisible(false); + description_frame->setMarginBottom(20); + description_frame->setContentView(description); + left->addView(description_frame); + + auto* actions = new brls::Box(brls::Axis::COLUMN); + actions->setId("detail/actions"); + actions->setGrow(1); + actions->setWidth(472); + actions->setPadding(4); + play_button_ = new ui::ActionRow(Tr(data_.owned ? "Play on GeForce NOW" : "Play from Store"), {}, + [this](brls::View*) { + Play(); + return true; + }, ui::ActionTone::Primary); + play_button_->setId("detail/play"); + play_button_->setHeight(64); + play_button_->setMarginBottom(10); + play_button_->updateActionHint(brls::BUTTON_A, Tr("Play")); + actions->addView(play_button_); + + store_button_ = new ui::ActionRow(Tr("Store"), {}, [this](brls::View*) { ShowStoreSelector(false); return true; }); + store_button_->setId("detail/store"); + store_button_->setMarginBottom(10); + store_button_->updateActionHint(brls::BUTTON_A, Tr("Choose game store")); UpdateStoreButton(); - poster_column->addView(store_button_); - - auto* shortcut_button = new brls::Button(); - shortcut_button->setMarginTop(10); - shortcut_button->setHeight(44); - shortcut_button->setCornerRadius(10); - shortcut_button->setStyle(&brls::BUTTONSTYLE_BORDERED); - shortcut_button->setText(Tr("Create Switch shortcut")); - shortcut_button->registerClickAction([this](brls::View*) { - CreateSwitchShortcut(); - return true; - }); - poster_column->addView(shortcut_button); - - content->addView(poster_column); - - auto* info_column = new brls::Box(brls::Axis::COLUMN); - info_column->setGrow(1.0f); - - info_column->addView(MakeLabel(data_.title, 32.0f, nvgRGB(236, 236, 239), 6.0f)); - info_column->addView(MakeLabel( - game_detail::DetailSubtitle(data_.owned), 17.0f, nvgRGB(88, 217, 138), 18.0f)); - - auto* metadata = new brls::Box(brls::Axis::COLUMN); - metadata->setPadding(16, 18, 14, 18); - metadata->setMarginBottom(18); - metadata->setCornerRadius(12); - metadata->setBorderThickness(1); - metadata->setBorderColor(nvgRGB(42, 42, 48)); - metadata->setBackgroundColor(nvgRGB(21, 21, 24)); - metadata->addView(MakeLabel("GAME DETAILS", 12.0f, nvgRGB(112, 119, 130), 10.0f)); - metadata->addView(MakeLabel( - "Store · " + game_detail::DisplayStore(data_.stores), - 17.0f, nvgRGB(220, 220, 225), 6.0f)); - if (!data_.membership_tier_label.empty()) - { - metadata->addView(MakeLabel( - "Membership · " + data_.membership_tier_label, - 17.0f, membership::TextColor(data_.membership_tier_label), 6.0f)); - } - if (!game_detail::IsUnknownMetadata(data_.publisher)) - metadata->addView(MakeLabel( - "Publisher · " + data_.publisher, - 17.0f, nvgRGB(220, 220, 225), 6.0f)); - metadata->addView(MakeLabel( - "Last played · " + game_detail::FormatLastPlayed(data_.last_played), - 17.0f, nvgRGB(220, 220, 225), 0.0f)); - info_column->addView(metadata); + actions->addView(store_button_); + + auto* shortcut_button = new ui::ActionRow(Tr("Create Switch shortcut"), Tr("HOME screen"), + [this](brls::View*) { + CreateSwitchShortcut(); + return true; + }); + shortcut_button->setId("detail/shortcut"); + shortcut_button->updateActionHint(brls::BUTTON_A, Tr("Create Switch shortcut")); + actions->addView(shortcut_button); if (IsNevernessToEverness(data_.title)) { - nte_button_ = new brls::Button(); - nte_button_->setMarginBottom(18); - nte_button_->registerClickAction([this](brls::View*) { + nte_button_ = new ui::ActionRow("NTE Auto-login", {}, [this](brls::View*) { OpenNteCredentialsMenu(); return true; }); + nte_button_->setId("detail/nte"); + nte_button_->setMarginTop(10); UpdateNteButton(); - info_column->addView(nte_button_); + actions->addView(nte_button_); } - - info_column->addView(MakeLabel("ABOUT", 12.0f, nvgRGB(112, 119, 130), 8.0f)); - auto* description = MakeLabel( - SafeText(data_.description, "No description is available yet for this title."), - 18.0f, - nvgRGB(206, 206, 214), - 0.0f); - description->setSingleLine(false); - - auto* description_frame = new brls::ScrollingFrame(); - description_frame->setGrow(1.0f); - description_frame->setContentView(description); - info_column->addView(description_frame); - - content->addView(info_column); + left->addView(actions); + content->addView(left); + + auto* right = new brls::Box(brls::Axis::COLUMN); + right->setGrow(1); + right->setShrink(1); + right->setPadding(28, 48, 24, 16); + right->setJustifyContent(brls::JustifyContent::FLEX_END); + right->setAlignItems(brls::AlignItems::FLEX_END); + auto* summary = new ui::NextStreamSummaryView(LoadStreamSettings()); + summary->setId("detail/next-stream"); + summary->setWidth(380); + if (!data_.last_played.empty()) + { + auto* history = ui::MakeLabel(Tr("Last played") + " · " + game_detail::FormatLastPlayed(data_.last_played), + 14, ui::Muted()); + history->setSingleLine(true); + history->setMarginTop(8); + summary->addView(history); + } + right->addView(summary); + content->addView(right); addView(content); - registerAction("Back", brls::BUTTON_B, [](brls::View* view) { - (void)view; + registerAction(Tr("Back"), brls::BUTTON_B, [](brls::View*) { brls::Application::popActivity(); return true; }); + auto* footer = new brls::Box(brls::Axis::ROW); + footer->setId("detail/footer"); + footer->setHeight(60); + footer->setShrink(0); + footer->setPadding(0, 32, 0, 32); + footer->setAlignItems(brls::AlignItems::CENTER); + footer->setBorderThickness(1); + footer->setBorderColor(ui::Rule()); + auto* hints = new brls::Hints(); + hints->setId("detail/hints"); + hints->setAllowAButtonTouch(true); + hints->setAddUnableAButtonAction(false); + hints->setMaxWidthPercentage(100); + hints->applyXMLAttribute("forceShown", "true"); + footer->addView(hints); + addView(footer); } GameDetailView::~GameDetailView() @@ -268,6 +323,11 @@ GameDetailView::~GameDetailView() alive_->store(false); } +brls::View* GameDetailView::getDefaultFocus() +{ + return play_button_; +} + void GameDetailView::Play() { const auto& session = AppState::Instance().session(); @@ -276,6 +336,11 @@ void GameDetailView::Play() ShowError("Not Logged In", "Sign in from Library before starting a GeForce NOW session."); return; } + if (!AppState::Instance().IsCurrentSession(account_generation_)) + { + ShowError("Account Changed", "Open this game again from the current account before playing."); + return; + } if (data_.variants.size() > 1 && !launcher_preference_loaded_) { @@ -299,17 +364,17 @@ void GameDetailView::UpdateStoreButton() const std::string store = selected_variant_index_ < data_.variants.size() ? game_detail::DisplayStore(data_.variants[selected_variant_index_].store) : game_detail::DisplayStore(data_.stores); - store_button_->setText(Tr("Store") + ": " + store + (data_.variants.size() > 1 ? " (" + Tr("change") + ")" : "")); + store_button_->SetValue(store + (data_.variants.size() > 1 ? " · " + Tr("change") : "")); } void GameDetailView::UpdateNteButton() { if (!nte_button_) return; - nte_button_->setText( + nte_button_->SetValue( LoadNteCredentials().valid() - ? "NTE Auto-login: Ready (L + X in game)" - : "NTE Auto-login: Set email and password"); + ? "Saved · L + X in game" + : "Set email and password"); } void GameDetailView::ConfigureNteCredentials() @@ -355,8 +420,16 @@ void GameDetailView::OpenNteCredentialsMenu() "NTE Auto-login\n\nCredentials are stored as plain text at:\n" + NteCredentialsPath() + "\n\nPress L + X on the first NTE email sign-in screen. B cancels an active sequence."); - dialog->addButton("Edit credentials", [this] { ConfigureNteCredentials(); }); - dialog->addButton("Clear credentials", [this] { + const auto alive = alive_; + const auto generation = account_generation_; + dialog->addButton("Edit credentials", [this, alive, generation] { + if (!alive->load() || AppState::Instance().session_generation() != generation) + return; + ConfigureNteCredentials(); + }); + dialog->addButton("Clear credentials", [this, alive, generation] { + if (!alive->load() || AppState::Instance().session_generation() != generation) + return; ClearNteCredentials(); UpdateNteButton(); brls::Application::notify("NTE Auto-login credentials removed"); @@ -368,9 +441,12 @@ void GameDetailView::OpenNteCredentialsMenu() void GameDetailView::ShowStoreSelector(bool launch_after_selection) { + if (AppState::Instance().session_generation() != account_generation_) + return; if (data_.variants.empty()) { - LaunchSelectedVariant(); + if (launch_after_selection) + LaunchSelectedVariant(); return; } @@ -379,43 +455,64 @@ void GameDetailView::ShowStoreSelector(bool launch_after_selection) for (const auto& variant : data_.variants) labels.push_back(VariantLabel(variant)); + const auto alive = alive_; + const auto generation = account_generation_; + const auto user_id = ActiveUserId(); auto* dropdown = new brls::Dropdown( Tr("Choose game store"), labels, - [this](int selected) { + [this, alive, generation, user_id](int selected) { + if (!alive->load() || AppState::Instance().session_generation() != generation) + return; if (selected < 0 || static_cast(selected) >= data_.variants.size()) return; selected_variant_index_ = static_cast(selected); launcher_preference_loaded_ = true; const auto& variant = data_.variants[selected_variant_index_]; data_.launch_app_id = variant.id; - client_.SaveLauncherPreference(ActiveUserId(), data_.game_id, variant.id); + client_.SaveLauncherPreference(user_id, data_.game_id, variant.id); UpdateStoreButton(); brls::Application::notify("Store selected: " + SafeText(variant.store, variant.id)); }, static_cast(selected_variant_index_), - [this, launch_after_selection](int selected) { - if (launch_after_selection && selected >= 0) - LaunchSelectedVariant(); + [this, alive, generation, launch_after_selection](int selected) { + if (!alive->load() || !AppState::Instance().IsCurrentSession(generation)) + return; + if (!launch_after_selection || selected < 0 || + static_cast(selected) >= data_.variants.size() || + static_cast(selected) != selected_variant_index_) + return; + DeferredLaunchSelectedVariant(generation, data_.variants[static_cast(selected)].id); }); brls::Application::pushActivity(new brls::Activity(dropdown)); } void GameDetailView::LaunchSelectedVariant() { - std::string launch_app_id = data_.launch_app_id; - std::string store = data_.stores; - if (selected_variant_index_ < data_.variants.size()) - { - launch_app_id = data_.variants[selected_variant_index_].id; - store = data_.variants[selected_variant_index_].store; - } + const std::string launch_app_id = selected_variant_index_ < data_.variants.size() + ? data_.variants[selected_variant_index_].id : data_.launch_app_id; + DeferredLaunchSelectedVariant(account_generation_, launch_app_id); +} + +void GameDetailView::DeferredLaunchSelectedVariant(std::uint64_t generation, + const std::string& launch_app_id) +{ + const auto& state = AppState::Instance(); + if (!alive_->load() || !state.IsCurrentSession(generation) || generation != account_generation_) + return; - if (launch_app_id.empty()) + const std::string current_launch_app_id = selected_variant_index_ < data_.variants.size() + ? data_.variants[selected_variant_index_].id : data_.launch_app_id; + if (launch_app_id != current_launch_app_id) + return; + + if (!IsNumericLaunchId(launch_app_id)) { - ShowError("Launch Error", "This game has no launch App ID."); + ShowError("Launch Error", "This game has no valid numeric launch App ID."); return; } - LaunchSessionDialog(client_, *AppState::Instance().session(), launch_app_id, + const std::string store = selected_variant_index_ < data_.variants.size() + ? data_.variants[selected_variant_index_].store : data_.stores; + LaunchSessionDialog(client_, *state.session(), launch_app_id, data_.title, store, data_.title, data_.game_id, data_.image_url); } diff --git a/app/src/game_detail_view.hpp b/app/src/game_detail_view.hpp index bd0432d2..c2692971 100644 --- a/app/src/game_detail_view.hpp +++ b/app/src/game_detail_view.hpp @@ -2,10 +2,12 @@ #include "gfn_client.hpp" #include "models.hpp" +#include "ui_theme.hpp" #include #include +#include #include #include #include @@ -34,11 +36,14 @@ class GameDetailView : public brls::Box public: GameDetailView(const GfnClient& client, GameDetailData data); ~GameDetailView() override; + brls::View* getDefaultFocus() override; private: void Play(); void ShowStoreSelector(bool launch_after_selection); void LaunchSelectedVariant(); + void DeferredLaunchSelectedVariant(std::uint64_t generation, + const std::string& launch_app_id); void CreateSwitchShortcut(); void UpdateStoreButton(); void OpenNteCredentialsMenu(); @@ -48,12 +53,14 @@ class GameDetailView : public brls::Box GfnClient client_; GameDetailData data_; - brls::Button* store_button_ = nullptr; - brls::Button* nte_button_ = nullptr; + ui::ActionRow* play_button_ = nullptr; + ui::ActionRow* store_button_ = nullptr; + ui::ActionRow* nte_button_ = nullptr; std::shared_ptr alive_ = std::make_shared(true); size_t selected_variant_index_ = 0; bool launcher_preference_loaded_ = false; + std::uint64_t account_generation_ = 0; }; GameDetailData MakeLibraryGameDetail(const GameInfo& game); diff --git a/app/src/gfn/catalog.cpp b/app/src/gfn/catalog.cpp index 2c968177..418d7edb 100644 --- a/app/src/gfn/catalog.cpp +++ b/app/src/gfn/catalog.cpp @@ -1,6 +1,5 @@ #include "internal.hpp" -#include "../community_proxy_policy.hpp" #include "../play_history.hpp" #include "../server_location_policy.hpp" #include "../stream_settings.hpp" @@ -272,8 +271,7 @@ std::string BuildLibraryUrl(const std::string& vpc_id, bool with_library_time) std::string ResolveVpcId( const HttpClient& http_client, - const AuthSession& session, - const std::string& proxy_url) + const AuthSession& session) { const StreamSettings settings = LoadStreamSettings(); const std::string streaming_base_url = server_location::ResolveStreamingBaseUrl( @@ -284,8 +282,7 @@ std::string ResolveVpcId( const HttpResponse response = http_client.Get( streaming_base_url + "v2/serverInfo", GfnClient::kUserAgent, - BuildGfnLcarsHeaders(ResolveSessionJwt(session), "NATIVE", "NVIDIA-CLASSIC", true), - proxy_url); + BuildGfnLcarsHeaders(ResolveSessionJwt(session), "NATIVE", "NVIDIA-CLASSIC", true)); if (response.status_code != 200) return "GFN-PC"; @@ -568,12 +565,10 @@ std::vector GfnClient::FetchLoginProviders() const std::vector GfnClient::FetchPublicGames() const { - const std::string proxy_url = community_proxy::EnabledUrl(LoadStreamSettings()); const HttpResponse response = http_client_.Get( kPublicCatalogEndpoint, kUserAgent, - {"Accept: application/json"}, - proxy_url); + {"Accept: application/json"}); if (response.status_code != 200) { @@ -623,15 +618,14 @@ CatalogPage GfnClient::FetchCatalogPage( { session = RecoverSavedSession(session); std::string jwt_token = ResolveSessionJwt(session); - const std::string proxy_url = community_proxy::EnabledUrl(LoadStreamSettings()); - const std::string vpc_id = ResolveVpcId(http_client_, session, proxy_url); + const std::string vpc_id = ResolveVpcId(http_client_, session); HttpResponse response = http_client_.Post( kGraphQlEndpoint, kUserAgent, BuildGraphQlPostHeaders(jwt_token), BuildCatalogRequestBody(vpc_id, search_query, cursor), - proxy_url, {.max_body_bytes = kCatalogPageBytes}); + {}, {.max_body_bytes = kCatalogPageBytes}); if (response.status_code == 401) { @@ -642,7 +636,7 @@ CatalogPage GfnClient::FetchCatalogPage( kUserAgent, BuildGraphQlPostHeaders(jwt_token), BuildCatalogRequestBody(vpc_id, search_query, cursor), - proxy_url, {.max_body_bytes = kCatalogPageBytes}); + {}, {.max_body_bytes = kCatalogPageBytes}); } if (response.status_code != 200) @@ -658,14 +652,12 @@ std::vector GfnClient::FetchLibraryGames(AuthSession& session) const { session = RecoverSavedSession(session); std::string jwt_token = ResolveSessionJwt(session); - const std::string proxy_url = community_proxy::EnabledUrl(LoadStreamSettings()); - const std::string vpc_id = ResolveVpcId(http_client_, session, proxy_url); + const std::string vpc_id = ResolveVpcId(http_client_, session); HttpResponse response = http_client_.Get( BuildLibraryUrl(vpc_id, true), kUserAgent, - BuildGraphQlHeaders(jwt_token), - proxy_url); + BuildGraphQlHeaders(jwt_token)); if (response.status_code == 401) { @@ -674,8 +666,7 @@ std::vector GfnClient::FetchLibraryGames(AuthSession& session) const response = http_client_.Get( BuildLibraryUrl(vpc_id, true), kUserAgent, - BuildGraphQlHeaders(jwt_token), - proxy_url); + BuildGraphQlHeaders(jwt_token)); } if (response.status_code != 200) @@ -683,8 +674,7 @@ std::vector GfnClient::FetchLibraryGames(AuthSession& session) const response = http_client_.Get( BuildLibraryUrl(vpc_id, false), kUserAgent, - BuildGraphQlHeaders(jwt_token), - proxy_url); + BuildGraphQlHeaders(jwt_token)); } if (response.status_code != 200) diff --git a/app/src/gfn/cloud_session.cpp b/app/src/gfn/cloud_session.cpp index 0fa9224b..a87f0d55 100644 --- a/app/src/gfn/cloud_session.cpp +++ b/app/src/gfn/cloud_session.cpp @@ -1,6 +1,5 @@ #include "cloud_session_internal.hpp" -#include "../community_proxy_policy.hpp" #include "../server_location_policy.hpp" #include @@ -14,6 +13,7 @@ using namespace gfn::detail; using namespace gfn::cloud_session; SessionInfo GfnClient::StartSession(AuthSession& session, const std::string& launch_app_id, + const StreamSettings& stream_settings, const std::string& launch_store, const std::string& internal_title) const { @@ -22,8 +22,6 @@ SessionInfo GfnClient::StartSession(AuthSession& session, const std::string& lau const std::string device_id = GenerateDeviceId(); const std::string sub_session_id = GenerateUuid(); - const StreamSettings stream_settings = LoadStreamSettings(); - const std::string proxy_url = community_proxy::EnabledUrl(stream_settings); std::string automatic_region_url; std::string automatic_region_trace; if (server_location::IsAutomatic(stream_settings.region)) @@ -95,7 +93,6 @@ SessionInfo GfnClient::StartSession(AuthSession& session, const std::string& lau http_client_, streaming_base_url, headers, - proxy_url, stream_settings); network_test_trace = network_test_session_id.empty() ? "unavailable; launch will use regional endpoint without a test ID" @@ -132,14 +129,14 @@ SessionInfo GfnClient::StartSession(AuthSession& session, const std::string& lau AppendSessionTraceLog("START headers:\n" + HeadersForTrace(headers)); AppendSessionTraceLog("START request body:\n" + JsonForTrace(body)); - HttpResponse response = http_client_.Post(url, kUserAgent, headers, body, proxy_url); + HttpResponse response = http_client_.Post(url, kUserAgent, headers, body); if (response.status_code == 401) { session = ForceRefreshSavedSession(session); jwt_token = ResolveSessionJwt(session); headers[0] = "Authorization: GFNJWT " + jwt_token; AppendSessionTraceLog("START authorization rejected; refreshed token and retrying once"); - response = http_client_.Post(url, kUserAgent, headers, body, proxy_url); + response = http_client_.Post(url, kUserAgent, headers, body); } AppendSessionTraceLog("START response HTTP " + std::to_string(response.status_code)); AppendSessionTraceLog("START response body:\n" + JsonForTrace(response.body)); @@ -223,7 +220,6 @@ SessionInfo GfnClient::PollSession(AuthSession& session, const std::string& sess session = EnsureFreshSavedSession(session); std::string jwt_token = ResolveSessionJwt(session); const std::string device_id = GenerateDeviceId(); - const std::string proxy_url = community_proxy::EnabledUrl(LoadStreamSettings()); std::string streaming_base_url = LoadActiveCloudSessionStreamingBaseUrl(session, session_id); if (streaming_base_url.empty()) @@ -247,14 +243,14 @@ SessionInfo GfnClient::PollSession(AuthSession& session, const std::string& sess AppendSessionTraceLog("POLL url=" + url); AppendSessionTraceLog("POLL headers:\n" + HeadersForTrace(headers)); - HttpResponse response = http_client_.Get(url, kUserAgent, headers, proxy_url); + HttpResponse response = http_client_.Get(url, kUserAgent, headers); if (response.status_code == 401) { session = ForceRefreshSavedSession(session); jwt_token = ResolveSessionJwt(session); headers[0] = "Authorization: GFNJWT " + jwt_token; AppendSessionTraceLog("POLL authorization rejected; refreshed token and retrying once"); - response = http_client_.Get(url, kUserAgent, headers, proxy_url); + response = http_client_.Get(url, kUserAgent, headers); } AppendSessionTraceLog("POLL response HTTP " + std::to_string(response.status_code)); AppendSessionTraceLog("POLL response body:\n" + JsonForTrace(response.body)); @@ -336,7 +332,6 @@ void GfnClient::StopSession(AuthSession& session, const std::string& session_id) session = EnsureFreshSavedSession(session); std::string jwt_token = ResolveSessionJwt(session); const std::string device_id = GenerateDeviceId(); - const std::string proxy_url = community_proxy::EnabledUrl(LoadStreamSettings()); std::string streaming_base_url = LoadActiveCloudSessionStreamingBaseUrl(session, session_id); if (streaming_base_url.empty()) @@ -361,7 +356,7 @@ void GfnClient::StopSession(AuthSession& session, const std::string& session_id) AppendSessionTraceLog("STOP url=" + url); AppendSessionTraceLog("STOP headers:\n" + HeadersForTrace(headers)); HttpResponse response = http_client_.Request( - "DELETE", url, kUserAgent, headers, {}, proxy_url); + "DELETE", url, kUserAgent, headers); if (response.status_code == 401) { session = ForceRefreshSavedSession(session); @@ -369,7 +364,7 @@ void GfnClient::StopSession(AuthSession& session, const std::string& session_id) headers[0] = "Authorization: GFNJWT " + jwt_token; AppendSessionTraceLog("STOP authorization rejected; refreshed token and retrying once"); response = http_client_.Request( - "DELETE", url, kUserAgent, headers, {}, proxy_url); + "DELETE", url, kUserAgent, headers); } AppendSessionTraceLog("STOP response HTTP " + std::to_string(response.status_code)); AppendSessionTraceLog("STOP response body:\n" + JsonForTrace(response.body)); diff --git a/app/src/gfn/cloud_session_internal.hpp b/app/src/gfn/cloud_session_internal.hpp index e3f6831d..f0db79b8 100644 --- a/app/src/gfn/cloud_session_internal.hpp +++ b/app/src/gfn/cloud_session_internal.hpp @@ -45,7 +45,6 @@ std::string CreateNetworkTestSession( const HttpClient& http_client, const std::string& streaming_base_url, const std::vector& headers, - const std::string& proxy_url, const StreamSettings& stream_settings); int ParseSessionStatus(json_t* status); diff --git a/app/src/gfn/cloud_session_protocol.cpp b/app/src/gfn/cloud_session_protocol.cpp index 3ff47b34..72efa289 100644 --- a/app/src/gfn/cloud_session_protocol.cpp +++ b/app/src/gfn/cloud_session_protocol.cpp @@ -393,7 +393,6 @@ std::string BuildSessionBody( json_object_set_new(features, "maxBitrateKbps", json_integer(stream_settings.bitrate_kbps)); json_object_set_new(features, "codec", json_integer(1)); json_object_set_new(features, "vsync", json_false()); - json_object_set_new(features, "dynamicStreamingMode", json_integer(3)); json_object_set_new(features, "audioChannelCount", json_integer(2)); json_object_set_new(req, "requestedStreamingFeatures", features); @@ -426,7 +425,6 @@ std::string CreateNetworkTestSession( const HttpClient& http_client, const std::string& streaming_base_url, const std::vector& headers, - const std::string& proxy_url, const StreamSettings& stream_settings) { JsonPtr root(json_object(), &json_decref); @@ -444,8 +442,7 @@ std::string CreateNetworkTestSession( streaming_base_url + "v2/nettestsession", GfnClient::kUserAgent, headers, - DumpJson(root.get()), - proxy_url); + DumpJson(root.get())); if (response.status_code < 200 || response.status_code >= 300) return {}; diff --git a/app/src/gfn/community_proxy.cpp b/app/src/gfn/community_proxy.cpp deleted file mode 100644 index ef197114..00000000 --- a/app/src/gfn/community_proxy.cpp +++ /dev/null @@ -1,88 +0,0 @@ -#include "internal.hpp" - -#include "../app_version.hpp" -#include "../community_proxy_policy.hpp" -#include "../device_identity_policy.hpp" - -#include -#include -#include - -namespace opennow -{ -using namespace gfn::detail; - -namespace -{ - -std::string CommunityProxyClientId() -{ - const std::string path = GetAppHome() + "/community_proxy_client_id.txt"; - const std::string stored = Trim(ReadTextFile(path)); - if (device_identity::IsCanonicalUuid(stored)) - return stored; - - const std::string device_id = GenerateDeviceId(); - if (device_identity::IsCanonicalUuid(device_id)) - return device_id; - - const std::string generated = GenerateUuid(); - WriteTextFileAtomically(path, generated); - return generated; -} - -} // namespace - -std::string GfnClient::ProvisionCommunityProxy() const -{ - JsonPtr request(json_object(), &json_decref); - json_object_set_new(request.get(), "clientId", json_string(CommunityProxyClientId().c_str())); - const std::string body = DumpJson(request.get()); - - const HttpResponse response = http_client_.Post( - std::string(community_proxy::kProvisionUrl), - "OpenNOW-Switch/" + std::string(kAppVersion), - {"Accept: application/json", "Content-Type: application/json"}, - body); - - JsonPtr payload(nullptr, &json_decref); - try - { - payload = LoadJson(response.body); - } - catch (const std::exception&) - { - if (response.status_code >= 200 && response.status_code < 300) - throw std::runtime_error("Community proxy returned an invalid response"); - } - - if (response.status_code < 200 || response.status_code >= 300) - { - const std::string detail = payload ? GetString(payload.get(), "message") : ""; - throw std::runtime_error( - detail.empty() - ? "Community proxy activation failed with HTTP " + - std::to_string(response.status_code) - : detail); - } - - std::string proxy_url = GetString(payload.get(), "proxyUrl"); - if (proxy_url.empty()) - { - const std::string username = GetString(payload.get(), "username"); - const std::string password = GetString(payload.get(), "password"); - if (!username.empty() && !password.empty()) - { - proxy_url = "http://" + UrlEncode(username) + ":" + UrlEncode(password) + "@" + - std::string(community_proxy::kHost) + ":" + - std::string(community_proxy::kPort); - } - } - - proxy_url = community_proxy::NormalizeUrl(proxy_url); - if (!community_proxy::IsCommunityProxyUrl(proxy_url)) - throw std::runtime_error("Community proxy returned an invalid endpoint"); - return proxy_url; -} - -} // namespace opennow diff --git a/app/src/gfn_client.hpp b/app/src/gfn_client.hpp index f7880b13..82b84ec1 100644 --- a/app/src/gfn_client.hpp +++ b/app/src/gfn_client.hpp @@ -2,6 +2,7 @@ #include "http_client.hpp" #include "models.hpp" +#include "stream_settings.hpp" #include #include @@ -30,7 +31,6 @@ class GfnClient std::vector FetchStreamRegions(AuthSession& session) const; std::vector MeasureStreamRegionLatencies( std::vector regions) const; - std::string ProvisionCommunityProxy() const; AuthSession LoginWithQrCode( const LoginProvider& provider, @@ -44,6 +44,7 @@ class GfnClient bool force_refresh = false) const; SessionInfo StartSession(AuthSession& session, const std::string& launch_app_id, + const StreamSettings& settings, const std::string& launch_store = "", const std::string& internal_title = "") const; SessionInfo PollSession(AuthSession& session, const std::string& session_id) const; diff --git a/app/src/library_row_view.cpp b/app/src/library_row_view.cpp new file mode 100644 index 00000000..93a019e7 --- /dev/null +++ b/app/src/library_row_view.cpp @@ -0,0 +1,106 @@ +#include "library_row_view.hpp" + +#include "cover_image_cache.hpp" +#include "localization.hpp" +#include "ui_theme.hpp" + +#include + +#include +#include + +namespace opennow +{ + +LibraryRowView::LibraryRowView(LibraryRowDisplay display, std::function selected, + std::function opened) + : brls::Box(brls::Axis::ROW), selected_(std::move(selected)) +{ + setId("library-row-" + display.identity); + setWidthPercentage(100); + setHeight(72); + setShrink(0); + setPadding(8, 12, 8, 12); + setAlignItems(brls::AlignItems::CENTER); + setMarginBottom(2); + setCornerRadius(12); + setBorderThickness(1); + setFocusable(true); + setHideHighlightBackground(true); + setHighlightPadding(3); + setHighlightCornerRadius(14); + + time_ = ui::MakeLabel(display.time, 12, ui::Muted(), ui::FontRole::Medium); + time_->setWidth(80); + time_->setShrink(0); + time_->setSingleLine(true); + time_->setMarginRight(10); + addView(time_); + + auto* image = new CachedImage(); + image->setWidth(112); + image->setHeight(56); + image->setShrink(0); + image->setMarginRight(18); + image->setCornerRadius(6); + image->setScalingType(brls::ImageScalingType::FILL); + SetCachedCoverImage(image, display.image_url); + addView(image); + + auto* text = new brls::Box(brls::Axis::COLUMN); + text->setGrow(1); + text->setShrink(1); + auto* title = ui::MakeLabel(display.title, 20, ui::Text(), ui::FontRole::Heading); + title->setSingleLine(true); + title->setWidthPercentage(100); + text->addView(title); + std::string detail = display.store; + if (!display.membership.empty()) + detail += (detail.empty() ? "" : " · ") + display.membership; + auto* subtitle = ui::MakeLabel(detail, 14, ui::Muted()); + subtitle->setWidthPercentage(100); + subtitle->setSingleLine(true); + text->addView(subtitle); + addView(text); + + prompt_ = ui::MakeLabel("A " + Tr("Open"), 14, ui::Green(), ui::FontRole::Medium); + prompt_->setWidth(std::max(72.0f, ui::TextWidth("A " + Tr("Open"), 14, ui::FontRole::Medium) + 4)); + prompt_->setMarginLeft(12); + prompt_->setSingleLine(true); + prompt_->setShrink(0); + addView(prompt_); + registerClickAction([selected = selected_, opened = std::move(opened)](brls::View*) { + if (selected) + selected(); + if (opened) + opened(); + return true; + }); + addGestureRecognizer(new brls::TapGestureRecognizer(this)); + updateActionHint(brls::BUTTON_A, Tr("Open")); + UpdateChrome(false); +} + +void LibraryRowView::onFocusGained() +{ + brls::Box::onFocusGained(); + UpdateChrome(true); + if (selected_) + selected_(); +} + +void LibraryRowView::onFocusLost() +{ + brls::Box::onFocusLost(); + UpdateChrome(false); +} + +void LibraryRowView::UpdateChrome(bool focused) +{ + setBackgroundColor(focused ? ui::Raised() : ui::Ground()); + setBorderColor(focused ? ui::Green() : nvgRGBA(42, 45, 51, 0)); + time_->setTextColor(focused ? ui::Green() : ui::Muted()); + prompt_->setVisibility(focused ? brls::Visibility::VISIBLE : brls::Visibility::INVISIBLE); +} + +} diff --git a/app/src/library_row_view.hpp b/app/src/library_row_view.hpp new file mode 100644 index 00000000..dbca5aee --- /dev/null +++ b/app/src/library_row_view.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include + +namespace opennow +{ + +struct LibraryRowDisplay +{ + std::string identity; + std::string title; + std::string store; + std::string membership; + std::string time; + std::string image_url; +}; + +class LibraryRowView final : public brls::Box +{ + public: + LibraryRowView(LibraryRowDisplay display, std::function selected, + std::function opened); + void onFocusGained() override; + void onFocusLost() override; + + private: + void UpdateChrome(bool focused); + brls::Label* time_ = nullptr; + brls::Label* prompt_ = nullptr; + std::function selected_; +}; + +} diff --git a/app/src/library_tab.cpp b/app/src/library_tab.cpp index 9c2ee4fb..8caefe0a 100644 --- a/app/src/library_tab.cpp +++ b/app/src/library_tab.cpp @@ -1,12 +1,13 @@ #include "library_tab.hpp" #include "app_state.hpp" -#include "game_browser_header.hpp" +#include "cover_image_cache.hpp" #include "game_detail_view.hpp" -#include "game_card_view.hpp" +#include "game_detail_policy.hpp" #include "game_grid_navigation.hpp" +#include "library_row_view.hpp" #include "library_sort.hpp" -#include "membership_label.hpp" +#include "library_timetable_policy.hpp" #include "ui_action_guard.hpp" #include "ui_helpers.hpp" #include "localization.hpp" @@ -22,17 +23,20 @@ namespace opennow namespace { -constexpr size_t kCardsPerRow = 5; -constexpr size_t kInitialVisibleLibraryLimit = 15; +constexpr size_t kRowsPerPage = 15; constexpr std::array kStoreFilters = {"All", "Steam", "Epic", "Ubisoft", "Xbox", "Battle.net"}; constexpr std::array kLibrarySortModes = { "Last Played", "Last Added", "A-Z", "Store"}; +void SetToolbarCaption(ui::ActionRow* control, const std::string& text) +{ + control->SetTitle(text); + control->setWidth(ui::TextWidth(text, 14, ui::FontRole::Medium) + 16); +} + brls::Label* MakeParagraph(const std::string& text, float bottom_margin = 16.0f, float font_size = 18.0f) { - auto* label = new brls::Label(); - label->setText(Tr(text)); - label->setFontSize(font_size); + auto* label = ui::MakeLabel(Tr(text), font_size, ui::Muted()); label->setMarginBottom(bottom_margin); return label; } @@ -52,13 +56,15 @@ bool ContainsText(const std::string& haystack, const std::string& needle) std::string PrimaryStore(const GameInfo& game) { - if (!game.available_stores.empty()) - return game.available_stores.front(); - - if (!game.publisher.empty()) - return game.publisher; + for (const auto& store : game.available_stores) + if (!game_detail::IsUnknownMetadata(store)) + return store; + return Tr("Unknown store"); +} - return "GeForce NOW"; +std::string GameIdentity(const GameInfo& game) +{ + return game.uuid.empty() ? game.id : game.uuid; } bool MatchesStoreFilter(const GameInfo& game, size_t filter_index) @@ -87,45 +93,131 @@ bool MatchesStoreFilter(const GameInfo& game, size_t filter_index) LibraryTab::LibraryTab() : brls::Box(brls::Axis::COLUMN) { - setPadding(18, 32, 18, 32); - setBackgroundColor(nvgRGB(16, 16, 20)); - - search_button_ = ui::MakeGameBrowserActionButton("Y Search"); - filter_button_ = ui::MakeGameBrowserActionButton("ZL All stores"); - sort_button_ = ui::MakeGameBrowserActionButton("ZR Last Played"); - more_button_ = ui::MakeGameBrowserActionButton("X More / Refresh"); - search_button_->setStyle(&brls::BUTTONSTYLE_HIGHLIGHT); + setId("library"); + setPadding(10, 48, 14, 48); + setBackgroundColor(ui::Ground()); + library_session_generation_ = AppState::Instance().session_generation(); + + auto* body = new brls::Box(brls::Axis::ROW); + body->setGrow(1); + auto* timetable = new brls::Box(brls::Axis::COLUMN); + timetable->setWidth(660); + timetable->setShrink(0); + timetable->setMarginRight(40); + heading_ = ui::MakeLabel(Tr("My Library"), 28, ui::Text(), ui::FontRole::Heading); + heading_->setGrow(1); + heading_->setShrink(1); + heading_->setSingleLine(true); + + auto make_control = [](const std::string& id, const std::string& text, float width, + std::function action) { + auto* control = new ui::ActionRow(text, {}, std::move(action)); + control->setId(id); + control->setWidth(width > 0 ? width : ui::TextWidth(text, 14, ui::FontRole::Medium) + 16); + control->setHeight(38); + control->setPadding(0, 8, 0, 8); + return control; + }; + search_button_ = make_control("library-search", "Y " + Tr("Search"), 0, + [this](brls::View*) { return RunUiAction("library.search.button", [this] { BeginSearch(); }); }); + filter_button_ = make_control("library-filter", "ZL " + Tr("All"), 0, + [this](brls::View*) { return RunUiAction("library.filter.button", [this] { CycleStoreFilter(); }); }); + sort_button_ = make_control("library-sort", "ZR " + Tr("Last Played"), 0, + [this](brls::View*) { return RunUiAction("library.sort.button", [this] { CycleSortMode(); }); }); + more_button_ = make_control("library-more", "X " + Tr("More"), 0, + [this](brls::View*) { return RunUiAction("library.more.button", [this] { LoadMoreOrRefresh(); }); }); toolbar_buttons_ = {search_button_, filter_button_, sort_button_, more_button_}; - addView(ui::MakeGameBrowserHeader("My Library", toolbar_buttons_)); - - search_button_->registerClickAction([this](brls::View*) { - return RunUiAction("library.search.button", [this]() { BeginSearch(); }); - }); - filter_button_->registerClickAction([this](brls::View*) { - return RunUiAction("library.filter.button", [this]() { CycleStoreFilter(); }); - }); - sort_button_->registerClickAction([this](brls::View*) { - return RunUiAction("library.sort.button", [this]() { CycleSortMode(); }); - }); - more_button_->registerClickAction([this](brls::View*) { - return RunUiAction("library.more.button", [this]() { LoadMoreOrRefresh(); }); - }); + for (auto* control : {search_button_, filter_button_, sort_button_, more_button_}) + { + control->SetFontSize(14); + control->setPadding(0, 4, 0, 4); + } + auto* toolbar = new brls::Box(brls::Axis::ROW); + toolbar->setHeight(38); + toolbar->setShrink(0); + toolbar->setAlignItems(brls::AlignItems::CENTER); + toolbar->setMarginBottom(4); + heading_->setGrow(0); + heading_->setMarginBottom(8); + timetable->addView(heading_); + toolbar->setMarginBottom(8); + for (size_t i = 0; i < toolbar_buttons_.size(); ++i) + { + toolbar_buttons_[i]->setMarginRight(i + 1 < toolbar_buttons_.size() ? 8 : 0); + toolbar->addView(toolbar_buttons_[i]); + } + timetable->addView(toolbar); status_label_ = MakeParagraph( "Open Settings > Account to connect GeForce NOW and load your library.", 8.0f, 14.0f); - status_label_->setTextColor(nvgRGB(132, 139, 151)); - addView(status_label_); + status_label_->setId("library-status"); + status_label_->setWidthPercentage(100); + status_label_->setMaxHeight(60); + timetable->addView(status_label_); - scrolling_frame_ = new brls::ScrollingFrame(); + scrolling_frame_ = new ui::FadingScrollFrame(); scrolling_frame_->setGrow(1.0f); + scrolling_frame_->setScrollingIndicatorVisible(false); scrolling_frame_->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); - grid_container_ = new brls::Box(brls::Axis::COLUMN); - grid_container_->setPadding(0, 0, 30, 0); - scrolling_frame_->setContentView(grid_container_); - - addView(scrolling_frame_); + list_container_ = new brls::Box(brls::Axis::COLUMN); + list_container_->setId("library-list"); + list_container_->setPadding(4, 4, 8, 4); + scrolling_frame_->setContentView(list_container_); + timetable->addView(scrolling_frame_); + auto* paging = new brls::Box(brls::Axis::ROW); + paging->setMarginTop(8); + previous_button_ = make_control("library-previous", Tr("Previous page"), 320, + [this](brls::View*) { return RunUiAction("library.previous.button", [this] { PreviousPage(); }); }); + previous_button_->setMarginRight(20); + next_button_ = make_control("library-next", Tr("More / Refresh"), 320, + [this](brls::View*) { return RunUiAction("library.next.button", [this] { LoadMoreOrRefresh(); }); }); + paging->addView(previous_button_); + paging->addView(next_button_); + timetable->addView(paging); + body->addView(timetable); + + preview_container_ = new brls::Box(brls::Axis::COLUMN); + preview_container_->setGrow(1); + preview_container_->setShrink(1); + preview_container_->setPaddingTop(6); + preview_image_ = new CachedImage(); + preview_image_->setId("library-preview-image"); + preview_image_->setWidthPercentage(100); + preview_image_->setHeight(272); + preview_image_->setShrink(0); + preview_image_->setCornerRadius(12); + preview_image_->setScalingType(brls::ImageScalingType::FILL); + preview_image_->setMarginBottom(10); + preview_container_->addView(preview_image_); + selection_label_ = ui::MakeLabel(Tr("Now selected"), 12, ui::Green(), ui::FontRole::Medium); + selection_label_->setHeight(18); + preview_container_->addView(selection_label_); + preview_title_ = ui::MakeLabel({}, 28, ui::Text(), ui::FontRole::Heading); + preview_title_->setId("library-preview-title"); + preview_title_->setWidthPercentage(100); + preview_title_->setHeight(40); + preview_title_->setSingleLine(true); + preview_container_->addView(preview_title_); + auto metadata = [this](const std::string& id) { + auto* label = ui::MakeLabel({}, 14, ui::Muted()); + label->setId(id); + label->setWidthPercentage(100); + label->setHeight(20); + label->setSingleLine(true); + preview_container_->addView(label); + return label; + }; + preview_last_played_ = metadata("library-preview-last-played"); + preview_store_ = metadata("library-preview-store"); + preview_membership_ = metadata("library-preview-membership"); + stream_summary_ = new ui::NextStreamSummaryView(LoadStreamSettings()); + stream_summary_->setMarginTop(8); + preview_container_->addView(stream_summary_); + preview_container_->setVisibility(brls::Visibility::INVISIBLE); + body->addView(preview_container_); + addView(body); registerAction("Search", brls::BUTTON_Y, [this](brls::View* view) { (void)view; @@ -146,6 +238,7 @@ LibraryTab::LibraryTab() (void)view; return RunUiAction("library.sort.hotkey", [this]() { CycleSortMode(); }); }, false, false); + WireVerticalGridNavigation({toolbar_buttons_, {previous_button_, next_button_}}); } LibraryTab::~LibraryTab() @@ -156,17 +249,20 @@ LibraryTab::~LibraryTab() void LibraryTab::BeginSearch() { + if (page_pending_) + return; const auto alive = alive_; + const auto generation = AppState::Instance().session_generation(); brls::Application::giveFocus(search_button_); brls::Application::getImeManager()->openForText( - [this, alive](std::string text) { - if (!alive->load()) + [this, alive, generation](std::string text) { + if (!alive->load() || AppState::Instance().session_generation() != generation) return; RunUiAction("library.search.result", [this, text = std::move(text)]() mutable { - MoveFocusBeforeDestroy(grid_container_, search_button_); + MoveFocusBeforeDestroy(list_container_, search_button_); search_query_ = std::move(text); - visible_limit_ = kInitialVisibleLibraryLimit; - RebuildGrid(); + page_index_ = 0; + RebuildList(); }); }, "Search Library", "Enter a title to filter your games", 64, search_query_); @@ -177,11 +273,17 @@ void LibraryTab::willAppear(bool resetState) brls::Box::willAppear(resetState); EnsureSessionLoaded(); - UpdateSessionUi(); - auto& state = AppState::Instance(); - games_ = state.library_games(); - RebuildGrid(); + if (library_session_generation_ != state.session_generation()) + { + library_session_generation_ = state.session_generation(); + selected_identity_.clear(); + page_index_ = 0; + last_library_sync_ = {}; + } + games_ = state.HasSession() ? state.library_games() : std::vector{}; + stream_summary_->Update(LoadStreamSettings()); + RebuildList(); const bool displayed_cached_library = !games_.empty(); const auto now = std::chrono::steady_clock::now(); @@ -211,7 +313,7 @@ void LibraryTab::UpdateSessionUi() const auto& state = AppState::Instance(); if (!state.HasSession()) { - status_label_->setText("Open Settings > Account to connect GeForce NOW and load your library."); + status_label_->setText(Tr("Open Settings > Account to connect GeForce NOW and load your library.")); return; } @@ -220,17 +322,17 @@ void LibraryTab::UpdateSessionUi() if (session.reauthentication_required) { status_label_->setText( - "Reconnect this account from Settings > Account before refreshing the library."); + Tr("Reconnect this account from Settings > Account before refreshing the library.")); } } void LibraryTab::ReloadLibrary(bool background) { auto& state = AppState::Instance(); - if (loading_ || !state.HasSession()) + if (loading_ || !state.HasSession() || state.session()->reauthentication_required) { UpdateSessionUi(); - RebuildGrid(); + RebuildList(); return; } @@ -238,7 +340,7 @@ void LibraryTab::ReloadLibrary(bool background) // Throttle automatic retries even when NVIDIA temporarily rejects a // refresh, otherwise every tab appearance immediately repeats it. last_library_sync_ = std::chrono::steady_clock::now(); - status_label_->setText("Syncing your GeForce NOW library..."); + status_label_->setText(Tr("Syncing your GeForce NOW library...")); AuthSession session = *state.session(); const auto generation = state.session_generation(); @@ -263,8 +365,8 @@ void LibraryTab::ReloadLibrary(bool background) current.SetSession(std::move(session)); current.SetLibraryGames(games_); loading_ = false; - UpdateSessionUi(); - RebuildGrid(); + library_session_generation_ = generation; + RebuildList(); if (!background) brls::Application::notify("Library refreshed"); }); @@ -281,7 +383,7 @@ void LibraryTab::ReloadLibrary(bool background) if (background && !games_.empty()) { status_label_->setText( - "Showing cached library. NVIDIA background refresh is temporarily unavailable."); + Tr("Showing cached library. NVIDIA background refresh is temporarily unavailable.")); return; } ShowError("Library Sync Failed", error); @@ -291,41 +393,45 @@ void LibraryTab::ReloadLibrary(bool background) }, false); } -void LibraryTab::RebuildGrid() +void LibraryTab::RebuildList() { if (rebuilding_) return; rebuilding_ = true; struct ResetFlag { bool& flag; ~ResetFlag() { flag = false; } } reset {rebuilding_}; - MoveFocusBeforeDestroy(grid_container_, search_button_); - grid_container_->clearViews(); - card_rows_.clear(); - first_card_ = nullptr; - load_more_button_ = nullptr; - current_grid_size_ = 0; + const bool restore_row_focus = IsViewInside(list_container_, brls::Application::getCurrentFocus()); + MoveFocusBeforeDestroy(list_container_, search_button_); + setLastFocusedView(search_button_); + WireVerticalGridNavigation({toolbar_buttons_, {previous_button_, next_button_}}); + list_container_->clearViews(); + rows_.clear(); + page_pending_ = false; + previous_button_->SetTitle(Tr("Previous page")); + previous_button_->SetValue({}); + heading_->setText(Tr("My Library")); + selection_label_->setText(Tr("Now selected")); + SetToolbarCaption(search_button_, "Y " + Tr("Search")); + SetToolbarCaption(filter_button_, "ZL " + Tr(kStoreFilters[store_filter_index_])); + SetToolbarCaption(sort_button_, "ZR " + Tr(kLibrarySortModes[sort_mode_index_])); + SetToolbarCaption(more_button_, "X " + Tr("More")); const auto& state = AppState::Instance(); if (!state.HasSession()) { - WireVerticalGridNavigation({toolbar_buttons_}); - grid_container_->addView(MakeParagraph( + selected_identity_.clear(); + filtered_count_ = 0; + UpdatePreview(); + UpdateSessionUi(); + next_button_->SetTitle(Tr("Connect an account")); + list_container_->addView(MakeParagraph( "After login, this screen will show your owned GeForce NOW titles with cover art and store labels.", 0.0f)); return; } - LoadMore(); -} - -void LibraryTab::LoadMore() -{ - const bool load_more_had_focus = focus_new_cards_after_load_ || - (load_more_button_ && brls::Application::getCurrentFocus() == load_more_button_); - focus_new_cards_after_load_ = false; - std::vector filtered_indices; - std::string lower_query = ToLower(search_query_); + const std::string lower_query = ToLower(search_query_); for (size_t i = 0; i < games_.size(); ++i) { @@ -337,130 +443,90 @@ void LibraryTab::LoadMore() } } + const auto now = std::chrono::system_clock::now(); SortLibraryIndices( filtered_indices, games_, static_cast(sort_mode_index_)); + if (sort_mode_index_ == 0) + std::stable_sort(filtered_indices.begin(), filtered_indices.end(), [this, now](size_t left, size_t right) { + return library::PresentTimestamp(games_[left].last_played, now).bucket < + library::PresentTimestamp(games_[right].last_played, now).bucket; + }); filtered_count_ = filtered_indices.size(); - const size_t visible_count = std::min(filtered_count_, visible_limit_); - if (load_more_button_) - { - MoveFocusBeforeDestroy(load_more_button_, more_button_); - grid_container_->removeView(load_more_button_); - load_more_button_ = nullptr; - } - - std::string status = "Loaded " + std::to_string(games_.size()) + " games."; - status += " Filter: " + std::string(kStoreFilters[store_filter_index_]) + "."; - status += " Sort: " + std::string(kLibrarySortModes[sort_mode_index_]) + "."; - if (!search_query_.empty()) - status += " Found " + std::to_string(filtered_count_) + " matches."; - if (filtered_count_ > visible_count) - status += " Showing first " + std::to_string(visible_count) + ". Press X or select Show more."; - else - status += " Press X to refresh."; - - status_label_->setText(status); - filter_button_->setText("ZL " + Tr(kStoreFilters[store_filter_index_])); - sort_button_->setText("ZR " + Tr(kLibrarySortModes[sort_mode_index_])); + const size_t total_pages = std::max(1, (filtered_count_ + kRowsPerPage - 1) / kRowsPerPage); + const auto selected = std::find_if(filtered_indices.begin(), filtered_indices.end(), [this](size_t index) { + return GameIdentity(games_[index]) == selected_identity_; + }); + if (selected != filtered_indices.end()) + page_index_ = static_cast(selected - filtered_indices.begin()) / kRowsPerPage; + page_index_ = std::min(page_index_, total_pages - 1); + const size_t page_start = std::min(filtered_count_, page_index_ * kRowsPerPage); + const size_t page_end = std::min(filtered_count_, page_start + kRowsPerPage); + status_label_->setText(TrFormat("{0} games · {1} matches · {2} · {3} · {4}/{5}", { + std::to_string(games_.size()), std::to_string(filtered_count_), + Tr(kLibrarySortModes[sort_mode_index_]), Tr(kStoreFilters[store_filter_index_]), + std::to_string(page_index_ + 1), std::to_string(total_pages)})); + UpdateSessionUi(); + next_button_->SetTitle(Tr(page_end < filtered_count_ ? "Next page" : "Refresh library")); + previous_button_->SetValue(page_index_ > 0 ? std::to_string(page_index_) : ""); + SetToolbarCaption(more_button_, "X " + Tr(page_end < filtered_count_ ? "More" : "Refresh")); if (filtered_indices.empty()) { - WireVerticalGridNavigation({toolbar_buttons_}); + selected_identity_.clear(); + UpdatePreview(); std::string empty_msg = search_query_.empty() ? "This account is logged in, but no owned games were returned by the current GeForce NOW library feed." : "No games found matching your search."; - grid_container_->addView(MakeParagraph(empty_msg, 0.0f)); + list_container_->addView(MakeParagraph(empty_msg, 0.0f)); return; } - brls::View* first_new_card = nullptr; - - // Always start on a new row. To be perfectly accurate, we should pad the last row if it was incomplete. - // For simplicity, we assume we load in multiples of kCardsPerRow, or we just append rows. - size_t start_index = current_grid_size_; - for (size_t start = start_index; start < visible_count; start += kCardsPerRow) + std::optional previous_bucket; + brls::View* selected_row = nullptr; + for (size_t i = page_start; i < page_end; ++i) { - auto* row = new brls::Box(brls::Axis::ROW); - row->setMarginBottom(2); - std::vector card_row; - - const size_t end = std::min(start + kCardsPerRow, visible_count); - for (size_t i = start; i < end; ++i) + const auto& game = games_[filtered_indices[i]]; + const auto timestamp = library::PresentTimestamp(game.last_played, now); + if (sort_mode_index_ == 0 && previous_bucket != timestamp.bucket) { - const size_t index = filtered_indices[i]; - const GameInfo& game = games_[index]; - - GameCardDisplay display; - display.title = game.title; - display.subtitle = PrimaryStore(game); - display.badge = BuildMembershipBadge( - game.last_played.empty() ? "In library" : "Recently played", - game.membership_tier_label); - display.image_url = game.image_url; - - auto* card = new GameCardView(display, [this, index]() { - OpenGameDialog(index); - }); - card->setMarginRight(i + 1 < end ? 24.0f : 0.0f); - - if (!first_new_card) - first_new_card = card; - if (!first_card_) - first_card_ = card; - - row->addView(card); - card_row.push_back(card); + auto* group = MakeParagraph(library::BucketLabel(timestamp.bucket), 2, 12); + group->setHeight(16); + group->setMarginTop(2); + list_container_->addView(group); + previous_bucket = timestamp.bucket; } - - grid_container_->addView(row); - card_rows_.push_back(std::move(card_row)); + const auto identity = GameIdentity(game); + auto* row = new LibraryRowView( + {identity, game.title, PrimaryStore(game), + game_detail::IsUnknownMetadata(game.membership_tier_label) ? "" : game.membership_tier_label, + Tr(timestamp.label), game.image_url}, + [this, identity] { SelectGame(identity); }, + [this, identity] { OpenGameDialog(identity); }); + if (identity == selected_identity_) + selected_row = row; + list_container_->addView(row); + rows_.push_back(row); } - - std::vector> navigation_rows {toolbar_buttons_}; - navigation_rows.insert(navigation_rows.end(), card_rows_.begin(), card_rows_.end()); + if (!selected_row) + SelectGame(GameIdentity(games_[filtered_indices[page_start]])); + else + UpdatePreview(); + std::vector> navigation_rows{toolbar_buttons_}; + for (auto* row : rows_) + navigation_rows.push_back({row}); + navigation_rows.push_back({previous_button_, next_button_}); WireVerticalGridNavigation(navigation_rows); - - current_grid_size_ = visible_count; - - if (filtered_count_ > visible_count) - { - load_more_button_ = new brls::Button(); - load_more_button_->setText( - "Show more games (" + std::to_string(filtered_count_ - visible_count) + " left)"); - load_more_button_->setStyle(&brls::BUTTONSTYLE_PRIMARY); - load_more_button_->setMarginTop(14); - load_more_button_->setMarginBottom(24); - load_more_button_->registerClickAction([this](brls::View* view) { - (void)view; - return RunUiAction("library.show_more.button", [this]() { - if (filtered_count_ > visible_limit_) - { - focus_new_cards_after_load_ = true; - MoveFocusBeforeDestroy(load_more_button_, more_button_); - visible_limit_ += kInitialVisibleLibraryLimit; - const auto alive = alive_; - brls::sync([this, alive]() { - if (alive->load()) - LoadMore(); - }); - } - }); - }); - grid_container_->addView(load_more_button_); - - navigation_rows.push_back({load_more_button_}); - WireVerticalGridNavigation(navigation_rows); - } - - if (first_new_card && (load_more_had_focus || !brls::Application::getCurrentFocus())) - { - brls::Application::giveFocus(first_new_card); - } + if (scrolling_frame_) + scrolling_frame_->setContentOffsetY(0, false); + setLastFocusedView(selected_row ? selected_row : rows_.front()); + if (restore_row_focus) + brls::Application::giveFocus(selected_row ? selected_row : rows_.front()); } void LibraryTab::LoadMoreOrRefresh() { - if (loading_) + if (loading_ || page_pending_) return; if (!AppState::Instance().HasSession()) @@ -469,42 +535,113 @@ void LibraryTab::LoadMoreOrRefresh() return; } - if (filtered_count_ > visible_limit_) + if ((page_index_ + 1) * kRowsPerPage < filtered_count_) { - MoveFocusBeforeDestroy(grid_container_, more_button_); - visible_limit_ += kInitialVisibleLibraryLimit; - LoadMore(); + ChangePage(page_index_ + 1); return; } ReloadLibrary(); } +void LibraryTab::PreviousPage() +{ + if (!loading_ && !page_pending_ && page_index_ > 0) + ChangePage(page_index_ - 1); +} + +void LibraryTab::ChangePage(size_t page) +{ + page_pending_ = true; + brls::Application::giveFocus(more_button_); + const auto alive = alive_; + const auto generation = AppState::Instance().session_generation(); + brls::sync([this, alive, generation, page] { + if (!alive->load()) + return; + page_pending_ = false; + if (!AppState::Instance().IsCurrentSession(generation)) + return; + page_index_ = page; + selected_identity_.clear(); + RebuildList(); + if (!rows_.empty()) + brls::Application::giveFocus(rows_.front()); + }); +} + +void LibraryTab::SelectGame(const std::string& identity) +{ + selected_identity_ = identity; + UpdatePreview(); +} + +void LibraryTab::UpdatePreview() +{ + const auto game = std::find_if(games_.begin(), games_.end(), [this](const GameInfo& value) { + return GameIdentity(value) == selected_identity_; + }); + if (!AppState::Instance().IsCurrentSession(library_session_generation_) || + game == games_.end() || selected_identity_.empty()) + { + preview_container_->setVisibility(brls::Visibility::INVISIBLE); + if (!preview_identity_.empty()) + SetCachedCoverImage(preview_image_, {}); + preview_identity_.clear(); + preview_image_url_.clear(); + preview_title_->setText({}); + return; + } + preview_container_->setVisibility(brls::Visibility::VISIBLE); + if (preview_identity_ != selected_identity_ || preview_image_url_ != game->image_url) + { + SetCachedCoverImage(preview_image_, game->image_url); + preview_identity_ = selected_identity_; + preview_image_url_ = game->image_url; + } + preview_title_->setText(game->title); + preview_store_->setText(Tr("Store") + " · " + PrimaryStore(*game)); + preview_membership_->setText(game_detail::IsUnknownMetadata(game->membership_tier_label) ? "" : + Tr("Membership") + " · " + game->membership_tier_label); + preview_last_played_->setText(Tr("Last played") + " · " + + game_detail::FormatLastPlayed(game->last_played)); +} + void LibraryTab::CycleStoreFilter() { - MoveFocusBeforeDestroy(grid_container_, filter_button_); + if (page_pending_) + return; + MoveFocusBeforeDestroy(list_container_, filter_button_); store_filter_index_ = (store_filter_index_ + 1) % kStoreFilters.size(); - visible_limit_ = kInitialVisibleLibraryLimit; - RebuildGrid(); + page_index_ = 0; + RebuildList(); brls::Application::notify("Store filter: " + std::string(kStoreFilters[store_filter_index_])); } void LibraryTab::CycleSortMode() { - MoveFocusBeforeDestroy(grid_container_, sort_button_); + if (page_pending_) + return; + MoveFocusBeforeDestroy(list_container_, sort_button_); sort_mode_index_ = (sort_mode_index_ + 1) % kLibrarySortModes.size(); - RebuildGrid(); + page_index_ = 0; + RebuildList(); brls::Application::notify("Sort: " + std::string(kLibrarySortModes[sort_mode_index_])); } -bool LibraryTab::OpenGameDialog(size_t index) +bool LibraryTab::OpenGameDialog(const std::string& identity) { - if (index >= games_.size()) + if (!AppState::Instance().IsCurrentSession(library_session_generation_)) + return false; + const auto game = std::find_if(games_.begin(), games_.end(), [&identity](const GameInfo& value) { + return GameIdentity(value) == identity; + }); + if (game == games_.end()) return false; brls::Application::pushActivity(new brls::Activity(new GameDetailView( client_, - MakeLibraryGameDetail(games_[index])))); + MakeLibraryGameDetail(*game)))); return true; } diff --git a/app/src/library_tab.hpp b/app/src/library_tab.hpp index 2409fa29..5945c6ca 100644 --- a/app/src/library_tab.hpp +++ b/app/src/library_tab.hpp @@ -2,6 +2,7 @@ #include "gfn_client.hpp" #include "models.hpp" +#include "ui_theme.hpp" #include @@ -13,6 +14,8 @@ namespace opennow { +class CachedImage; + class LibraryTab : public brls::Box { public: @@ -26,36 +29,51 @@ class LibraryTab : public brls::Box void UpdateSessionUi(); void ReloadLibrary(bool background = false); void BeginSearch(); - void RebuildGrid(); - void LoadMore(); + void RebuildList(); void LoadMoreOrRefresh(); + void PreviousPage(); + void ChangePage(size_t page); + void SelectGame(const std::string& identity); + void UpdatePreview(); void CycleStoreFilter(); void CycleSortMode(); - bool OpenGameDialog(size_t index); + bool OpenGameDialog(const std::string& identity); GfnClient client_; std::vector games_; - std::vector> card_rows_; + std::vector rows_; std::vector toolbar_buttons_; + brls::Label* heading_ = nullptr; + brls::Label* selection_label_ = nullptr; brls::Label* status_label_ = nullptr; - brls::Button* search_button_ = nullptr; - brls::Button* filter_button_ = nullptr; - brls::Button* sort_button_ = nullptr; - brls::Button* more_button_ = nullptr; - brls::Button* load_more_button_ = nullptr; + ui::ActionRow* search_button_ = nullptr; + ui::ActionRow* filter_button_ = nullptr; + ui::ActionRow* sort_button_ = nullptr; + ui::ActionRow* more_button_ = nullptr; + ui::ActionRow* previous_button_ = nullptr; + ui::ActionRow* next_button_ = nullptr; brls::ScrollingFrame* scrolling_frame_ = nullptr; - brls::Box* grid_container_ = nullptr; - brls::View* first_card_ = nullptr; + brls::Box* list_container_ = nullptr; + brls::Box* preview_container_ = nullptr; + CachedImage* preview_image_ = nullptr; + brls::Label* preview_title_ = nullptr; + brls::Label* preview_store_ = nullptr; + brls::Label* preview_membership_ = nullptr; + brls::Label* preview_last_played_ = nullptr; + ui::NextStreamSummaryView* stream_summary_ = nullptr; bool loading_ = false; bool rebuilding_ = false; - bool focus_new_cards_after_load_ = false; + bool page_pending_ = false; std::shared_ptr alive_ = std::make_shared(true); std::string search_query_ = ""; - size_t visible_limit_ = 15; - size_t current_grid_size_ = 0; + size_t page_index_ = 0; size_t filtered_count_ = 0; size_t store_filter_index_ = 0; size_t sort_mode_index_ = 0; + std::string selected_identity_; + std::string preview_identity_; + std::string preview_image_url_; + std::uint64_t library_session_generation_ = 0; std::chrono::steady_clock::time_point last_library_sync_ {}; }; diff --git a/app/src/library_timetable_policy.hpp b/app/src/library_timetable_policy.hpp new file mode 100644 index 00000000..1af8f73c --- /dev/null +++ b/app/src/library_timetable_policy.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace opennow::library +{ + +enum class DateBucket { Today, ThisWeek, Earlier, Unplayed, Unknown }; + +struct TimestampPresentation +{ + DateBucket bucket = DateBucket::Unknown; + std::string label = "?"; +}; + +inline TimestampPresentation PresentTimestamp( + std::string_view value, std::chrono::system_clock::time_point now) +{ + using namespace std::chrono; + if (value.empty()) + return {DateBucket::Unplayed, "--"}; + if (value.size() < 20 || value[4] != '-' || value[7] != '-' || + value[10] != 'T' || value[13] != ':' || value[16] != ':' || value.back() != 'Z') + return {}; + auto number = [value](size_t offset, size_t count) -> std::optional { + unsigned result = 0; + for (size_t i = offset; i < offset + count; ++i) + { + if (value[i] < '0' || value[i] > '9') + return std::nullopt; + result = result * 10 + static_cast(value[i] - '0'); + } + return result; + }; + const auto y = number(0, 4); + const auto m = number(5, 2); + const auto d = number(8, 2); + const auto h = number(11, 2); + const auto min = number(14, 2); + const auto sec = number(17, 2); + if (!y || *y == 0 || !m || !d || !h || !min || !sec || *h > 23 || *min > 59 || *sec > 59) + return {}; + const year_month_day date{year{static_cast(*y)}, month{*m}, day{*d}}; + if (!date.ok()) + return {}; + unsigned fraction = 0; + if (value.size() != 20) + { + if (value.size() < 22 || value.size() > 30 || value[19] != '.') + return {}; + const size_t digits = value.size() - 21; + const auto parsed = number(20, digits); + if (!parsed) + return {}; + fraction = *parsed; + for (size_t i = digits; i < 9; ++i) + fraction *= 10; + } + const sys_days played_day{date}; + const auto played = played_day + hours{*h} + minutes{*min} + seconds{*sec}; + const auto now_seconds = floor(now); + if (played > now_seconds || (played == now_seconds && + nanoseconds{fraction} > now - now_seconds)) + return {}; + const auto today = floor(now); + if (played_day == today) + return {DateBucket::Today, std::string(value.substr(11, 5))}; + const auto week_start = today - days{weekday{today}.iso_encoding() - 1}; + if (played_day >= week_start) + { + constexpr std::array days_of_week { + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; + return {DateBucket::ThisWeek, days_of_week[weekday{played_day}.iso_encoding() - 1]}; + } + return {DateBucket::Earlier, std::string(value.substr(0, 10))}; +} + +inline const char* BucketLabel(DateBucket bucket) +{ + switch (bucket) + { + case DateBucket::Today: return "Today (UTC)"; + case DateBucket::ThisWeek: return "This week (UTC)"; + case DateBucket::Earlier: return "Earlier (UTC)"; + case DateBucket::Unplayed: return "Unplayed"; + case DateBucket::Unknown: return "Unknown play time"; + } + return "Unknown play time"; +} + +} diff --git a/app/src/localization.cpp b/app/src/localization.cpp index 61781392..699941c3 100644 --- a/app/src/localization.cpp +++ b/app/src/localization.cpp @@ -17,6 +17,8 @@ const Dictionary& DictionaryFor(const std::string& code) static const Dictionary empty; static const std::unordered_map dictionaries = { {"ru", { + {"Selected server", "Выбранный сервер"}, {"Next value", "Следующее значение"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Включите диагностические журналы для поиска проблем. Применяется после сохранения."}, {"Store", "Магазин"}, {"Library", "Библиотека"}, {"Settings", "Настройки"}, {"My Library", "Моя библиотека"}, {"Search", "Поиск"}, {"Close", "Закрыть"}, {"Cancel", "Отмена"}, {"Account", "Аккаунт"}, {"Stream", "Поток"}, @@ -82,6 +84,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"SAVED NVIDIA ACCOUNT", "СОХРАНЕННЫЙ АККАУНТ NVIDIA"} }}, {"uk", { + {"Selected server", "Вибраний сервер"}, {"Next value", "Наступне значення"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Увімкніть діагностичні журнали для пошуку проблем. Застосовується після збереження."}, {"Store", "Магазин"}, {"Library", "Бібліотека"}, {"Settings", "Налаштування"}, {"My Library", "Моя бібліотека"}, {"Search", "Пошук"}, {"Close", "Закрити"}, {"Cancel", "Скасувати"}, {"Account", "Обліковий запис"}, {"Stream", "Трансляція"}, @@ -112,6 +116,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"Enter email and password", "Ввести пошту та пароль"}, {"Login failed", "Помилка входу"} }}, {"es", { + {"Selected server", "Servidor seleccionado"}, {"Next value", "Siguiente valor"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Activa el registro de diagnóstico para resolver problemas. Se aplica al guardar."}, {"Store", "Tienda"}, {"Library", "Biblioteca"}, {"Settings", "Ajustes"}, {"My Library", "Mi biblioteca"}, {"Search", "Buscar"}, {"Close", "Cerrar"}, {"Cancel", "Cancelar"}, {"Account", "Cuenta"}, {"Stream", "Transmisión"}, {"Game", "Juego"}, {"Controls", "Controles"}, {"Audio", "Audio"}, @@ -136,6 +142,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"Enter email and password", "Introducir correo y contraseña"}, {"Login failed", "Error de inicio de sesión"} }}, {"it", { + {"Selected server", "Server selezionato"}, {"Next value", "Valore successivo"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Attiva i log diagnostici per risolvere i problemi. Si applica dopo il salvataggio."}, {"Store", "Negozio"}, {"Library", "Libreria"}, {"Settings", "Impostazioni"}, {"My Library", "La mia libreria"}, {"Search", "Cerca"}, {"Close", "Chiudi"}, {"Cancel", "Annulla"}, {"Account", "Account"}, {"Stream", "Streaming"}, {"Game", "Gioco"}, {"Controls", "Comandi"}, {"Audio", "Audio"}, @@ -156,6 +164,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"Welcome back", "Bentornato"}, {"Login failed", "Accesso non riuscito"} }}, {"fr", { + {"Selected server", "Serveur sélectionné"}, {"Next value", "Valeur suivante"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Activez les journaux de diagnostic pour résoudre les problèmes. Le changement s’applique après l’enregistrement."}, {"Store", "Boutique"}, {"Library", "Bibliothèque"}, {"Settings", "Paramètres"}, {"My Library", "Ma bibliothèque"}, {"Search", "Rechercher"}, {"Close", "Fermer"}, {"Cancel", "Annuler"}, {"Account", "Compte"}, {"Stream", "Streaming"}, {"Game", "Jeu"}, {"Controls", "Commandes"}, {"Audio", "Audio"}, @@ -176,6 +186,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"Welcome back", "Bon retour"}, {"Login failed", "Échec de la connexion"} }}, {"pl", { + {"Selected server", "Wybrany serwer"}, {"Next value", "Następna wartość"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "Włącz dzienniki diagnostyczne, aby rozwiązywać problemy. Zmiana działa po zapisaniu."}, {"Store", "Sklep"}, {"Library", "Biblioteka"}, {"Settings", "Ustawienia"}, {"My Library", "Moja biblioteka"}, {"Search", "Szukaj"}, {"Close", "Zamknij"}, {"Cancel", "Anuluj"}, {"Account", "Konto"}, {"Stream", "Strumień"}, {"Game", "Gra"}, {"Controls", "Sterowanie"}, {"Audio", "Dźwięk"}, @@ -196,6 +208,8 @@ const Dictionary& DictionaryFor(const std::string& code) {"Welcome back", "Witaj ponownie"}, {"Login failed", "Logowanie nie powiodło się"} }}, {"zh-CN", { + {"Selected server", "所选服务器"}, {"Next value", "下一个值"}, + {"Enable diagnostic logging for troubleshooting. Applies after saving.", "启用诊断日志以排查问题。保存后生效。"}, {"Store", "商店"}, {"Library", "游戏库"}, {"Settings", "设置"}, {"My Library", "我的游戏库"}, {"Search", "搜索"}, {"Close", "关闭"}, {"Cancel", "取消"}, {"Account", "账户"}, {"Stream", "串流"}, {"Game", "游戏"}, {"Controls", "控制"}, {"Audio", "音频"}, diff --git a/app/src/main.cpp b/app/src/main.cpp index f08b19bd..c6fc4208 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -20,6 +20,7 @@ #include "localization.hpp" #include "stream_diagnostics.hpp" #include "stream_settings.hpp" +#include "ui_theme.hpp" #ifdef __SWITCH__ namespace @@ -121,6 +122,7 @@ int main(int argc, char* argv[]) const opennow::StreamSettings startup_settings = opennow::LoadStreamSettings(); opennow::SetInterfaceLanguage(startup_settings.interface_language); brls::Application::createWindow("OpenNOW"); + opennow::ui::InitializeThemeAndFonts(); AppendBootLog("boot: window created"); // Plus is an in-game Xbox Start/Guide input. Borealis otherwise binds diff --git a/app/src/main_tabs_view.cpp b/app/src/main_tabs_view.cpp index 4fd7f8b8..5e1ba08d 100644 --- a/app/src/main_tabs_view.cpp +++ b/app/src/main_tabs_view.cpp @@ -43,9 +43,9 @@ MainTabsView::MainTabsView() else state.MarkSessionLoaded(); } - addTab(Tr("Store"), []() { return new CatalogTab(); }); - addTab(Tr("Library"), []() { return new LibraryTab(); }); - addTab(Tr("Settings"), []() { return new SettingsTab(); }); + addTab("Store", []() { return new CatalogTab(); }); + addTab("Library", []() { return new LibraryTab(); }); + addTab("Settings", []() { return new SettingsTab(); }); focusTab(1); last_auth_check_ = std::chrono::steady_clock::now(); } diff --git a/app/src/queue_view.cpp b/app/src/queue_view.cpp new file mode 100644 index 00000000..1c552743 --- /dev/null +++ b/app/src/queue_view.cpp @@ -0,0 +1,243 @@ +#include "queue_view.hpp" +#include "localization.hpp" +#include "ui_theme.hpp" + +#include +#include + +namespace opennow +{ +namespace +{ + +class QueuePositionLabel final : public brls::Label +{ + public: + QueuePositionLabel() { font = ui::Font(ui::FontRole::Display); } + void setText(const std::string& value) override + { + value_ = value; + brls::Label::setText(value); + } + void draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style, brls::FrameContext*) override + { + nvgSave(vg); + nvgIntersectScissor(vg, x, y, width, height); + nvgFontFaceId(vg, ui::Font(ui::FontRole::Display)); + nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); + const bool known = !value_.empty() && value_.front() >= '0' && value_.front() <= '9'; + const size_t count = known ? value_.size() : 1; + const float card_width = known ? std::min(150.0f, (width - 12.0f * static_cast(count - 1)) / static_cast(count)) : std::min(340.0f, width); + for (size_t index = 0; index < count; ++index) + { + const float card_x = x + static_cast(index) * (card_width + 12); + nvgBeginPath(vg); + nvgRoundedRect(vg, card_x, y, card_width, height, 12); + nvgFillColor(vg, ui::Raised()); + nvgFill(vg); + nvgStrokeWidth(vg, 1); + nvgStrokeColor(vg, ui::Rule()); + nvgStroke(vg); + nvgBeginPath(vg); + nvgRect(vg, card_x, y + height * 0.5f, card_width, 1); + nvgFillColor(vg, ui::Rule()); + nvgFill(vg); + nvgFontSize(vg, known ? std::min(112.0f, card_width) : 58.0f); + if (!known) + { + const float text_width = nvgTextBounds(vg, 0, 0, value_.c_str(), nullptr, nullptr); + if (text_width > card_width - 24) + nvgFontSize(vg, 58.0f * (card_width - 24) / text_width); + } + nvgFillColor(vg, ui::Text()); + const char digit[] = {value_[index], '\0'}; + nvgText(vg, card_x + card_width * 0.5f, y + height * 0.5f, + known ? digit : value_.c_str(), nullptr); + } + nvgRestore(vg); + } + + private: + std::string value_; +}; + +} + +QueueView::QueueView(const QueueDisplayState& state, const StreamSettings& settings, + std::function minimize, std::function cancel) + : brls::Box(brls::Axis::COLUMN) +{ + setId("queue/root"); + setBackgroundColor(ui::Ground()); + auto* header = new brls::Box(brls::Axis::ROW); + header->setHeight(76); + header->setShrink(0); + header->setPadding(0, 40, 0, 40); + header->setAlignItems(brls::AlignItems::CENTER); + header->setLineBottom(1); + header->setLineColor(ui::Rule()); + auto* logo = new brls::Image(); + logo->setWidth(62); + logo->setHeight(38); + logo->setScalingType(brls::ImageScalingType::FIT); + logo->setImageFromRes("img/opennow-logo-mark-small.png"); + logo->setMarginRight(10); + header->addView(logo); + auto* brand = ui::MakeLabel("OpenNOW", 22, ui::Text(), ui::FontRole::Heading); + brand->setGrow(1); + header->addView(brand); + phase_ = ui::MakeLabel(state.status, 16, ui::Muted()); + phase_->setId("queue/phase"); + phase_->setMaxWidth(600); + phase_->setSingleLine(true); + header->addView(phase_); + addView(header); + + auto* content = new brls::Box(brls::Axis::ROW); + content->setGrow(1); + content->setPadding(34, 40, 26, 40); + auto* left = new brls::Box(brls::Axis::COLUMN); + left->setGrow(1); + left->setShrink(1); + left->setMarginRight(64); + position_heading_ = ui::MakeLabel(Tr("Position in queue"), 15, ui::Green(), ui::FontRole::Medium); + position_heading_->setId("queue/position-heading"); + left->addView(position_heading_); + game_ = ui::MakeLabel(state.game_title, 29, ui::Text(), ui::FontRole::Heading); + game_->setId("queue/game"); + game_->setSingleLine(true); + game_->setMarginTop(6); + game_->setMarginBottom(20); + left->addView(game_); + counter_ = new brls::Box(brls::Axis::COLUMN); + counter_->setHeight(170); + counter_->setWidthPercentage(100); + counter_->setJustifyContent(brls::JustifyContent::CENTER); + position_ = new QueuePositionLabel(); + position_->setWidthPercentage(100); + position_->setHeight(170); + position_->setId("queue/position"); + position_->setSingleLine(true); + counter_->addView(position_); + left->addView(counter_); + status_ = ui::MakeLabel("", 25, ui::Text(), ui::FontRole::Heading); + status_->setId("queue/status"); + status_->setSingleLine(true); + status_->setMarginTop(20); + left->addView(status_); + detail_ = ui::MakeLabel("", 18, ui::Muted()); + detail_->setId("queue/detail"); + auto* detail_scroll = new brls::ScrollingFrame(); + detail_scroll->setId("queue/detail-scroll"); + detail_scroll->setGrow(1); + detail_scroll->setMarginTop(6); + detail_scroll->setContentView(detail_); + left->addView(detail_scroll); + content->addView(left); + + auto* right = new brls::Box(brls::Axis::COLUMN); + right->setWidth(480); + right->setShrink(0); + auto* steps = new brls::Box(brls::Axis::ROW); + steps->setHeight(70); + steps->setMarginTop(8); + const char* names[] = {"Queue", "Setup", "Ready"}; + for (size_t index = 0; index < steps_.size(); ++index) + { + auto* column = new brls::Box(brls::Axis::COLUMN); + column->setGrow(1); + column->setMarginRight(index == 2 ? 0 : 8); + column->setLineTop(5); + column->setLineColor(ui::Rule()); + steps_[index] = ui::MakeLabel(Tr(names[index]), 16, ui::Muted()); + steps_[index]->setId("queue/step/" + std::to_string(index)); + steps_[index]->setMarginTop(10); + column->addView(steps_[index]); + steps->addView(column); + } + right->addView(steps); + auto add_summary = [right](const std::string& id, const std::string& title, const std::string& value) { + auto* row = new brls::Box(brls::Axis::ROW); + row->setHeight(46); + row->setAlignItems(brls::AlignItems::CENTER); + row->setLineBottom(1); + row->setLineColor(ui::Rule()); + auto* label = ui::MakeLabel(Tr(title), 16, ui::Muted()); + label->setGrow(1); + row->addView(label); + auto* detail = ui::MakeLabel(value, 17, ui::Text(), ui::FontRole::Medium); + detail->setId(id); + detail->setMaxWidthPercentage(76); + detail->setSingleLine(true); + row->addView(detail); + right->addView(row); + }; + add_summary("queue/region", "Location", ui::ConfiguredLocation(settings.region)); + add_summary("queue/stream-summary", "Stream", ui::StreamSummary(settings)); + add_summary("queue/reminder", "Reminder", Tr("At position") + " " + std::to_string(settings.queue_notify_threshold)); + auto* summary = new ui::NextStreamSummaryView(settings); + summary->setId("queue/next-stream"); + summary->setMarginTop(16); + right->addView(summary); + auto* minimize_row = new ui::ActionRow(Tr("Minimize and browse"), Tr("Queue keeps running"), + [minimize](brls::View*) { minimize(); return true; }); + minimize_row->setId("queue/minimize"); + minimize_row->setMarginTop(22); + right->addView(minimize_row); + minimize_ = minimize_row; + auto* cancel_row = new ui::ActionRow(Tr("Cancel session"), "", + [cancel](brls::View*) { cancel(); return true; }, ui::ActionTone::Destructive); + cancel_row->setId("queue/cancel"); + cancel_row->setMarginTop(12); + right->addView(cancel_row); + cancel_ = cancel_row; + content->addView(right); + addView(content); + auto* footer = new brls::Box(brls::Axis::ROW); + footer->setHeight(60); + footer->setShrink(0); + footer->setPadding(0, 40, 0, 40); + footer->setAlignItems(brls::AlignItems::CENTER); + footer->setLineTop(1); + footer->setLineColor(ui::Rule()); + auto* select = ui::MakeLabel(Tr("A Select Up / Down Choose"), 16, ui::Muted()); + select->setGrow(1); + footer->addView(select); + footer->addView(ui::MakeLabel(Tr("Minus + Plus Reopen queue after minimizing"), 16, ui::Muted())); + addView(footer); + registerAction(Tr("Minimize"), brls::BUTTON_B, + [minimize](brls::View*) { minimize(); return true; }, true); + setLastFocusedView(minimize_row); + Update(state); +} + +void QueueView::Update(const QueueDisplayState& state) +{ + const bool show_position = state.stage == 1 && state.position >= 0 && !state.failed; + counter_->setVisibility(show_position ? brls::Visibility::VISIBLE : brls::Visibility::GONE); + position_heading_->setVisibility(show_position ? brls::Visibility::VISIBLE : brls::Visibility::GONE); + game_->setMarginTop(show_position ? 6 : 0); + status_->setMarginTop(show_position ? 20 : 0); + game_->setText(state.game_title); + phase_->setText(Tr(state.status)); + status_->setText(Tr(state.status)); + status_->setTextColor(state.failed ? ui::Danger() : ui::Text()); + detail_->setText(Tr(state.detail)); + minimize_->SetTitle(Tr(state.failed ? "Back to game" : "Minimize and browse")); + minimize_->SetValue(state.failed ? "" : Tr("Queue keeps running")); + cancel_->SetTitle(Tr(state.failed ? "Close" : "Cancel session")); + const std::string position = state.position >= 0 + ? (state.position < 10 ? "0" : "") + std::to_string(state.position) : Tr("Unknown"); + position_->setText(position); + position_->setFontSize(state.position < 0 ? 62 : std::min(112.0f, 480.0f / static_cast(position.size()))); + const int step = state.stage < 2 ? 0 : state.stage == 2 ? 1 : 2; + for (size_t index = 0; index < steps_.size(); ++index) + { + const auto color = !state.failed && static_cast(index) <= step ? ui::Green() : ui::Muted(); + steps_[index]->setTextColor(color); + steps_[index]->getParent()->setLineColor(!state.failed && static_cast(index) <= step ? ui::Green() : ui::Rule()); + } +} + +} diff --git a/app/src/queue_view.hpp b/app/src/queue_view.hpp new file mode 100644 index 00000000..f66e2df4 --- /dev/null +++ b/app/src/queue_view.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "stream_settings.hpp" +#include +#include +#include +#include + +namespace opennow +{ + +namespace ui { class ActionRow; } + +struct QueueDisplayState +{ + std::string game_title; + std::string status; + std::string detail; + int position = -1; + int stage = 0; + bool failed = false; +}; + +class QueueView final : public brls::Box +{ + public: + QueueView(const QueueDisplayState& state, const StreamSettings& settings, + std::function minimize, std::function cancel); + void Update(const QueueDisplayState& state); + + private: + brls::Label* game_; + brls::Label* status_; + brls::Label* position_; + brls::Label* position_heading_; + brls::Box* counter_; + brls::Label* detail_; + brls::Label* phase_; + ui::ActionRow* minimize_; + ui::ActionRow* cancel_; + std::array steps_ {}; +}; + +} diff --git a/app/src/settings_tab.cpp b/app/src/settings_tab.cpp index b10f4d46..72d5e935 100644 --- a/app/src/settings_tab.cpp +++ b/app/src/settings_tab.cpp @@ -3,11 +3,13 @@ #include "app_state.hpp" #include "localization.hpp" #include "stream_settings.hpp" +#include "stream_settings_policy.hpp" #include "stream_diagnostics.hpp" #include "ui_action_guard.hpp" #include "ui_helpers.hpp" #include +#include #include #include #include @@ -18,9 +20,8 @@ namespace opennow brls::Label* SettingsTab::MakeParagraph( const std::string& text, float bottom_margin) { - auto* label = new brls::Label(); - label->setText(Tr(text)); - label->setFontSize(18); + auto* label = ui::MakeLabel(Tr(text)); + label->setSingleLine(false); label->setMarginBottom(bottom_margin); return label; } @@ -40,28 +41,27 @@ std::string SettingsTab::FormatBytes(std::uint64_t bytes) return formatted.str(); } +std::string SettingsTab::BitrateValue(int kbps) +{ + std::ostringstream value; + value << std::fixed << std::setprecision(3) << static_cast(kbps) / 1000.0; + std::string text = value.str(); + while (!text.empty() && text.back() == '0') + text.pop_back(); + if (!text.empty() && text.back() == '.') + text.pop_back(); + return text + " Mbps"; +} + namespace { -bool SameSettings(const StreamSettings& left, const StreamSettings& right) +std::string OptionId(const std::string& title) { - return left.preset_id == right.preset_id && left.label == right.label && - left.width == right.width && left.height == right.height && - left.fps == right.fps && left.bitrate_kbps == right.bitrate_kbps && - left.codec == right.codec && left.region == right.region && - left.audio_enabled == right.audio_enabled && - left.audio_volume == right.audio_volume && - left.audio_buffer_ms == right.audio_buffer_ms && - left.video_backend == right.video_backend && - left.debug_diagnostics == right.debug_diagnostics && - left.stats_overlay_enabled == right.stats_overlay_enabled && - left.game_language == right.game_language && - left.persist_game_settings == right.persist_game_settings && - left.controller_layout == right.controller_layout && - left.image_quality_mode == right.image_quality_mode && - left.interface_language == right.interface_language && - left.community_proxy_enabled == right.community_proxy_enabled && - left.community_proxy_url == right.community_proxy_url; + std::string id = "settings/option/"; + for (unsigned char character : title) + id += std::isalnum(character) ? static_cast(std::tolower(character)) : '-'; + return id; } } // namespace @@ -69,20 +69,22 @@ bool SameSettings(const StreamSettings& left, const StreamSettings& right) SettingsTab::SettingsTab() : brls::Box(brls::Axis::COLUMN) { - setPadding(16, 28, 18, 28); - setBackgroundColor(nvgRGB(12, 13, 16)); + setId("settings"); + setPadding(24, 48, 20, 48); + setBackgroundColor(ui::Ground()); auto* body = new brls::Box(brls::Axis::ROW); body->setGrow(1.0f); + body->setMinHeight(0); auto* sidebar = new brls::Box(brls::Axis::COLUMN); sidebar->setWidth(220); - sidebar->setPadding(14, 12, 16, 12); - sidebar->setBackgroundColor(nvgRGB(16, 18, 22)); + sidebar->setShrink(0); + sidebar->setMarginRight(32); - auto* nav_label = MakeParagraph("General", 10.0f); + auto* nav_label = MakeParagraph("Settings", 20.0f); nav_label->setFontSize(13); - nav_label->setTextColor(nvgRGB(108, 115, 126)); + nav_label->setTextColor(ui::Muted()); sidebar->addView(nav_label); const std::array, 4> categories {{ @@ -93,85 +95,133 @@ SettingsTab::SettingsTab() }}; for (const auto& [label, category] : categories) { - auto* row = new brls::Box(brls::Axis::ROW); - row->setHeight(44); - row->setAlignItems(brls::AlignItems::CENTER); - row->setPadding(0, 12, 0, 0); - row->setMarginBottom(3); - row->setCornerRadius(6); - row->setHighlightCornerRadius(6); - row->setHighlightPadding(0); - row->setFocusable(true); - - auto* marker = new brls::Rectangle(); - marker->setWidth(3); - marker->setHeight(20); - marker->setMarginRight(12); - marker->setColor(nvgRGBA(0, 0, 0, 0)); - row->addView(marker); - - auto* text = MakeParagraph(label, 0.0f); - text->setFontSize(16); - text->setTextColor(nvgRGB(184, 190, 200)); - row->addView(text); - - row->registerClickAction([this, category](brls::View*) { + auto* row = new ui::ActionRow(Tr(label), {}, [this, category](brls::View*) { SelectCategory(category); return true; + }, ui::ActionTone::Flat); + std::string category_id = label; + for (char& character : category_id) + character = static_cast(std::tolower(static_cast(character))); + row->setId("settings/category/" + category_id); + row->setHeight(48); + row->setMarginBottom(6); + row->SetFocusHandler([this] { + ShowOptionHelp({}, page_subtitle_->getFullText()); }); - category_nav_items_.push_back({row, marker, text}); + category_nav_items_.push_back({row, row}); sidebar->addView(row); } + save_status_ = ui::MakeLabel({}, 15, ui::Green(), ui::FontRole::Medium); + save_status_->setId("settings/save-status"); + save_status_->setSingleLine(false); + save_status_->setMarginTop(32); + save_status_->setMarginBottom(8); + sidebar->addView(save_status_); auto* nav_hint = MakeParagraph( "Stream changes apply when the next game starts.", 0.0f); nav_hint->setFontSize(14); - nav_hint->setTextColor(nvgRGB(124, 132, 144)); + nav_hint->setTextColor(ui::Muted()); nav_hint->setSingleLine(false); sidebar->addView(nav_hint); body->addView(sidebar); - auto* sidebar_divider = new brls::Rectangle(); - sidebar_divider->setWidth(1); - sidebar_divider->setMarginRight(24); - sidebar_divider->setColor(nvgRGBA(255, 255, 255, 16)); - body->addView(sidebar_divider); - auto* content_shell = new brls::Box(brls::Axis::COLUMN); - content_shell->setGrow(1.0f); - content_shell->setPadding(0, 0, 0, 0); + content_shell->setWidth(560); + content_shell->setShrink(0); + content_shell->setMinHeight(0); + content_shell->setMarginRight(40); auto* page_heading = new brls::Box(brls::Axis::ROW); page_heading->setAlignItems(brls::AlignItems::CENTER); page_heading->setMarginBottom(2); - page_title_ = MakeParagraph("Account", 0.0f); - page_title_->setFontSize(27); + page_title_ = ui::MakeLabel(Tr("Account"), 28, ui::Text(), ui::FontRole::Heading); + page_title_->setSingleLine(true); page_title_->setGrow(1.0f); page_heading->addView(page_title_); - save_status_ = MakeParagraph("All changes saved", 0.0f); - save_status_->setFontSize(14); - save_status_->setTextColor(nvgRGB(88, 230, 146)); - page_heading->addView(save_status_); content_shell->addView(page_heading); page_subtitle_ = MakeParagraph("Manage your GeForce NOW identity and saved sign-in.", 14.0f); page_subtitle_->setFontSize(15); - page_subtitle_->setTextColor(nvgRGB(142, 149, 160)); + page_subtitle_->setTextColor(ui::Muted()); + page_subtitle_->setSingleLine(true); content_shell->addView(page_subtitle_); scrolling_frame_ = new brls::ScrollingFrame(); scrolling_frame_->setGrow(1.0f); + scrolling_frame_->setMinHeight(0); + scrolling_frame_->setId("settings/options"); + scrolling_frame_->setScrollingIndicatorVisible(false); scrolling_frame_->setScrollingBehavior(brls::ScrollingBehavior::CENTERED); content_container_ = new brls::Box(brls::Axis::COLUMN); - content_container_->setPadding(0, 0, 28, 0); + content_container_->setPadding(4, 4, 12, 4); scrolling_frame_->setContentView(content_container_); content_shell->addView(scrolling_frame_); body->addView(content_shell); + + auto* help_scroll = new brls::ScrollingFrame(); + help_scrolling_frame_ = help_scroll; + help_scroll->setWidth(332); + help_scroll->setShrink(0); + help_scroll->setMinHeight(0); + help_scroll->setScrollingIndicatorVisible(false); + auto* help = new brls::Box(brls::Axis::COLUMN); + help->setId("settings/help"); + help->setPadding(4, 0, 12, 0); + help_title_ = ui::MakeLabel({}, 15, ui::Muted(), ui::FontRole::Medium); + help_title_->setId("settings/help/title"); + help_title_->setSingleLine(false); + help_title_->setMarginBottom(14); + help->addView(help_title_); + help_value_ = ui::MakeLabel({}, 28, ui::Text(), ui::FontRole::Heading); + help_value_->setId("settings/help/value"); + help_value_->setSingleLine(false); + help_value_->setMarginBottom(16); + help->addView(help_value_); + + bitrate_meter_ = new brls::Box(brls::Axis::ROW); + bitrate_meter_->setHeight(42); + bitrate_meter_->setMarginBottom(16); + for (int step : {8, 12, 16, 20, 25}) + { + auto* column = new brls::Box(brls::Axis::COLUMN); + column->setGrow(1); + column->setMarginRight(step == 25 ? 0 : 6); + auto* bar = new brls::Rectangle(); + bar->setHeight(8); + bar->setCornerRadius(3); + bar->setMarginBottom(7); + column->addView(bar); + auto* label = ui::MakeLabel(std::to_string(step), 13, ui::Muted(), ui::FontRole::Mono); + column->addView(label); + bitrate_meter_->addView(column); + bitrate_steps_.push_back({bar, label}); + } + help->addView(bitrate_meter_); + help_description_ = ui::MakeLabel({}, 17, ui::Text()); + help_description_->setId("settings/help/description"); + help_description_->setSingleLine(false); + help_description_->setMarginBottom(18); + help->addView(help_description_); + help_next_ = ui::MakeLabel({}, 14, ui::Green(), ui::FontRole::Medium); + help_next_->setSingleLine(false); + help_next_->setMarginBottom(24); + help->addView(help_next_); + draft_summary_status_ = ui::MakeLabel({}, 14, ui::Muted(), ui::FontRole::Medium); + draft_summary_status_->setId("settings/draft-status"); + draft_summary_status_->setSingleLine(false); + draft_summary_status_->setMarginBottom(8); + help->addView(draft_summary_status_); + next_stream_summary_ = new ui::NextStreamSummaryView(draft_settings_); + next_stream_summary_->setId("settings/summary"); + help->addView(next_stream_summary_); + help_scroll->setContentView(help); + body->addView(help_scroll); addView(body); - registerAction("Save", brls::BUTTON_X, [this](brls::View* view) { + registerAction(Tr("Save"), brls::BUTTON_X, [this](brls::View* view) { return SaveChanges(view); }, false, true); - registerAction("Revert", brls::BUTTON_Y, [this](brls::View* view) { + registerAction(Tr("Revert"), brls::BUTTON_Y, [this](brls::View* view) { return RevertChanges(view); }, false, true); @@ -186,24 +236,13 @@ SettingsTab::~SettingsTab() brls::Box* SettingsTab::MakeSection(const std::string& title, const std::string& subtitle) { auto* section = new brls::Box(brls::Axis::COLUMN); - section->setPadding(16, 18, 16, 18); - section->setMarginBottom(14); - section->setCornerRadius(6); - section->setBorderThickness(1); - section->setBorderColor(nvgRGB(42, 46, 54)); - section->setBackgroundColor(nvgRGB(18, 20, 24)); - - auto* label = MakeParagraph(title, subtitle.empty() ? 14.0f : 4.0f); - label->setFontSize(19); - label->setTextColor(nvgRGB(242, 244, 247)); + section->setMarginBottom(18); + + auto* label = ui::MakeLabel(Tr(title), 14, ui::Muted(), ui::FontRole::Medium); + label->setSingleLine(true); + label->setMarginBottom(8); section->addView(label); - if (!subtitle.empty()) - { - auto* detail = MakeParagraph(subtitle, 14.0f); - detail->setFontSize(14); - detail->setTextColor(nvgRGB(133, 141, 152)); - section->addView(detail); - } + (void)subtitle; return section; } @@ -213,36 +252,19 @@ brls::Box* SettingsTab::MakeOptionRow( std::function value, std::function action) { - auto* row = new brls::Box(brls::Axis::ROW); - row->setHeight(64); - row->setAlignItems(brls::AlignItems::CENTER); - row->setPadding(7, 8, 7, 8); - row->setMarginBottom(4); - - auto* copy = new brls::Box(brls::Axis::COLUMN); - copy->setGrow(1.0f); - copy->setMarginRight(18); - auto* name = MakeParagraph(title, 2.0f); - name->setFontSize(18); - copy->addView(name); - auto* detail = MakeParagraph(description, 0.0f); - detail->setFontSize(13); - detail->setTextColor(nvgRGB(132, 140, 151)); - copy->addView(detail); - row->addView(copy); - - auto* button = new brls::Button(); - button->setWidth(224); - button->setHeight(42); - button->setCornerRadius(6); - button->setText(Tr(value())); - button->registerClickAction([this, action = std::move(action)](brls::View* view) { + auto* row = new ui::ActionRow(Tr(title), Tr(value()), + [this, action = std::move(action)](brls::View* view) { const bool handled = action ? action(view) : true; UpdateOptionValues(); return handled; + }, ui::ActionTone::Flat); + row->setId(OptionId(title)); + row->updateActionHint(brls::BUTTON_A, Tr("Change")); + row->setMarginBottom(6); + row->SetFocusHandler([this, title, description, value] { + ShowOptionHelp(title, description, value); }); - option_values_.push_back({button, std::move(value)}); - row->addView(button); + option_values_.push_back({row, std::move(value)}); return row; } @@ -253,32 +275,13 @@ brls::Box* SettingsTab::MakeActionRow( std::function action, bool destructive) { - auto* row = new brls::Box(brls::Axis::ROW); - row->setHeight(64); - row->setAlignItems(brls::AlignItems::CENTER); - row->setPadding(7, 8, 7, 8); - row->setMarginBottom(4); - - auto* copy = new brls::Box(brls::Axis::COLUMN); - copy->setGrow(1.0f); - copy->setMarginRight(18); - auto* name = MakeParagraph(title, 2.0f); - name->setFontSize(18); - name->setTextColor(destructive ? nvgRGB(255, 170, 174) : nvgRGB(238, 241, 245)); - copy->addView(name); - auto* detail = MakeParagraph(description, 0.0f); - detail->setFontSize(13); - detail->setTextColor(nvgRGB(132, 140, 151)); - copy->addView(detail); - row->addView(copy); - - auto* button = new brls::Button(); - button->setWidth(224); - button->setHeight(42); - button->setCornerRadius(6); - button->setText(Tr(button_text)); - button->registerClickAction(std::move(action)); - row->addView(button); + auto* row = new ui::ActionRow(Tr(title), Tr(button_text), std::move(action), + destructive ? ui::ActionTone::Destructive : ui::ActionTone::Flat); + row->setId(OptionId(title)); + row->setMarginBottom(6); + row->SetFocusHandler([this, title, description, button_text] { + ShowOptionHelp(title, description, [button_text] { return button_text; }); + }); return row; } @@ -286,21 +289,25 @@ brls::Label* SettingsTab::AddInfoLine( brls::Box* parent, const std::string& label, const std::string& value) { auto* row = new brls::Box(brls::Axis::ROW); - row->setHeight(40); + row->setMinHeight(40); row->setPadding(5, 8, 5, 8); + row->setAlignItems(brls::AlignItems::CENTER); row->setMarginBottom(2); row->setCornerRadius(7); - row->setHighlightCornerRadius(7); - row->setHighlightPadding(0); - row->setFocusable(true); auto* key = MakeParagraph(label, 0.0f); - key->setGrow(1.0f); + key->setWidthPercentage(40); + key->setMarginRight(12); + key->setShrink(0); + key->setSingleLine(true); key->setFontSize(15); - key->setTextColor(nvgRGB(139, 147, 158)); + key->setTextColor(ui::Muted()); row->addView(key); auto* text = MakeParagraph(value, 0.0f); + text->setGrow(1); + text->setShrink(1); + text->setSingleLine(true); text->setFontSize(15); - text->setTextColor(nvgRGB(210, 216, 225)); + text->setTextColor(ui::Text()); row->addView(text); parent->addView(row); return text; @@ -317,7 +324,7 @@ void SettingsTab::SelectCategory(Category category) break; case Category::Stream: page_title_->setText(Tr("Stream")); - page_subtitle_->setText(Tr("Choose a simple quality profile for your connection.")); + page_subtitle_->setText(Tr("Stream changes apply when the next game starts.")); break; case Category::Preferences: page_title_->setText(Tr("Preferences")); @@ -340,12 +347,8 @@ void SettingsTab::UpdateCategoryChrome() { const auto& item = category_nav_items_[index]; const bool active = index == selected; - item.row->setBackgroundColor( - active ? nvgRGBA(77, 218, 130, 24) : nvgRGBA(0, 0, 0, 0)); - item.marker->setColor( - active ? nvgRGB(77, 218, 130) : nvgRGBA(0, 0, 0, 0)); - item.label->setTextColor( - active ? nvgRGB(245, 247, 249) : nvgRGB(184, 190, 200)); + item.action->SetTone(active ? ui::ActionTone::Neutral : ui::ActionTone::Flat); + item.action->SetValue(active ? "›" : ""); } } @@ -360,6 +363,7 @@ void SettingsTab::RebuildCategory() ? category_nav_items_[selected].row : static_cast(this); MoveFocusBeforeDestroy(content_container_, stable_focus); + ShowOptionHelp({}, page_subtitle_->getFullText()); option_values_.clear(); cover_cache_files_ = nullptr; @@ -387,16 +391,61 @@ void SettingsTab::RebuildCategory() void SettingsTab::UpdateOptionValues() { - for (auto& [button, value] : option_values_) + for (auto& [row, value] : option_values_) + { + if (row && value) + row->SetValue(Tr(value())); + } + UpdateOptionHelp(); +} + +void SettingsTab::ShowOptionHelp( + const std::string& title, const std::string& description, + std::function value) +{ + if (help_scrolling_frame_ && help_option_ != title) + help_scrolling_frame_->setContentOffsetY(0, false); + help_option_ = title; + help_description_text_ = description; + help_value_provider_ = std::move(value); + UpdateOptionHelp(); +} + +void SettingsTab::UpdateOptionHelp() +{ + if (!help_title_) + return; + + const bool bitrate = help_option_ == "Bitrate"; + help_title_->setText(help_option_.empty() ? page_title_->getFullText() : Tr(help_option_)); + help_value_->setText(help_value_provider_ ? Tr(help_value_provider_()) : ""); + help_value_->setFontSize(bitrate ? 40 : 24); + help_value_->setVisibility(help_value_provider_ ? brls::Visibility::VISIBLE : brls::Visibility::GONE); + help_description_->setText(Tr(help_description_text_)); + bitrate_meter_->setVisibility(bitrate ? brls::Visibility::VISIBLE : brls::Visibility::GONE); + help_next_->setVisibility(bitrate ? brls::Visibility::VISIBLE : brls::Visibility::GONE); + if (bitrate) { - if (button && value) - button->setText(Tr(value())); + constexpr std::array steps {8000, 12000, 16000, 20000, 25000}; + bool matched = false; + for (size_t index = 0; index < steps.size(); ++index) + { + const bool selected = steps[index] == draft_settings_.bitrate_kbps; + matched = matched || selected; + bitrate_steps_[index].first->setColor(selected ? ui::Green() : ui::Rule()); + bitrate_steps_[index].second->setTextColor(selected ? ui::Green() : ui::Muted()); + } + if (!matched) + help_title_->setText(Tr("Bitrate") + " · " + Tr("Custom")); + auto next = draft_settings_; + settings::CycleBitrate(next); + help_next_->setText(Tr("Next value") + ": " + BitrateValue(next.bitrate_kbps)); } } void SettingsTab::MarkDirty() { - dirty_ = !SameSettings(draft_settings_, saved_settings_); + dirty_ = draft_settings_ != saved_settings_; RefreshSummary(); } @@ -427,7 +476,7 @@ bool SettingsTab::SaveChanges(brls::View* view) const std::array category_names { "Account", "Stream", "Preferences", "App"}; for (size_t index = 0; index < category_nav_items_.size() && index < category_names.size(); ++index) - category_nav_items_[index].label->setText(Tr(category_names[index])); + category_nav_items_[index].action->SetTitle(Tr(category_names[index])); if (interface_language_changed) SelectCategory(category_); brls::Application::notify(Tr("Settings saved; changes apply to the next stream")); @@ -437,8 +486,6 @@ bool SettingsTab::SaveChanges(brls::View* view) bool SettingsTab::RevertChanges(brls::View* view) { (void)view; - ++proxy_request_generation_; - community_proxy_provisioning_ = false; draft_settings_ = saved_settings_; dirty_ = false; RefreshSummary(); @@ -481,14 +528,18 @@ void SettingsTab::RefreshSummary() if (dirty_) { - save_status_->setText(Tr("Unsaved changes | X Save")); - save_status_->setTextColor(nvgRGB(255, 190, 92)); + save_status_->setText(Tr("Unsaved changes")); + save_status_->setTextColor(ui::Green()); } else { save_status_->setText(Tr("All changes saved")); - save_status_->setTextColor(nvgRGB(88, 230, 146)); + save_status_->setTextColor(ui::Muted()); } + draft_summary_status_->setText(Tr(dirty_ ? "Unsaved changes" : "All changes saved")); + draft_summary_status_->setTextColor(dirty_ ? ui::Green() : ui::Muted()); + next_stream_summary_->Update(draft_settings_); + UpdateOptionHelp(); } } // namespace opennow diff --git a/app/src/settings_tab.hpp b/app/src/settings_tab.hpp index 63771fb5..6a2e03df 100644 --- a/app/src/settings_tab.hpp +++ b/app/src/settings_tab.hpp @@ -15,6 +15,7 @@ #include #include "stream_settings.hpp" +#include "ui_theme.hpp" namespace opennow { @@ -46,6 +47,9 @@ class SettingsTab : public brls::Box void BuildAppPage(); void UpdateCategoryChrome(); void UpdateOptionValues(); + void ShowOptionHelp(const std::string& title, const std::string& description, + std::function value = {}); + void UpdateOptionHelp(); void MarkDirty(); bool SaveChanges(brls::View* view); bool RevertChanges(brls::View* view); @@ -67,6 +71,7 @@ class SettingsTab : public brls::Box static brls::Label* MakeParagraph( const std::string& text, float bottom_margin = 16.0f); static std::string FormatBytes(std::uint64_t bytes); + static std::string BitrateValue(int kbps); brls::Box* MakeSection(const std::string& title, const std::string& subtitle = {}); brls::Box* MakeOptionRow( const std::string& title, @@ -86,7 +91,6 @@ class SettingsTab : public brls::Box bool SwitchSavedAccount(brls::View* view); bool BeginLogin(brls::View* view); bool ClearCoverCache(brls::View* view); - bool ToggleCommunityProxy(brls::View* view); bool ChooseGameLanguage(brls::View* view); bool TogglePersistGameSettings(brls::View* view); bool ToggleControllerLayout(brls::View* view); @@ -100,24 +104,33 @@ class SettingsTab : public brls::Box brls::Label* page_title_ = nullptr; brls::Label* page_subtitle_ = nullptr; brls::Label* save_status_ = nullptr; + brls::Label* help_title_ = nullptr; + brls::Label* help_value_ = nullptr; + brls::Label* help_description_ = nullptr; + brls::Label* help_next_ = nullptr; + brls::Label* draft_summary_status_ = nullptr; + brls::Box* bitrate_meter_ = nullptr; + std::vector> bitrate_steps_; + ui::NextStreamSummaryView* next_stream_summary_ = nullptr; + std::string help_option_; + std::string help_description_text_; + std::function help_value_provider_; brls::ScrollingFrame* scrolling_frame_ = nullptr; + brls::ScrollingFrame* help_scrolling_frame_ = nullptr; brls::Box* content_container_ = nullptr; struct CategoryNavItem { brls::Box* row = nullptr; - brls::Rectangle* marker = nullptr; - brls::Label* label = nullptr; + ui::ActionRow* action = nullptr; }; std::vector category_nav_items_; - std::vector>> option_values_; + std::vector>> option_values_; Category category_ = Category::Account; StreamSettings saved_settings_; StreamSettings draft_settings_; bool settings_loaded_ = false; bool dirty_ = false; - bool community_proxy_provisioning_ = false; - std::uint64_t proxy_request_generation_ = 0; std::shared_ptr alive_ = std::make_shared(true); brls::Label* cover_cache_files_ = nullptr; brls::Label* cover_cache_bytes_ = nullptr; diff --git a/app/src/settings_tab_actions.cpp b/app/src/settings_tab_actions.cpp index ee7418f8..8773a0ac 100644 --- a/app/src/settings_tab_actions.cpp +++ b/app/src/settings_tab_actions.cpp @@ -374,69 +374,6 @@ bool SettingsTab::RefreshServerLocations(brls::View* view) return true; } -bool SettingsTab::ToggleCommunityProxy(brls::View* view) -{ - (void)view; - if (community_proxy_provisioning_) - return true; - - if (draft_settings_.community_proxy_enabled) - { - draft_settings_.community_proxy_enabled = false; - MarkDirty(); - return true; - } - - const auto alive = alive_; - auto* dialog = new brls::Dialog( - "The Zortos community proxy is optional, shared and may be rate-limited or " - "unavailable. It only routes NVIDIA catalog and session requests; streaming " - "traffic stays direct."); - dialog->addButton("Enable proxy", [this, alive]() { - if (!alive->load()) - return; - const auto generation = ++proxy_request_generation_; - community_proxy_provisioning_ = true; - UpdateOptionValues(); - brls::Application::notify("Activating community proxy..."); - - GfnClient client = client_; - brls::async([this, alive, generation, client]() mutable { - if (!alive->load()) - return; - try - { - std::string proxy_url = client.ProvisionCommunityProxy(); - brls::sync([this, alive, generation, proxy_url = std::move(proxy_url)]() mutable { - if (!alive->load() || generation != proxy_request_generation_) - return; - draft_settings_.community_proxy_url = std::move(proxy_url); - draft_settings_.community_proxy_enabled = true; - community_proxy_provisioning_ = false; - MarkDirty(); - UpdateOptionValues(); - brls::Application::notify( - "Community proxy ready; press X to save"); - }); - } - catch (const std::exception& ex) - { - const std::string message = ex.what(); - brls::sync([this, alive, generation, message] { - if (!alive->load() || generation != proxy_request_generation_) - return; - community_proxy_provisioning_ = false; - UpdateOptionValues(); - ShowError("Community Proxy Failed", message); - }); - } - }, false); - }); - dialog->addButton("Cancel", [] {}); - dialog->open(); - return true; -} - bool SettingsTab::CycleImageQuality(brls::View* view) { (void)view; diff --git a/app/src/settings_tab_pages.cpp b/app/src/settings_tab_pages.cpp index 9e607e5d..635bd7d5 100644 --- a/app/src/settings_tab_pages.cpp +++ b/app/src/settings_tab_pages.cpp @@ -74,7 +74,7 @@ void SettingsTab::BuildStreamPage() "Use automatic routing or choose a specific GeForce NOW data center."); location->addView(MakeOptionRow( "Location", - "Locations are ranked by direct latency from this console.", + "Use automatic routing or choose a specific GeForce NOW data center.", [this] { return ServerLocationValue(); }, [this](brls::View* view) { return ChooseServerLocation(view); })); location->addView(MakeActionRow( @@ -101,10 +101,10 @@ void SettingsTab::BuildStreamPage() video->addView(MakeOptionRow( "Bitrate", "Higher values improve motion detail but need stronger Wi-Fi.", [this] { - return std::to_string(draft_settings_.bitrate_kbps / 1000) + " Mbps"; + return BitrateValue(draft_settings_.bitrate_kbps); }, [this](brls::View* view) { return CycleBitrate(view); })); - AddInfoLine(video, "Encoder", "H.264"); + AddInfoLine(video, "Encoder", draft_settings_.codec); video->addView(MakeOptionRow( "Decoder", "Choose automatic fallback, hardware-only or software-only decode.", [this] { @@ -122,24 +122,6 @@ void SettingsTab::BuildStreamPage() [this](brls::View* view) { return CycleImageQuality(view); })); content_container_->addView(video); - auto* connection = MakeSection( - "Connection", - "Optional routing for NVIDIA catalog, session creation and queue requests."); - connection->addView(MakeOptionRow( - "Zortos community proxy", - "Streaming, signaling and account authentication always stay direct.", - [this] { - if (community_proxy_provisioning_) - return std::string("Connecting..."); - return draft_settings_.community_proxy_enabled - ? std::string("Enabled") - : std::string("Disabled"); - }, - [this](brls::View* view) { return ToggleCommunityProxy(view); })); - content_container_->addView(connection); - - if (!server_locations_loaded_ && !server_locations_loading_) - BeginServerLocationLoad(false, false); } void SettingsTab::BuildPreferencesPage() @@ -205,6 +187,18 @@ void SettingsTab::BuildAppPage() "When minimized, pop a reminder when queue drops to this position. Choose 5, 10, 20 or 50.", [this] { return std::to_string(draft_settings_.queue_notify_threshold); }, [this](brls::View* view) { return CycleQueueNotifyThreshold(view); })); + interface->addView(MakeOptionRow( + "Debug diagnostics", + "Enable diagnostic logging for troubleshooting. Applies after saving.", + [this] { + return draft_settings_.debug_diagnostics + ? std::string("Enabled") : std::string("Disabled"); + }, + [this](brls::View*) { + draft_settings_.debug_diagnostics = !draft_settings_.debug_diagnostics; + MarkDirty(); + return true; + })); content_container_->addView(interface); auto* shortcuts = MakeSection( diff --git a/app/src/stream/audio/AudioPipeline.cpp b/app/src/stream/audio/AudioPipeline.cpp index 6b6198c0..9ed2273a 100644 --- a/app/src/stream/audio/AudioPipeline.cpp +++ b/app/src/stream/audio/AudioPipeline.cpp @@ -804,6 +804,10 @@ void AudioPipeline::set_sender_report(uint32_t ssrc, uint64_t ntp_us, uint32_t r " rtp=" + std::to_string(rtp_timestamp)); } +bool AudioPipeline::is_playing() const { + return impl_->output_started.load(std::memory_order_acquire); +} + int64_t AudioPipeline::playback_ntp_us() const { if (!impl_->output_ready.load()) return -1; diff --git a/app/src/stream/audio/AudioPipeline.hpp b/app/src/stream/audio/AudioPipeline.hpp index 26522dd0..ee707c96 100644 --- a/app/src/stream/audio/AudioPipeline.hpp +++ b/app/src/stream/audio/AudioPipeline.hpp @@ -19,6 +19,7 @@ class AudioPipeline { void submit(const PeerAudioPacket& packet); void set_sender_report(uint32_t ssrc, uint64_t ntp_us, uint32_t rtp_timestamp); std::string debug_info() const; + bool is_playing() const; int64_t playback_ntp_us() const; private: diff --git a/app/src/stream/deko3d/DKVideoRenderer.cpp b/app/src/stream/deko3d/DKVideoRenderer.cpp index 829e9d22..44ee5e1b 100644 --- a/app/src/stream/deko3d/DKVideoRenderer.cpp +++ b/app/src/stream/deko3d/DKVideoRenderer.cpp @@ -2,7 +2,6 @@ #include "DKVideoRenderer.hpp" #include "SoftwareYuvUpload.hpp" -#include "../../stream_settings.hpp" #include "../../video_quality_policy.hpp" #include @@ -17,6 +16,7 @@ extern "C" { #include #include #include +#include namespace { @@ -121,6 +121,11 @@ void set_color_transform(Transformation& transform, AVColorSpace color_space, bo } // namespace +DKVideoRenderer::DKVideoRenderer(std::string image_quality_mode) + : image_quality_mode_(std::move(image_quality_mode)) +{ +} + DKVideoRenderer::~DKVideoRenderer() { if (auto* active = configurations_.active()) @@ -176,7 +181,8 @@ bool DKVideoRenderer::isFrameFullRange(const AVFrame* frame) } bool DKVideoRenderer::Configuration::initialize( - int width, int height, AVFrame* frame, uint64_t generation) + int width, int height, AVFrame* frame, uint64_t generation, + const std::string& image_quality_mode) { hardware_frames_ = frame->format == AV_PIX_FMT_NVTEGRA; @@ -237,9 +243,8 @@ bool DKVideoRenderer::Configuration::initialize( transform.uv[3] = multiplier; } - const auto quality_settings = opennow::LoadStreamSettings(); const auto tuning = opennow::video::ResolveQualityTuning( - quality_settings.image_quality_mode); + image_quality_mode); transform.quality[0] = 1.0f / static_cast(frame_width_); transform.quality[1] = 1.0f / static_cast(frame_height_); transform.quality[2] = tuning.denoise_strength; @@ -330,7 +335,7 @@ bool DKVideoRenderer::Configuration::initialize( brls::Logger::info( "Deko3D {} renderer initialized {}x{} quality={}", hardware_frames_ ? "NVTEGRA zero-copy" : "software NV12 upload", - frame_width_, frame_height_, quality_settings.image_quality_mode); + frame_width_, frame_height_, image_quality_mode); return true; } @@ -485,7 +490,7 @@ void DKVideoRenderer::drawLatest(NVGcontext* vg, int width, int height, AVFrame* (!active || !active->matches(width, height, frame))) { configurations_.replace(started, [&]() -> std::unique_ptr { auto candidate = std::make_unique(); - if (!candidate->initialize(width, height, frame, generation)) + if (!candidate->initialize(width, height, frame, generation, image_quality_mode_)) return nullptr; return candidate; }); diff --git a/app/src/stream/deko3d/DKVideoRenderer.hpp b/app/src/stream/deko3d/DKVideoRenderer.hpp index bd9c792f..0bcc89d9 100644 --- a/app/src/stream/deko3d/DKVideoRenderer.hpp +++ b/app/src/stream/deko3d/DKVideoRenderer.hpp @@ -15,10 +15,11 @@ #include #include #include +#include class DKVideoRenderer : public IVideoRenderer { public: - DKVideoRenderer() = default; + explicit DKVideoRenderer(std::string image_quality_mode); ~DKVideoRenderer() override; void draw(NVGcontext* vg, int width, int height, AVFrame* frame, int imageFormat) override; @@ -31,7 +32,8 @@ class DKVideoRenderer : public IVideoRenderer { private: struct Configuration { ~Configuration(); - bool initialize(int width, int height, AVFrame* frame, uint64_t generation); + bool initialize(int width, int height, AVFrame* frame, uint64_t generation, + const std::string& image_quality_mode); bool matches(int width, int height, const AVFrame* frame) const; bool prepareFrame(AVFrame* frame, uint64_t generation); bool drawLatest(AVFrame* frame, uint64_t generation); @@ -113,6 +115,7 @@ class DKVideoRenderer : public IVideoRenderer { opennow::video::GpuFrameQueue submitted_frames_; }; + std::string image_quality_mode_; opennow::video::GpuConfigurationQueue configurations_; VideoRenderStats render_stats_ {}; }; diff --git a/app/src/stream/ffmpeg/FFmpegVideoDecoder.cpp b/app/src/stream/ffmpeg/FFmpegVideoDecoder.cpp index 05f5f5c3..5c057554 100644 --- a/app/src/stream/ffmpeg/FFmpegVideoDecoder.cpp +++ b/app/src/stream/ffmpeg/FFmpegVideoDecoder.cpp @@ -1,13 +1,13 @@ #include "FFmpegVideoDecoder.hpp" #include "AVFrameHolder.hpp" #include "borealis.hpp" -#include "../../stream_settings.hpp" #include "../../video_quality_policy.hpp" #include "../DecodeQueuePolicy.hpp" #include #include #include #include +#include extern "C" { #include } @@ -35,7 +35,8 @@ extern "C" { //} #endif -FFmpegVideoDecoder::FFmpegVideoDecoder() { +FFmpegVideoDecoder::FFmpegVideoDecoder(std::string image_quality_mode) + : image_quality_mode_(std::move(image_quality_mode)) { // AVBufferRef* deviceRef = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_MEDIACODEC); // AVHWDeviceContext* ctx = (AVHWDeviceContext*)deviceRef->data; // AVMediaCodecDeviceContext* hwctx = (AVMediaCodecDeviceContext*)ctx->hwctx; @@ -124,14 +125,10 @@ int FFmpegVideoDecoder::setup(int video_format, int width, int height, } #else if (video_format & VIDEO_FORMAT_MASK_H264) { -#if defined(OPENNOW_ENABLE_NVDEC) - m_decoder = m_uses_hardware_frames - ? avcodec_find_decoder_by_name("h264_nvtegra") - : avcodec_find_decoder(AV_CODEC_ID_H264); -#else +#if !defined(OPENNOW_ENABLE_NVDEC) m_uses_hardware_frames = false; - m_decoder = avcodec_find_decoder(AV_CODEC_ID_H264); #endif + m_decoder = avcodec_find_decoder(AV_CODEC_ID_H264); } else if (video_format & VIDEO_FORMAT_MASK_H265) { m_decoder = avcodec_find_decoder(AV_CODEC_ID_HEVC); } else { @@ -151,8 +148,7 @@ int FFmpegVideoDecoder::setup(int video_format, int width, int height, } if (perf_lvl & DISABLE_LOOP_FILTER) { - const auto settings = opennow::LoadStreamSettings(); - const auto tuning = opennow::video::ResolveQualityTuning(settings.image_quality_mode); + const auto tuning = opennow::video::ResolveQualityTuning(image_quality_mode_); // Keep deblocking on reference pictures in enhanced modes. This removes // persistent block edges while still skipping work on disposable frames. m_decoder_context->skip_loop_filter = tuning.preserve_reference_deblocking @@ -194,7 +190,7 @@ int FFmpegVideoDecoder::setup(int video_format, int width, int height, char error[512]; av_strerror(err, error, sizeof(error)); brls::Logger::error("FFmpeg: Error initializing hardware decoder - {}", error); - return -1; + return err; } m_decoder_context->hw_device_ctx = av_buffer_ref(hw_device_ctx); } else { diff --git a/app/src/stream/ffmpeg/FFmpegVideoDecoder.hpp b/app/src/stream/ffmpeg/FFmpegVideoDecoder.hpp index acec4c19..db21fcdb 100644 --- a/app/src/stream/ffmpeg/FFmpegVideoDecoder.hpp +++ b/app/src/stream/ffmpeg/FFmpegVideoDecoder.hpp @@ -1,10 +1,11 @@ #pragma once #include "IFFmpegVideoDecoder.hpp" #include "AVFrameHolder.hpp" +#include class FFmpegVideoDecoder : public IFFmpegVideoDecoder { public: - FFmpegVideoDecoder(); + explicit FFmpegVideoDecoder(std::string image_quality_mode); ~FFmpegVideoDecoder(); int setup(int video_format, int width, int height, int redraw_rate, @@ -15,8 +16,10 @@ class FFmpegVideoDecoder : public IFFmpegVideoDecoder { int capabilities() const override; VideoDecodeStats* video_decode_stats() override; bool uses_hardware_frames() const override { return m_uses_hardware_frames; } + uint64_t decoded_frame_count() const { return m_frames_out; } private: + std::string image_quality_mode_; int decode(); AVFrame* get_frame(bool native_frame, int& decode_error); @@ -30,7 +33,7 @@ class FFmpegVideoDecoder : public IFFmpegVideoDecoder { int m_stream_fps = 0; int m_frames_in = 0; - int m_frames_out = 0; + uint64_t m_frames_out = 0; int m_corrupt_frames_dropped = 0; int m_current_frame = 0, m_next_frame = 0; uint32_t m_last_frame = 0; diff --git a/app/src/stream_end_policy.hpp b/app/src/stream_end_policy.hpp index 5162ea97..3dc6a9ba 100644 --- a/app/src/stream_end_policy.hpp +++ b/app/src/stream_end_policy.hpp @@ -36,6 +36,15 @@ struct StreamEndSignals std::chrono::milliseconds video_idle {0}; }; +inline bool HasStreamNetworkConnection( + bool internet_connected, + bool video_started, + std::chrono::milliseconds video_idle) +{ + return internet_connected || + (video_started && video_idle < std::chrono::seconds(3)); +} + inline StreamEndReason DetectStreamEnd(const StreamEndSignals& signals) { constexpr auto kFreeLimit = std::chrono::hours(1); diff --git a/app/src/stream_launch.cpp b/app/src/stream_launch.cpp new file mode 100644 index 00000000..e6ae9dd1 --- /dev/null +++ b/app/src/stream_launch.cpp @@ -0,0 +1,17 @@ +#include "cloud_launch_internal.hpp" +#include "StreamView.hpp" +#include + +namespace opennow +{ +void PresentCloudStream(const SessionInfo& info, const GfnClient& client, + const AuthSession& auth, const std::string& title, + const StreamSettings& settings) +{ + const std::string& token = auth.tokens.id_token.empty() + ? auth.tokens.access_token : auth.tokens.id_token; + brls::Application::pushActivity(new brls::Activity(StreamView::create( + info.signaling_url, token, info.session_id, info.media_ip, info.media_port, + info.ice_servers, client, auth, title, settings))); +} +} diff --git a/app/src/stream_overlay_view.cpp b/app/src/stream_overlay_view.cpp new file mode 100644 index 00000000..b0cd07af --- /dev/null +++ b/app/src/stream_overlay_view.cpp @@ -0,0 +1,186 @@ +#include "stream_overlay_view.hpp" +#include "localization.hpp" +#include "ui_theme.hpp" + +#include +#include +#include + +namespace opennow +{ + +StreamOverlayDisplay FormatStreamOverlay(const StreamOverlaySample& sample) +{ + StreamOverlayDisplay display; + display.game_title = sample.game_title.empty() ? Tr("GeForce NOW session") : sample.game_title; + display.provider = sample.provider.empty() ? "GeForce NOW" : sample.provider; + display.connection = Tr(sample.peer_connected ? "Connected" : sample.signaling_connected ? "Negotiating" : "Connecting"); + display.peer_connected = sample.peer_connected; + display.wifi_warning = sample.wifi_warning; + display.nte_session = sample.nte_session; + char number[96]; + std::snprintf(number, sizeof(number), "%.0f", sample.presented_fps); + display.metrics[0] = number; + std::snprintf(number, sizeof(number), "%.1f", sample.bitrate_mbps); + display.metrics[1] = number; + display.metrics[2] = sample.rtt_ms < 0 ? Tr("Unknown") : std::to_string(sample.rtt_ms); + const auto packets = sample.packets_received + sample.sequence_gaps; + if (packets > 0) { + std::snprintf(number, sizeof(number), "%.2f", static_cast(sample.sequence_gaps) * 100.0 / static_cast(packets)); + display.metrics[3] = number; + } else { + display.metrics[3] = Tr("Unknown"); + } + display.details[0] = sample.width > 0 && sample.height > 0 + ? std::to_string(sample.width) + "×" + std::to_string(sample.height) : Tr("Unknown"); + std::snprintf(number, sizeof(number), "%.0f / %.0f / %.0f", sample.incoming_fps, sample.decoded_fps, sample.presented_fps); + display.details[1] = number; + std::snprintf(number, sizeof(number), "%.1f ms", static_cast(sample.decode_us_p95) / 1000.0); + display.details[2] = number; + std::snprintf(number, sizeof(number), "%.1f ms", static_cast(sample.render_us_p95) / 1000.0); + display.details[3] = number; + display.details[4] = std::to_string(sample.queue_size) + " / " + std::to_string(sample.queue_high_water); + display.details[5] = sample.network.empty() ? Tr("Unknown") : sample.network; + display.details[6] = (sample.codec.empty() ? Tr("Unknown") : sample.codec) + " / " + (sample.location.empty() ? Tr("Auto") : sample.location); + display.details[7] = std::to_string(sample.dropped_frames); + display.details[8] = std::to_string(sample.nack_requests); + display.details[9] = std::to_string(sample.late_packets_dropped); + return display; +} + +void SetStreamOverlayElapsed(StreamOverlayDisplay& display, std::uint64_t seconds) +{ + char elapsed[48]; + std::snprintf(elapsed, sizeof(elapsed), "%llu:%02llu", static_cast(seconds / 60), static_cast(seconds % 60)); + display.elapsed = elapsed; +} + +StreamOverlayView::StreamOverlayView() +{ + setId("stream-overlay/root"); + setFocusable(true); + setHideHighlightBackground(true); + setHideHighlightBorder(true); + logo_.setImageFromRes("img/opennow-logo-mark-small.png"); + for (size_t index = 0; index < fonts_.size(); ++index) + fonts_[index] = ui::Font(static_cast(index)); +} + +void StreamOverlayView::Update(const StreamOverlaySample& sample) +{ + std::string elapsed = std::move(display_.elapsed); + display_ = FormatStreamOverlay(sample); + display_.elapsed = std::move(elapsed); +} + +void StreamOverlayView::UpdateElapsed(std::uint64_t seconds) +{ + SetStreamOverlayElapsed(display_, seconds); +} + +void StreamOverlayView::draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style, brls::FrameContext*) +{ + nvgSave(vg); + nvgTranslate(vg, x, y); + nvgScale(vg, width / 1280.0f, height / 720.0f); + auto panel = [vg](float px, float py, float w, float h) { + nvgBeginPath(vg); + nvgRoundedRect(vg, px, py, w, h, 12); + nvgFillColor(vg, ui::Raised()); + nvgFill(vg); + nvgStrokeWidth(vg, 1); + nvgStrokeColor(vg, ui::Rule()); + nvgStroke(vg); + }; + auto text = [vg, this](float tx, float ty, const char* copy, float size, NVGcolor color, + ui::FontRole font = ui::FontRole::Body, int align = NVG_ALIGN_LEFT) { + nvgFontFaceId(vg, fonts_[static_cast(font)]); + nvgFontSize(vg, size); + nvgFillColor(vg, color); + nvgTextAlign(vg, align | NVG_ALIGN_MIDDLE); + nvgText(vg, tx, ty, copy, nullptr); + }; + nvgBeginPath(vg); + nvgRect(vg, 0, 0, 1280, 720); + nvgFillColor(vg, nvgRGBA(11, 12, 14, 242)); + nvgFill(vg); + if (logo_.getTexture() > 0) { + const float mark_height = 48.0f * logo_.getOriginalImageHeight() / logo_.getOriginalImageWidth(); + const float mark_y = 44.0f - mark_height * 0.5f; + nvgBeginPath(vg); + nvgRect(vg, 40, mark_y, 48, mark_height); + nvgFillPaint(vg, nvgImagePattern(vg, 40, mark_y, 48, mark_height, 0, logo_.getTexture(), 1)); + nvgFill(vg); + } + text(98, 44, "OpenNOW · Stream status", 15, ui::Muted(), ui::FontRole::Medium); + nvgFontFaceId(vg, fonts_[static_cast(ui::FontRole::Heading)]); + nvgFontSize(vg, 29); + const bool long_title = nvgTextBounds(vg, 0, 0, display_.game_title.c_str(), nullptr, nullptr) > 860; + nvgSave(vg); + nvgScissor(vg, 40, 66, long_title ? 818 : 860, 48); + text(40, 88, display_.game_title.c_str(), 29, ui::Text(), ui::FontRole::Heading); + nvgRestore(vg); + if (long_title) + text(870, 88, "…", 29, ui::Text(), ui::FontRole::Heading); + panel(940, 70, 300, 36); + text(1090, 88, display_.connection.c_str(), 17, display_.peer_connected ? ui::Green() : ui::Muted(), ui::FontRole::Medium, NVG_ALIGN_CENTER); + nvgSave(vg); + nvgScissor(vg, 40, 108, 1100, 28); + text(40, 124, display_.provider.c_str(), 17, ui::Muted()); + nvgRestore(vg); + text(1240, 124, display_.elapsed.c_str(), 17, ui::Muted(), ui::FontRole::Medium, NVG_ALIGN_RIGHT); + + static constexpr const char* metric_labels[] = {"Display FPS", "Bitrate", "Ping", "Packet loss"}; + static constexpr const char* units[] = {"fps", "Mbps", "ms", "%"}; + for (size_t index = 0; index < 4; ++index) + { + const float mx = 40 + static_cast(index) * 303.75f; + panel(mx, 160, 288.75f, 116); + text(mx + 18, 185, metric_labels[index], 16, ui::Muted()); + text(mx + 18, 224, display_.metrics[index].c_str(), display_.metrics[index].size() > 6 ? 29 : 38, ui::Text(), ui::FontRole::Display); + nvgFontFaceId(vg, fonts_[static_cast(ui::FontRole::Display)]); + nvgFontSize(vg, display_.metrics[index].size() > 6 ? 29 : 38); + const float value_width = nvgTextBounds(vg, 0, 0, display_.metrics[index].c_str(), nullptr, nullptr); + text(mx + 26 + value_width, 228, units[index], 16, ui::Muted()); + text(mx + 18, 254, index == 0 ? "Presented frames" : index == 1 ? "Incoming video" : index == 2 ? "Round-trip time" : "Sequence gaps / total", 13, ui::Muted()); + } + panel(40, 296, 730, 344); + panel(794, 296, 446, 344); + text(60, 324, "Stream details", 18, ui::Text(), ui::FontRole::Heading); + text(814, 324, "Controller shortcuts", 18, ui::Text(), ui::FontRole::Heading); + static constexpr const char* detail_labels[] = { + "Resolution", "FPS in / decode / display", "Decode latency p95", "Render latency p95", "Decoder queue / high-water", + "Network", "Codec / configured location", "Dropped video frames", "NACK recovery requests", "Late packets dropped"}; + for (size_t index = 0; index < 10; ++index) + { + const size_t column = index / 5; + const float dx = 60 + static_cast(column) * 354; + const float dy = 366 + static_cast(index % 5) * 48; + text(dx, dy, detail_labels[index], 14, ui::Muted()); + nvgSave(vg); + nvgScissor(vg, dx, dy + 8, 330, 24); + text(dx, dy + 24, display_.details[index].c_str(), 16, ui::Text(), ui::FontRole::Mono); + nvgRestore(vg); + } + static constexpr const char* keys[] = {"Minus + Plus", "Minus + Y", "Keyboard strip", "B", "ZL + ZR + −", "Hold +", "Touch", "L + X"}; + static constexpr const char* actions[] = {"Open or close this menu", "On-screen keyboard", "Esc, Win and Windows shortcuts", "Close menu or keyboard", "Exit the stream", "Xbox Guide button", "Remote pointer", "NTE auto-login"}; + for (size_t index = 0; index < (display_.nte_session ? 8u : 7u); ++index) + { + const float sy = 358 + static_cast(index) * 32; + panel(814, sy - 12, 120, 28); + text(874, sy + 2, keys[index], 14, ui::Text(), ui::FontRole::Medium, NVG_ALIGN_CENTER); + text(948, sy + 2, actions[index], 16, ui::Muted()); + } + if (display_.wifi_warning) + text(814, 615, "2.4 GHz Wi-Fi · Use 5 GHz or Ethernet", 14, ui::Danger(), ui::FontRole::Medium); + nvgBeginPath(vg); + nvgRect(vg, 0, 660, 1280, 1); + nvgFillColor(vg, ui::Rule()); + nvgFill(vg); + text(40, 690, "Menu controls stay on your Switch and are not sent to the game.", 16, ui::Muted()); + text(1240, 690, "B Close menu", 17, ui::Text(), ui::FontRole::Medium, NVG_ALIGN_RIGHT); + nvgRestore(vg); +} + +} diff --git a/app/src/stream_overlay_view.hpp b/app/src/stream_overlay_view.hpp new file mode 100644 index 00000000..539aaad5 --- /dev/null +++ b/app/src/stream_overlay_view.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include + +namespace opennow +{ + +struct StreamOverlaySample +{ + std::string game_title; + std::string provider; + std::string codec; + std::string location; + std::string network; + float incoming_fps = 0; + float decoded_fps = 0; + float presented_fps = 0; + float bitrate_mbps = 0; + int rtt_ms = -1; + int width = 0; + int height = 0; + std::uint64_t decode_us_p95 = 0; + std::uint64_t render_us_p95 = 0; + std::uint64_t queue_size = 0; + std::uint64_t queue_high_water = 0; + std::uint64_t packets_received = 0; + std::uint64_t sequence_gaps = 0; + std::uint64_t late_packets_dropped = 0; + std::uint64_t dropped_frames = 0; + std::uint64_t nack_requests = 0; + bool peer_connected = false; + bool signaling_connected = false; + bool wifi_warning = false; + bool nte_session = false; +}; + +struct StreamOverlayDisplay +{ + std::string game_title; + std::string provider; + std::string connection; + std::array metrics; + std::array details; + std::string elapsed; + bool peer_connected = false; + bool wifi_warning = false; + bool nte_session = false; +}; + +StreamOverlayDisplay FormatStreamOverlay(const StreamOverlaySample& sample); +void SetStreamOverlayElapsed(StreamOverlayDisplay& display, std::uint64_t seconds); + +class StreamOverlayView final : public brls::View +{ + public: + StreamOverlayView(); + void Update(const StreamOverlaySample& sample); + void UpdateElapsed(std::uint64_t seconds); + const StreamOverlayDisplay& Display() const { return display_; } + void draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style style, brls::FrameContext* ctx) override; + + private: + brls::Image logo_; + std::array fonts_ {}; + StreamOverlayDisplay display_; +}; + +} diff --git a/app/src/stream_settings.cpp b/app/src/stream_settings.cpp index 103b8d4b..89c98325 100644 --- a/app/src/stream_settings.cpp +++ b/app/src/stream_settings.cpp @@ -1,7 +1,6 @@ #include "stream_settings.hpp" #include "app_paths.hpp" #include "atomic_file_replace.hpp" -#include "community_proxy_policy.hpp" #include "localization.hpp" #include "server_location_policy.hpp" @@ -150,14 +149,8 @@ StreamSettings Sanitize(StreamSettings settings) if (!IsSupportedInterfaceLanguage(settings.interface_language)) settings.interface_language = "en"; - if (!settings.community_proxy_url.empty() && - !community_proxy::IsCommunityProxyUrl(settings.community_proxy_url)) - { - settings.community_proxy_enabled = false; - settings.community_proxy_url.clear(); - } - if (settings.community_proxy_enabled && settings.community_proxy_url.empty()) - settings.community_proxy_enabled = false; + settings.community_proxy_enabled = false; + settings.community_proxy_url.clear(); return settings; } @@ -385,9 +378,7 @@ std::string FormatStreamSettings(const StreamSettings& settings) " | Controls: " + settings.controller_layout + " | Motion quality: " + settings.image_quality_mode + " | Stats overlay: " + - (settings.stats_overlay_enabled ? "On" : "Off") + - " | Community proxy: " + - (settings.community_proxy_enabled ? "On" : "Off"); + (settings.stats_overlay_enabled ? "On" : "Off"); } diff --git a/app/src/stream_settings.hpp b/app/src/stream_settings.hpp index ed35f34f..8034ee92 100644 --- a/app/src/stream_settings.hpp +++ b/app/src/stream_settings.hpp @@ -30,6 +30,8 @@ struct StreamSettings bool community_proxy_enabled = false; std::string community_proxy_url; int queue_notify_threshold = 10; + + bool operator==(const StreamSettings&) const = default; }; struct GameLanguageOption diff --git a/app/src/stream_view_overlay.cpp b/app/src/stream_view_overlay.cpp index 71e478c1..78d1d8cc 100644 --- a/app/src/stream_view_overlay.cpp +++ b/app/src/stream_view_overlay.cpp @@ -1,4 +1,5 @@ #include "StreamView.hpp" +#include "ui_theme.hpp" #include #include @@ -11,7 +12,7 @@ void StreamView::DrawDebugOverlay(NVGcontext* vg, float x, float y, float width) std::string debug_text = session_->get_debug_info(); nvgFontSize(vg, 24.0f); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgFillColor(vg, nvgRGB(255, 255, 255)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_TOP); nvgTextBox(vg, x + 50.0f, y + 50.0f, width - 100.0f, debug_text.c_str(), nullptr); @@ -76,10 +77,10 @@ void StreamView::DrawPreparingStream( nvgFillColor(vg, nvgRGBA(16, 18, 22, 248)); nvgFill(vg); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); nvgFontSize(vg, 14.0f); - nvgFillColor(vg, nvgRGB(77, 218, 130)); + nvgFillColor(vg, opennow::ui::Green()); nvgText(vg, panel_x + 42.0f, panel_y + 45.0f, "NOW LOADING", nullptr); nvgFontSize(vg, 28.0f); nvgFillColor(vg, nvgRGB(241, 245, 247)); @@ -99,7 +100,7 @@ void StreamView::DrawPreparingStream( nvgFill(vg); nvgBeginPath(vg); nvgRect(vg, first_x, rail_y - 2.0f, gap * std::min(stage, 2), 4.0f); - nvgFillColor(vg, nvgRGB(77, 218, 130)); + nvgFillColor(vg, opennow::ui::Green()); nvgFill(vg); const float pulse = 0.5f + 0.5f * static_cast(std::sin(seconds * 3.1)); @@ -113,12 +114,12 @@ void StreamView::DrawPreparingStream( { nvgBeginPath(vg); nvgCircle(vg, cx, rail_y, 29.0f + pulse * 3.0f); - nvgFillColor(vg, nvgRGBA(77, 218, 130, 22 + static_cast(pulse * 24.0f))); + nvgFillColor(vg, nvgRGBA(118, 232, 58, 22 + static_cast(pulse * 24.0f))); nvgFill(vg); } nvgBeginPath(vg); nvgCircle(vg, cx, rail_y, 22.0f); - nvgFillColor(vg, complete || active ? nvgRGB(77, 218, 130) : nvgRGB(24, 28, 33)); + nvgFillColor(vg, complete || active ? opennow::ui::Green() : nvgRGB(24, 28, 33)); nvgFill(vg); nvgStrokeWidth(vg, 2.0f); nvgStrokeColor(vg, complete || active ? nvgRGB(108, 235, 153) : nvgRGB(43, 48, 55)); @@ -145,13 +146,13 @@ void StreamView::DrawPreparingStream( nvgArc(vg, spinner_x, spinner_y, 19.0f, angle, angle + kPi * 1.42f, NVG_CW); nvgStrokeWidth(vg, 5.0f); nvgLineCap(vg, NVG_ROUND); - nvgStrokeColor(vg, nvgRGB(77, 218, 130)); + nvgStrokeColor(vg, opennow::ui::Green()); nvgStroke(vg); const float head_angle = angle + kPi * 1.42f; nvgBeginPath(vg); nvgCircle(vg, spinner_x + std::cos(head_angle) * 19.0f, spinner_y + std::sin(head_angle) * 19.0f, 3.4f); - nvgFillColor(vg, nvgRGB(123, 242, 166)); + nvgFillColor(vg, opennow::ui::Green()); nvgFill(vg); nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); @@ -219,11 +220,11 @@ void StreamView::DrawPerformanceOverlay(NVGcontext* vg, float x, float y) { nvgBeginPath(vg); nvgRoundedRect(vg, box_x + 6.0f, box_y + 7.0f, 4.0f, 32.0f, 2.0f); - nvgFillColor(vg, nvgRGB(55, 220, 125)); + nvgFillColor(vg, opennow::ui::Green()); nvgFill(vg); nvgFontSize(vg, 18.0f); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgFillColor(vg, nvgRGB(245, 250, 247)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); nvgText(vg, box_x + 20.0f, box_y + 23.0f, text, nullptr); @@ -269,11 +270,11 @@ void StreamView::DrawControllerNotice( nvgBeginPath(vg); nvgRoundedRect(vg, box_x, box_y, 6.0f, box_height, 3.0f); - nvgFillColor(vg, nvgRGB(55, 220, 125)); + nvgFillColor(vg, opennow::ui::Green()); nvgFill(vg); nvgFontSize(vg, 21.0f); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgFillColor(vg, nvgRGB(248, 252, 249)); nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); nvgText( @@ -324,7 +325,7 @@ void StreamView::DrawNetworkWarning( nvgFillColor(vg, nvgRGB(255, 184, 58)); nvgFill(vg); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); nvgFontSize(vg, 18.0f); nvgFillColor(vg, nvgRGB(252, 244, 224)); @@ -342,288 +343,55 @@ void StreamView::DrawStreamOverlay( { if (!stream_overlay_visible_) return; - - const VideoPerformanceCounters counters = previous_video_counters_; - const StreamTransportHealth health = - session_ ? session_->get_transport_health() : StreamTransportHealth {}; - const StreamNetworkCounters network = network_counters_; - const bool wifi_warning = - opennow::network::ShouldWarnForStreaming(network_info_.wifi_band); - - nvgBeginPath(vg); - nvgRect(vg, x, y, width, height); - nvgFillColor(vg, nvgRGBA(2, 5, 7, 190)); - nvgFill(vg); - - const float margin_x = std::clamp(width * 0.0265f, 22.0f, 40.0f); - const float margin_y = std::clamp(height * 0.033f, 18.0f, 30.0f); - const float panel_x = x + margin_x; - const float panel_y = y + margin_y; - const float panel_width = width - margin_x * 2.0f; - const float panel_height = height - margin_y * 2.0f; - nvgBeginPath(vg); - nvgRoundedRect(vg, panel_x, panel_y, panel_width, panel_height, 18.0f); - nvgFillColor(vg, nvgRGBA(12, 16, 19, 248)); - nvgFill(vg); - nvgStrokeWidth(vg, 1.5f); - nvgStrokeColor(vg, nvgRGBA(103, 118, 124, 82)); - nvgStroke(vg); - - const float padding = 30.0f; - const float header_bottom = panel_y + 105.0f; - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, nvgRGB(77, 218, 130)); - nvgText(vg, panel_x + padding, panel_y + 23.0f, "OPENNOW / STREAM STATUS", nullptr); - nvgFontSize(vg, 25.0f); - nvgFillColor(vg, nvgRGB(244, 248, 246)); - nvgText(vg, panel_x + padding, panel_y + 54.0f, - game_title_.empty() ? "GeForce NOW session" : game_title_.c_str(), nullptr); - nvgFontSize(vg, 14.0f); - nvgFillColor(vg, nvgRGB(142, 153, 160)); - const std::string provider = - auth_.provider.display_name.empty() ? "GeForce NOW" : auth_.provider.display_name; - nvgText(vg, panel_x + padding, panel_y + 80.0f, - (provider + " cloud session").c_str(), nullptr); - - const std::string connection_status = - health.peer_completed ? "CONNECTED" : "CONNECTING"; - const float status_width = health.peer_completed ? 112.0f : 122.0f; - const float status_x = panel_x + panel_width - padding - status_width; - nvgBeginPath(vg); - nvgRoundedRect(vg, status_x, panel_y + 24.0f, status_width, 30.0f, 15.0f); - nvgFillColor(vg, health.peer_completed - ? nvgRGBA(77, 218, 130, 25) - : nvgRGBA(255, 184, 58, 25)); - nvgFill(vg); - nvgStrokeWidth(vg, 1.0f); - nvgStrokeColor(vg, health.peer_completed - ? nvgRGBA(77, 218, 130, 95) - : nvgRGBA(255, 184, 58, 95)); - nvgStroke(vg); - nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, health.peer_completed - ? nvgRGB(114, 232, 157) - : nvgRGB(255, 198, 91)); - nvgText(vg, status_x + status_width * 0.5f, panel_y + 39.0f, - connection_status.c_str(), nullptr); - - nvgTextAlign(vg, NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(151, 161, 166)); - nvgText(vg, panel_x + panel_width - padding, panel_y + 79.0f, - "B Close menu", nullptr); - - nvgBeginPath(vg); - nvgRect(vg, panel_x + padding, header_bottom, panel_width - padding * 2.0f, 1.0f); - nvgFillColor(vg, nvgRGBA(151, 164, 170, 35)); - nvgFill(vg); - - const float content_top = header_bottom + 24.0f; - const float divider_x = panel_x + panel_width * 0.625f; - const float left_x = panel_x + padding; - const float left_width = divider_x - left_x - 26.0f; - const float right_x = divider_x + 28.0f; - const float right_edge = panel_x + panel_width - padding; - - nvgBeginPath(vg); - nvgRect(vg, divider_x, content_top, 1.0f, - panel_y + panel_height - padding - content_top); - nvgFillColor(vg, nvgRGBA(151, 164, 170, 35)); - nvgFill(vg); - - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(172, 183, 187)); - nvgText(vg, left_x, content_top, "LIVE PERFORMANCE", nullptr); - - const float metric_top = content_top + 20.0f; - const float metric_gap = 8.0f; - const float metric_width = (left_width - metric_gap * 3.0f) * 0.25f; - const float metric_height = 76.0f; - auto draw_metric = [vg, metric_top, metric_width, metric_height]( - float metric_x, const char* label, - const std::string& value, bool caution) { - nvgBeginPath(vg); - nvgRoundedRect(vg, metric_x, metric_top, metric_width, metric_height, 10.0f); - nvgFillColor(vg, nvgRGBA(24, 30, 33, 225)); - nvgFill(vg); - nvgStrokeWidth(vg, 1.0f); - nvgStrokeColor(vg, caution - ? nvgRGBA(255, 184, 58, 80) - : nvgRGBA(151, 164, 170, 28)); - nvgStroke(vg); - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, nvgRGB(132, 144, 150)); - nvgText(vg, metric_x + 13.0f, metric_top + 21.0f, label, nullptr); - nvgFontSize(vg, 23.0f); - nvgFillColor(vg, caution ? nvgRGB(255, 198, 91) : nvgRGB(240, 246, 242)); - nvgText(vg, metric_x + 13.0f, metric_top + 51.0f, value.c_str(), nullptr); - }; - - char number[96]; - std::snprintf(number, sizeof(number), "%.0f", presented_fps_); - draw_metric(left_x, "DISPLAY FPS", number, false); - std::snprintf(number, sizeof(number), "%.1f Mbps", stream_bitrate_mbps_); - draw_metric(left_x + (metric_width + metric_gap), "BITRATE", number, false); - const std::string ping = - network_rtt_ms_ >= 0 ? std::to_string(network_rtt_ms_) + " ms" : "-- ms"; - draw_metric(left_x + (metric_width + metric_gap) * 2.0f, "PING", ping, - network_rtt_ms_ >= 80); - - const std::uint64_t packet_total = - static_cast(network.packets_received) + network.sequence_gaps; - const double packet_loss = packet_total > 0 - ? static_cast(network.sequence_gaps) * 100.0 / - static_cast(packet_total) - : 0.0; - std::snprintf(number, sizeof(number), "%.2f%%", packet_loss); - draw_metric(left_x + (metric_width + metric_gap) * 3.0f, "PACKET LOSS", number, - packet_loss >= 1.0); - - const float details_title_y = metric_top + metric_height + 24.0f; - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(172, 183, 187)); - nvgText(vg, left_x, details_title_y, "STREAM DETAILS", nullptr); - - const float details_top = details_title_y + 20.0f; - const float detail_gap = 26.0f; - const float detail_width = (left_width - detail_gap) * 0.5f; - auto draw_detail = [vg, details_top, detail_width]( - float column_x, int row, const std::string& label, - const std::string& value, bool caution = false) { - const float row_y = details_top + row * 36.0f; - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(132, 144, 150)); - nvgText(vg, column_x, row_y + 13.0f, label.c_str(), nullptr); - nvgTextAlign(vg, NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE); - nvgFillColor(vg, caution ? nvgRGB(255, 198, 91) : nvgRGB(225, 232, 228)); - nvgText(vg, column_x + detail_width, row_y + 13.0f, value.c_str(), nullptr); - nvgBeginPath(vg); - nvgRect(vg, column_x, row_y + 30.0f, detail_width, 1.0f); - nvgFillColor(vg, nvgRGBA(151, 164, 170, 24)); - nvgFill(vg); - }; - - const std::string resolution = session_ - ? std::to_string(session_->stream_width()) + " × " + - std::to_string(session_->stream_height()) - : "--"; - draw_detail(left_x, 0, "Resolution", resolution); - std::snprintf(number, sizeof(number), "%.0f / %.0f / %.0f", - incoming_fps_, decoded_fps_, presented_fps_); - draw_detail(left_x, 1, "FPS in / decode / display", number); - draw_detail(left_x, 2, "Decode latency p95", - std::to_string(counters.decode_us_p95 / 1000) + " ms"); - draw_detail(left_x, 3, "Render latency p95", - std::to_string(counters.render_us_p95 / 1000) + " ms"); - draw_detail(left_x, 4, "Decoder queue", - std::to_string(counters.decode_queue_size) + " / " + - std::to_string(counters.decode_queue_high_water) + " high"); - draw_detail(left_x, 5, "Codec / location", - stream_codec_ + " / " + stream_region_); - - const float second_column_x = left_x + detail_width + detail_gap; - std::string network_connection = "Unknown"; - if (network_info_.type == opennow::NetworkConnectionType::Ethernet) - network_connection = "Ethernet"; - else if (network_info_.type == opennow::NetworkConnectionType::Wifi) - network_connection = opennow::network::WifiBandLabel(network_info_.wifi_band); - draw_detail(second_column_x, 0, "Network", network_connection, wifi_warning); - std::snprintf(number, sizeof(number), "%.2f%% · %u late", - packet_loss, network.late_packets_dropped); - draw_detail(second_column_x, 1, "Packet loss", number, packet_loss >= 1.0); - draw_detail(second_column_x, 2, "Dropped video frames", - std::to_string(network.access_units_dropped), - network.access_units_dropped > 0); - draw_detail(second_column_x, 3, "NACK recovery requests", - std::to_string(network.nack_requests)); - draw_detail(second_column_x, 4, "Connection", - health.peer_completed ? "Connected" : "Negotiating"); - const auto elapsed = std::chrono::duration_cast( - now - stream_started_at_).count(); - std::snprintf(number, sizeof(number), "%lld:%02lld", - static_cast(elapsed / 60), - static_cast(elapsed % 60)); - draw_detail(second_column_x, 5, "Session time", number); - - float right_y = content_top; - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(172, 183, 187)); - nvgText(vg, right_x, right_y, "CONTROLLER SHORTCUTS", nullptr); - right_y += 27.0f; - - if (wifi_warning) { - const float warning_height = 62.0f; - nvgBeginPath(vg); - nvgRoundedRect(vg, right_x, right_y, right_edge - right_x, - warning_height, 10.0f); - nvgFillColor(vg, nvgRGBA(255, 184, 58, 16)); - nvgFill(vg); - nvgStrokeWidth(vg, 1.0f); - nvgStrokeColor(vg, nvgRGBA(255, 184, 58, 75)); - nvgStroke(vg); - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 15.0f); - nvgFillColor(vg, nvgRGB(255, 205, 108)); - nvgText(vg, right_x + 14.0f, right_y + 20.0f, - "2.4 GHz Wi-Fi detected", nullptr); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, nvgRGB(190, 194, 196)); - nvgText(vg, right_x + 14.0f, right_y + 43.0f, - "Use 5 GHz or Ethernet for smoother streaming.", nullptr); - right_y += warning_height + 13.0f; + if (!stream_overlay_view_) + stream_overlay_view_ = std::make_unique(); + if (now >= stream_overlay_next_sample_) + { + const auto health = session_ ? session_->get_transport_health() : StreamTransportHealth {}; + const auto& counters = previous_video_counters_; + const auto& network = network_counters_; + opennow::StreamOverlaySample sample; + sample.game_title = game_title_; + sample.provider = auth_.provider.display_name; + sample.codec = stream_codec_; + sample.location = opennow::ui::ConfiguredLocation(stream_region_); + if (!network_info_.connected) + sample.network = "Disconnected"; + else if (network_info_.type == opennow::NetworkConnectionType::Ethernet) + sample.network = "Ethernet"; + else if (network_info_.type == opennow::NetworkConnectionType::Wifi) + sample.network = opennow::network::WifiBandLabel(network_info_.wifi_band); + sample.incoming_fps = incoming_fps_; + sample.decoded_fps = decoded_fps_; + sample.presented_fps = presented_fps_; + sample.bitrate_mbps = stream_bitrate_mbps_; + sample.rtt_ms = network_rtt_ms_; + sample.width = session_ ? session_->stream_width() : 0; + sample.height = session_ ? session_->stream_height() : 0; + sample.decode_us_p95 = counters.decode_us_p95; + sample.render_us_p95 = counters.render_us_p95; + sample.queue_size = counters.decode_queue_size; + sample.queue_high_water = counters.decode_queue_high_water; + sample.packets_received = network.packets_received; + sample.sequence_gaps = network.sequence_gaps; + sample.late_packets_dropped = network.late_packets_dropped; + sample.dropped_frames = network.access_units_dropped; + sample.nack_requests = network.nack_requests; + sample.peer_connected = health.peer_completed; + sample.signaling_connected = health.signaling_connected; + sample.wifi_warning = opennow::network::ShouldWarnForStreaming(network_info_.wifi_band); + sample.nte_session = is_nte_session_; + stream_overlay_view_->Update(sample); + stream_overlay_next_sample_ = now + std::chrono::milliseconds(750); } - - auto draw_shortcut = [vg, right_x]( - float& row_y, const char* keys, const char* action, - bool emphasized = false) { - const float key_width = 132.0f; - nvgBeginPath(vg); - nvgRoundedRect(vg, right_x, row_y, key_width, 30.0f, 7.0f); - nvgFillColor(vg, emphasized - ? nvgRGBA(77, 218, 130, 22) - : nvgRGBA(31, 38, 42, 230)); - nvgFill(vg); - nvgStrokeWidth(vg, 1.0f); - nvgStrokeColor(vg, emphasized - ? nvgRGBA(77, 218, 130, 85) - : nvgRGBA(140, 154, 161, 65)); - nvgStroke(vg); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, emphasized - ? nvgRGB(122, 235, 164) - : nvgRGB(231, 237, 233)); - nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); - nvgText(vg, right_x + key_width * 0.5f, row_y + 15.0f, keys, nullptr); - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 13.0f); - nvgFillColor(vg, nvgRGB(188, 198, 202)); - nvgText(vg, right_x + key_width + 15.0f, row_y + 15.0f, action, nullptr); - row_y += 42.0f; - }; - - draw_shortcut(right_y, "MINUS + PLUS", "Open or close this menu", true); - draw_shortcut(right_y, "MINUS + Y", "Open the on-screen keyboard"); - draw_shortcut(right_y, "KEYBOARD STRIP", "Tap Esc, Win or Windows shortcuts"); - draw_shortcut(right_y, "B", "Close menu or keyboard"); - draw_shortcut(right_y, "ZL + ZR + MINUS", "Exit the stream"); - draw_shortcut(right_y, "HOLD PLUS", "Press the Xbox Guide button"); - draw_shortcut(right_y, "TOUCH", "Move and click the remote pointer"); - if (is_nte_session_) - draw_shortcut(right_y, "L + X", "Start NTE auto-login"); - - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, nvgRGB(103, 116, 122)); - nvgText(vg, right_x, panel_y + panel_height - 26.0f, - "Menu controls stay on your Switch and are not sent to the game.", nullptr); + const auto elapsed = std::chrono::duration_cast(now - stream_started_at_).count(); + if (elapsed != stream_overlay_elapsed_seconds_) + { + stream_overlay_elapsed_seconds_ = elapsed; + stream_overlay_view_->UpdateElapsed(static_cast(std::max(0, elapsed))); + } + stream_overlay_view_->draw(vg, x, y, width, height, + brls::Application::getStyle(), nullptr); } void StreamView::DrawNteAutoLoginStatus( @@ -651,9 +419,9 @@ void StreamView::DrawNteAutoLoginStatus( nvgFill(vg); nvgBeginPath(vg); nvgRoundedRect(vg, box_x, box_y, 6.0f, box_height, 3.0f); - nvgFillColor(vg, nvgRGB(55, 220, 125)); + nvgFillColor(vg, opennow::ui::Green()); nvgFill(vg); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgFontSize(vg, 19.0f); nvgFillColor(vg, nvgRGB(246, 250, 248)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); @@ -700,6 +468,7 @@ void StreamView::BeginStreamEnd( return; stream_end_reason_ = reason; + RecordLifecycleEvent("end"); network_monitor_.request_stop(); stream_end_started_at_ = now; stream_auto_exit_at_ = now + std::chrono::seconds(15); @@ -751,7 +520,8 @@ void StreamView::UpdateStreamEndState(std::chrono::steady_clock::time_point now) signals.free_tier = free_tier_session_; signals.session_elapsed = std::chrono::duration_cast( now - stream_started_at_); - signals.internet_connected = internet_connected_; + signals.internet_connected = opennow::HasStreamNetworkConnection( + internet_connected_, health.video_started, health.video_idle); signals.peer_completed = health.peer_completed; signals.peer_terminal = health.peer_terminal; signals.signaling_connected = health.signaling_connected; @@ -814,7 +584,7 @@ void StreamView::DrawStreamEndNotice( nvgRoundedRect(vg, box_x, box_y, 7.0f, box_height, 3.5f); nvgFillColor(vg, nvgRGB(255, 91, 91)); nvgFill(vg); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); nvgFontSize(vg, 23.0f); nvgFillColor(vg, nvgRGB(252, 246, 246)); @@ -869,7 +639,7 @@ void StreamView::DrawSessionLimitNotice( nvgFill(vg); nvgFontSize(vg, prominent ? 22.0f : 21.0f); - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); + nvgFontFaceId(vg, opennow::ui::Font(opennow::ui::FontRole::Body)); nvgFillColor(vg, nvgRGB(250, 252, 250)); nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); nvgText(vg, box_x + box_width * 0.5f, box_y + box_height * 0.5f, text.c_str(), nullptr); diff --git a/app/src/top_bar_frame.cpp b/app/src/top_bar_frame.cpp index 5af8737b..41050801 100644 --- a/app/src/top_bar_frame.cpp +++ b/app/src/top_bar_frame.cpp @@ -8,10 +8,14 @@ #include "subscription_display.hpp" #include "ui_refresh_policy.hpp" #include "ui_helpers.hpp" +#include "ui_theme.hpp" #include #include #include +#include #include +#include +#include namespace opennow { @@ -100,7 +104,7 @@ class SubscriptionIconView final : public brls::View nvgStrokeWidth(vg, 2.0f * scale); nvgLineCap(vg, NVG_ROUND); nvgLineJoin(vg, NVG_ROUND); - nvgStrokeColor(vg, nvgRGB(77, 218, 130)); + nvgStrokeColor(vg, ui::Muted()); nvgStroke(vg); } @@ -116,10 +120,10 @@ brls::Box* MakeSubscriptionChip(SubscriptionIcon icon, brls::Label*& value_label chip->setAlignItems(brls::AlignItems::CENTER); chip->setPadding(5, 10, 5, 5); chip->setMarginRight(6); - chip->setCornerRadius(17); - chip->setBorderThickness(1.5f); - chip->setBorderColor(nvgRGB(38, 208, 103)); - chip->setBackgroundColor(nvgRGBA(8, 28, 17, 170)); + chip->setCornerRadius(10); + chip->setBorderThickness(1); + chip->setBorderColor(ui::Rule()); + chip->setBackgroundColor(ui::Ground()); chip->addView(new SubscriptionIconView(icon)); @@ -127,7 +131,7 @@ brls::Box* MakeSubscriptionChip(SubscriptionIcon icon, brls::Label*& value_label value_label->setFontSize(12); value_label->setSingleLine(true); value_label->setShrink(0.0f); - value_label->setTextColor(nvgRGB(234, 255, 241)); + value_label->setTextColor(ui::Muted()); chip->addView(value_label); return chip; } @@ -138,15 +142,19 @@ TopBarFrame::TopBarFrame() : brls::Box(brls::Axis::COLUMN) { setGrow(1.0f); - setBackgroundColor(nvgRGB(12, 13, 16)); + setBackgroundColor(ui::Ground()); // Header container header_container_ = new brls::Box(brls::Axis::ROW); + header_container_->setId("shell-header"); header_container_->setHeight(76); + header_container_->setShrink(0); header_container_->setAlignItems(brls::AlignItems::CENTER); header_container_->setJustifyContent(brls::JustifyContent::SPACE_BETWEEN); - header_container_->setPadding(0, 28, 0, 28); - header_container_->setBackgroundColor(nvgRGB(9, 10, 12)); + header_container_->setPadding(0, 40, 0, 40); + header_container_->setBackgroundColor(ui::Ground()); + header_container_->setLineBottom(1); + header_container_->setLineColor(ui::Rule()); addView(header_container_); @@ -161,17 +169,18 @@ TopBarFrame::TopBarFrame() brand_mark->setHeight(40); brand_mark->setMarginRight(10); brand_mark->setScalingType(brls::ImageScalingType::FIT); - brand_mark->setImageFromRes("img/opennow-logo-mark.png"); + brand_mark->setImageFromRes("img/opennow-logo-mark-small.png"); brand->addView(brand_mark); - auto* brand_name = new brls::Label(); + auto* brand_name = new ui::StyledLabel(ui::FontRole::Display); brand_name->setText("OpenNOW"); - brand_name->setFontSize(21); - brand_name->setTextColor(nvgRGB(248, 248, 248)); + brand_name->setFontSize(24); + brand_name->setTextColor(ui::Text()); brand->addView(brand_name); header_container_->addView(brand); tabs_container_ = new brls::Box(brls::Axis::ROW); + tabs_container_->setId("shell-tabs"); tabs_container_->setGrow(1.0f); tabs_container_->setShrink(0.0f); tabs_container_->setAlignItems(brls::AlignItems::CENTER); @@ -179,7 +188,7 @@ TopBarFrame::TopBarFrame() header_container_->addView(tabs_container_); auto* status_container = new brls::Box(brls::Axis::ROW); - status_container->setWidth(500); + status_container->setWidth(440); status_container->setHeight(54); status_container->setShrink(0.0f); status_container->setAlignItems(brls::AlignItems::CENTER); @@ -197,6 +206,7 @@ TopBarFrame::TopBarFrame() queue_chip_ = MakeSubscriptionChip( SubscriptionIcon::Queue, queue_position_label_); queue_chip_->setFocusable(true); + queue_chip_->addGestureRecognizer(new brls::TapGestureRecognizer(queue_chip_)); queue_chip_->registerClickAction([](brls::View* v){ if (IsQueueMinimized()) RestoreMinimizedQueueDialog(); return true; @@ -232,7 +242,7 @@ TopBarFrame::TopBarFrame() account_name_label_->setSingleLine(true); account_name_label_->setShrink(0.0f); account_name_label_->setHorizontalAlign(brls::HorizontalAlign::LEFT); - account_name_label_->setTextColor(nvgRGB(236, 236, 239)); + account_name_label_->setTextColor(ui::Text()); account_labels->addView(account_name_label_); account_detail_label_ = new brls::Label(); account_detail_label_->setText("No account"); @@ -240,21 +250,33 @@ TopBarFrame::TopBarFrame() account_detail_label_->setSingleLine(true); account_detail_label_->setShrink(0.0f); account_detail_label_->setHorizontalAlign(brls::HorizontalAlign::LEFT); - account_detail_label_->setTextColor(nvgRGB(112, 119, 130)); + account_detail_label_->setTextColor(ui::Muted()); account_labels->addView(account_detail_label_); account_container_->addView(account_labels); - // Divider - auto* divider = new brls::Rectangle(); - divider->setHeight(1); - divider->setColor(nvgRGBA(255, 255, 255, 18)); - addView(divider); - // Content container content_container_ = new brls::Box(brls::Axis::COLUMN); + content_container_->setId("shell-content"); content_container_->setGrow(1.0f); + content_container_->setMinHeight(0); addView(content_container_); + auto* footer = new brls::Box(brls::Axis::ROW); + footer->setId("shell-footer"); + footer->setHeight(60); + footer->setShrink(0); + footer->setPadding(0, 40, 0, 40); + footer->setAlignItems(brls::AlignItems::CENTER); + footer->setBackgroundColor(ui::Ground()); + footer->setLineTop(1); + footer->setLineColor(ui::Rule()); + auto* hints = new brls::Hints(); + hints->setId("shell-hints"); + hints->setGrow(1); + hints->setAllowAButtonTouch(true); + footer->addView(hints); + addView(footer); + registerAction("Previous Tab", brls::BUTTON_LB, [this](brls::View* view) { if (tabs_.empty()) return false; int prev = active_tab_index_ - 1; @@ -292,6 +314,33 @@ void TopBarFrame::draw(NVGcontext* vg, float x, float y, float width, float heig } } brls::Box::draw(vg, x, y, width, height, style, ctx); + if (!tabs_.empty()) + { + const auto bounds = tabs_container_->getFrame(); + const auto first = tabs_.front().tab_box->getFrame(); + const auto last = tabs_.back().tab_box->getFrame(); + if (first.getMinX() - bounds.getMinX() >= 32 && bounds.getMaxX() - last.getMaxX() >= 32) + { + nvgSave(vg); + nvgFontFaceId(vg, ui::Font(ui::FontRole::Medium)); + nvgFontSize(vg, 16); + nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); + const float center_y = header_container_->getFrame().getMidY(); + const std::array, 2> keys {{ + {first.getMinX() - 32, "L"}, {last.getMaxX() + 8, "R"}}}; + for (const auto& [key_x, text] : keys) + { + nvgBeginPath(vg); + nvgRoundedRect(vg, key_x, center_y - 13, 24, 26, 6); + nvgStrokeWidth(vg, 1); + nvgStrokeColor(vg, ui::Muted()); + nvgStroke(vg); + nvgFillColor(vg, ui::Text()); + nvgText(vg, key_x + 12, center_y, text, nullptr); + } + nvgRestore(vg); + } + } } TopBarFrame::~TopBarFrame() @@ -324,12 +373,13 @@ void TopBarFrame::addTab(const std::string& label, TabViewCreator creator) tab_box->setCornerRadius(6); tab_box->setBackgroundColor(nvgRGBA(0, 0, 0, 0)); tab_box->setFocusable(true); + tab_box->addGestureRecognizer(new brls::TapGestureRecognizer(tab_box)); - auto* text = new brls::Label(); + auto* text = new ui::StyledLabel(ui::FontRole::Medium); text->setText(Tr(label)); text->setSingleLine(true); text->setFontSize(18); - text->setTextColor(nvgRGB(128, 133, 143)); + text->setTextColor(ui::Muted()); auto* label_container = new brls::Box(brls::Axis::ROW); label_container->setHeight(57); @@ -339,13 +389,13 @@ void TopBarFrame::addTab(const std::string& label, TabViewCreator creator) tab_box->addView(label_container); auto* underline_container = new brls::Box(brls::Axis::ROW); - underline_container->setWidth(66); + underline_container->setWidth(28); underline_container->setHeight(5); underline_container->setAlignItems(brls::AlignItems::FLEX_END); auto* underline = new brls::Rectangle(); - underline->setWidth(66); - underline->setHeight(5); + underline->setWidth(28); + underline->setHeight(3); underline->setColor(nvgRGBA(0, 0, 0, 0)); underline_container->addView(underline); @@ -398,11 +448,11 @@ void TopBarFrame::SelectTab(int index) for (size_t i = 0; i < tabs_.size(); ++i) { if ((int)i == index) { - tabs_[i].header_label->setTextColor(nvgRGB(255, 255, 255)); - tabs_[i].underline->setColor(nvgRGB(77, 218, 130)); - tabs_[i].tab_box->setBackgroundColor(nvgRGBA(77, 218, 130, 22)); + tabs_[i].header_label->setTextColor(ui::Text()); + tabs_[i].underline->setColor(ui::Green()); + tabs_[i].tab_box->setBackgroundColor(nvgRGBA(0, 0, 0, 0)); } else { - tabs_[i].header_label->setTextColor(nvgRGB(128, 133, 143)); + tabs_[i].header_label->setTextColor(ui::Muted()); tabs_[i].underline->setColor(nvgRGBA(0, 0, 0, 0)); tabs_[i].tab_box->setBackgroundColor(nvgRGBA(0, 0, 0, 0)); } @@ -429,6 +479,12 @@ void TopBarFrame::SelectTab(int index) void TopBarFrame::UpdateStatusBar(bool force) { + if (displayed_language_ != GetInterfaceLanguage()) + { + displayed_language_ = GetInterfaceLanguage(); + for (auto& tab : tabs_) + tab.header_label->setText(Tr(tab.label)); + } if (!account_name_label_ || !account_detail_label_) return; diff --git a/app/src/top_bar_frame.hpp b/app/src/top_bar_frame.hpp index 81890e9c..0169c33d 100644 --- a/app/src/top_bar_frame.hpp +++ b/app/src/top_bar_frame.hpp @@ -44,6 +44,7 @@ class TopBarFrame : public brls::Box brls::Label* account_detail_label_; std::string displayed_avatar_url_; std::string displayed_status_; + std::string displayed_language_; std::chrono::steady_clock::time_point last_status_update_ {}; struct TabInfo { diff --git a/app/src/ui_helpers.cpp b/app/src/ui_helpers.cpp index a5b58394..2cc45e5f 100644 --- a/app/src/ui_helpers.cpp +++ b/app/src/ui_helpers.cpp @@ -1,95 +1,141 @@ #include "ui_helpers.hpp" #include "app_state.hpp" -#include "cover_image_cache.hpp" +#include "queue_view.hpp" +#include "cloud_launch_internal.hpp" #include "cloud_launch_state.hpp" #include "play_history.hpp" #include "session_error_policy.hpp" #include "localization.hpp" #include "network_utils.hpp" #include +#ifdef __SWITCH__ #include -#include +#endif #include -#include #include #include +#include #include -#include "StreamView.hpp" namespace opennow { namespace { -class LaunchAnimationView; +class QueueActivity; struct QueueSessionState { CloudLaunchState launch; GfnClient client; AuthSession auth; + StreamSettings settings; std::string game_title; std::string title = "Checking your NVIDIA account"; std::string detail = "Validating the saved session before requesting a cloud rig."; int stage = 0; int position = -1; - float progress = 0.08f; bool minimized = false; bool notified = false; - brls::Dialog* dialog = nullptr; - LaunchAnimationView* animation = nullptr; - brls::Label* stage_label = nullptr; - brls::Label* detail_label = nullptr; + QueueActivity* activity = nullptr; + QueueView* view = nullptr; }; std::shared_ptr active_queue; void CancelQueue(const std::shared_ptr& state); -class QueueDialog final : public brls::Dialog +class QueueActivity final : public brls::Activity { public: - QueueDialog(brls::Box* content, std::shared_ptr state) - : brls::Dialog(content), state_(std::move(state)) + explicit QueueActivity(std::shared_ptr state) + : state_(std::move(state)) { - state_->dialog = this; + auto* view = new QueueView(Display(), state_->settings, + [this] { Dismiss(); }, + [this] { CancelQueue(state_); Dismiss(); }); + state_->view = view; + state_->activity = this; + state_->minimized = false; } - ~QueueDialog() override + brls::View* createContentView() override { return state_->view; } + + ~QueueActivity() override { + StopDismissWatch(); Detach(); } - void dismiss(std::function callback = [] {}) override + void willDisappear(bool reset_state = false) override { - Detach(); - brls::Dialog::dismiss(std::move(callback)); + if (reset_state) + Detach(); + brls::Activity::willDisappear(reset_state); } - void AddCancelButton(const std::string& label) + void onResume() override { - addButton(label, [] {}); - button2->registerClickAction([this](brls::View*) { - CancelQueue(state_); - dismiss(); - return true; + if (!dismiss_pending_ || dismiss_watch_) + return; + dismiss_watch_ = brls::Application::getRunLoopEvent()->subscribe([this] { + const auto stack = brls::Application::getActivitiesStack(); + if (stack.empty() || stack.back() != this || !dismiss_pending_) + return; + dismiss_pending_ = false; + brls::sync([state = state_] { + if (state->activity) + state->activity->Dismiss(); + }); }); } + void onPause() override { StopDismissWatch(); } + + void Dismiss(std::function callback = [] {}) + { + StopDismissWatch(); + const auto stack = brls::Application::getActivitiesStack(); + if (!stack.empty() && stack.back() != this) + { + dismiss_pending_ = true; + state_->view = nullptr; + callback(); + return; + } + dismiss_pending_ = false; + Detach(); + brls::Application::popActivity(brls::TransitionAnimation::NONE, std::move(callback)); + } + + QueueDisplayState Display() const + { + return {state_->game_title, state_->title, state_->detail, + state_->position, state_->stage, !state_->launch.running()}; + } + private: + void StopDismissWatch() + { + if (!dismiss_watch_) + return; + brls::Application::getRunLoopEvent()->unsubscribe(*dismiss_watch_); + dismiss_watch_.reset(); + } + void Detach() { - if (state_->dialog != this) + if (state_->activity != this) return; - state_->dialog = nullptr; - state_->animation = nullptr; - state_->stage_label = nullptr; - state_->detail_label = nullptr; + state_->activity = nullptr; + state_->view = nullptr; state_->minimized = state_->launch.running(); } std::shared_ptr state_; + bool dismiss_pending_ = false; + std::optional dismiss_watch_; }; void FinishQueue(const std::shared_ptr& state) @@ -114,136 +160,11 @@ void CancelQueue(const std::shared_ptr& state) } } -class LaunchAnimationView final : public brls::View +void UpdateQueue(const std::shared_ptr& state) { - public: - LaunchAnimationView() - { - setWidth(620); - setHeight(168); - } - - void SetState(int source_stage, float progress) - { - visual_stage_ = source_stage <= 1 ? 0 : (source_stage == 2 ? 1 : 2); - progress_ = std::clamp(progress, 0.04f, 0.98f); - invalidate(); - } - - void draw(NVGcontext* vg, float x, float y, float width, float height, - brls::Style style, brls::FrameContext* ctx) override - { - (void)style; - (void)ctx; - constexpr float kPi = 3.14159265358979323846f; - const double seconds = std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()).count(); - const float pulse = 0.5f + 0.5f * static_cast(std::sin(seconds * 3.2)); - static constexpr const char* kLabels[] = {"Queue", "Setup", "Ready"}; - const float rail_y = y + 42.0f; - const float first_x = x + 76.0f; - const float gap = (width - 152.0f) * 0.5f; - - nvgBeginPath(vg); - nvgRect(vg, first_x, rail_y - 2.0f, gap * 2.0f, 4.0f); - nvgFillColor(vg, nvgRGB(38, 42, 48)); - nvgFill(vg); - - float completed_width = visual_stage_ * gap; - if (visual_stage_ < 2) - completed_width += gap * std::clamp((progress_ - visual_stage_ * 0.32f) / 0.64f, 0.0f, 0.90f); - else - completed_width = gap * 2.0f; - nvgBeginPath(vg); - nvgRect(vg, first_x, rail_y - 2.0f, completed_width, 4.0f); - nvgFillColor(vg, nvgRGB(77, 218, 130)); - nvgFill(vg); - - nvgFontFaceId(vg, brls::Application::getFont(brls::FONT_REGULAR)); - nvgTextAlign(vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); - for (int i = 0; i < 3; ++i) - { - const float cx = first_x + gap * i; - const bool complete = i < visual_stage_; - const bool active = i == visual_stage_; - if (active) - { - nvgBeginPath(vg); - nvgCircle(vg, cx, rail_y, 29.0f + pulse * 3.0f); - nvgFillColor(vg, nvgRGBA(77, 218, 130, 25 + static_cast(pulse * 22.0f))); - nvgFill(vg); - } - nvgBeginPath(vg); - nvgCircle(vg, cx, rail_y, 22.0f); - nvgFillColor(vg, complete || active ? nvgRGB(77, 218, 130) : nvgRGB(24, 28, 33)); - nvgFill(vg); - nvgStrokeWidth(vg, 2.0f); - nvgStrokeColor(vg, complete || active ? nvgRGB(123, 242, 166) : nvgRGB(43, 48, 55)); - nvgStroke(vg); - - nvgFontSize(vg, 17.0f); - nvgFillColor(vg, complete || active ? nvgRGB(8, 35, 22) : nvgRGB(95, 101, 111)); - const std::string number = std::to_string(i + 1); - nvgText(vg, cx, rail_y, number.c_str(), nullptr); - nvgFontSize(vg, 12.0f); - nvgFillColor(vg, complete || active ? nvgRGB(228, 235, 232) : nvgRGB(91, 96, 105)); - nvgText(vg, cx, rail_y + 42.0f, kLabels[i], nullptr); - } - - const float spinner_x = x + width * 0.5f; - const float spinner_y = y + 137.0f; - const float angle = static_cast( - std::fmod(seconds * 3.0, static_cast(kPi) * 2.0)); - nvgBeginPath(vg); - nvgCircle(vg, spinner_x, spinner_y, 18.0f); - nvgStrokeWidth(vg, 5.0f); - nvgStrokeColor(vg, nvgRGB(35, 43, 48)); - nvgStroke(vg); - nvgBeginPath(vg); - nvgArc(vg, spinner_x, spinner_y, 18.0f, angle, - angle + kPi * 1.42f, NVG_CW); - nvgStrokeWidth(vg, 5.0f); - nvgLineCap(vg, NVG_ROUND); - nvgStrokeColor(vg, nvgRGB(77, 218, 130)); - nvgStroke(vg); - const float head_angle = angle + kPi * 1.42f; - nvgBeginPath(vg); - nvgCircle(vg, spinner_x + std::cos(head_angle) * 18.0f, - spinner_y + std::sin(head_angle) * 18.0f, 3.2f); - nvgFillColor(vg, nvgRGB(118, 234, 241)); - nvgFill(vg); - } - - private: - float progress_ = 0.08f; - int visual_stage_ = 0; -}; - -void SetLaunchProgress( - LaunchAnimationView* animation, - brls::Label* stage_label, - brls::Label* detail_label, - int stage, - const std::string& title, - const std::string& detail, - float progress) -{ - if (animation) - animation->SetState(stage, progress); - if (stage_label) - { - stage_label->setText(Tr(title)); - const bool queue_position = title.rfind("Position in queue:", 0) == 0; - stage_label->setFontSize(queue_position ? 36 : 27); - stage_label->setTextColor(queue_position - ? nvgRGB(77, 218, 130) - : nvgRGB(238, 242, 245)); - } - if (detail_label) - { - detail_label->setText(Tr(detail)); - detail_label->setFontSize(title.rfind("Position in queue:", 0) == 0 ? 18 : 16); - } + if (state->view) + state->view->Update({state->game_title, state->title, state->detail, + state->position, state->stage, !state->launch.running()}); } std::string SessionStatusText(const SessionInfo& info) @@ -288,33 +209,9 @@ bool IsQueueMinimized() void RestoreMinimizedQueueDialog() { auto state = active_queue; - if (!state || !state->launch.running() || !state->minimized || state->dialog) + if (!state || !state->launch.running() || !state->minimized || state->activity) return; - - auto* box = new brls::Box(brls::Axis::COLUMN); - box->setWidth(520); - box->setPadding(24, 24, 24, 24); - box->setBackgroundColor(nvgRGB(24, 28, 33)); - auto* game = new brls::Label(); - game->setText(state->game_title); - game->setFontSize(20); - game->setTextColor(nvgRGB(245, 248, 250)); - game->setMarginBottom(10); - box->addView(game); - state->stage_label = new brls::Label(); - state->stage_label->setMarginBottom(8); - box->addView(state->stage_label); - state->detail_label = new brls::Label(); - state->detail_label->setTextColor(nvgRGB(151, 159, 170)); - state->detail_label->setMarginBottom(12); - box->addView(state->detail_label); - SetLaunchProgress(nullptr, state->stage_label, state->detail_label, - state->stage, state->title, state->detail, state->progress); - auto* dialog = new QueueDialog(box, state); - dialog->addButton(Tr("Stay in queue"), [] {}); - dialog->AddCancelButton(Tr("Cancel queue")); - dialog->setCancelable(true); - dialog->open(); + brls::Application::pushActivity(new QueueActivity(state)); } void ShowDialog(const std::string& title, const std::string& body) @@ -349,72 +246,7 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, return; } - auto* box = new brls::Box(brls::Axis::COLUMN); - box->setWidth(720); - box->setPadding(24, 36, 18, 36); - box->setAlignItems(brls::AlignItems::CENTER); - box->setBackgroundColor(nvgRGBA(0, 0, 0, 0)); - - auto* game_header = new brls::Box(brls::Axis::ROW); - game_header->setWidth(620); - game_header->setHeight(96); - game_header->setAlignItems(brls::AlignItems::CENTER); - game_header->setMarginBottom(6); - - auto* cover = new CachedImage(); - cover->setWidth(82); - cover->setHeight(82); - cover->setCornerRadius(12); - cover->setScalingType(brls::ImageScalingType::FILL); - cover->setMarginRight(20); - SetCachedCoverImage(cover, image_url); - game_header->addView(cover); - - auto* game_copy = new brls::Box(brls::Axis::COLUMN); - game_copy->setGrow(1.0f); - auto* eyebrow = new brls::Label(); - eyebrow->setText(Tr("Now loading")); - eyebrow->setFontSize(13); - eyebrow->setTextColor(nvgRGB(77, 218, 130)); - eyebrow->setMarginBottom(5); - game_copy->addView(eyebrow); - auto* heading = new brls::Label(); - heading->setText(title); - heading->setFontSize(27); - heading->setTextColor(nvgRGB(245, 248, 250)); - heading->setMarginBottom(5); - game_copy->addView(heading); - auto* store_label = new brls::Label(); - store_label->setText(launch_store.empty() ? "GeForce NOW" : launch_store); - store_label->setFontSize(14); - store_label->setTextColor(nvgRGB(145, 151, 162)); - game_copy->addView(store_label); - game_header->addView(game_copy); - box->addView(game_header); - - auto* animation = new LaunchAnimationView(); - animation->setMarginBottom(0); - box->addView(animation); - - auto* stage_label = new brls::Label(); - stage_label->setText(Tr("Checking your NVIDIA account")); - stage_label->setFontSize(27); - stage_label->setTextColor(nvgRGB(238, 242, 245)); - stage_label->setHorizontalAlign(brls::HorizontalAlign::CENTER); - stage_label->setMarginBottom(6); - box->addView(stage_label); - - auto* detail_label = new brls::Label(); - detail_label->setText(Tr("Validating the saved session before requesting a cloud rig.")); - detail_label->setFontSize(16); - detail_label->setTextColor(nvgRGB(151, 159, 170)); - detail_label->setHorizontalAlign(brls::HorizontalAlign::CENTER); - detail_label->setMarginBottom(4); - box->addView(detail_label); - - SetLaunchProgress(animation, stage_label, detail_label, 0, - "Checking your NVIDIA account", - "Validating the saved session before requesting a cloud rig.", 0.08f); + (void)image_url; GfnClient bg_client = client; AuthSession bg_auth = auth; @@ -426,21 +258,11 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, auto launch_state = std::make_shared(); launch_state->client = client; launch_state->auth = auth; + launch_state->settings = LoadStreamSettings(); launch_state->game_title = title; - launch_state->animation = animation; - launch_state->stage_label = stage_label; - launch_state->detail_label = detail_label; - auto* dialog = new QueueDialog(box, launch_state); - dialog->setCancelable(false); active_queue = launch_state; const auto account_generation = AppState::Instance().session_generation(); - - dialog->addButton(Tr("Minimize - browse"), [launch_state]() { - if (launch_state->launch.running()) - brls::Application::notify(Tr("Queue minimized. Tap the Queue chip to return.")); - }); - dialog->AddCancelButton(Tr("Cancel session")); - dialog->open(); + brls::Application::pushActivity(new QueueActivity(launch_state)); #ifdef __SWITCH__ brls::Application::getPlatform()->disableScreenDimming(true); #endif @@ -448,26 +270,24 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, brls::async([bg_client, bg_auth, bg_app_id, bg_store, bg_internal_title, bg_history_game_id, launch_state, account_generation]() mutable { auto post_progress = [launch_state, account_generation]( - int stage, std::string title, std::string detail, float progress, int position = -1) { + int stage, std::string title, std::string detail, int position = -1) { brls::sync([launch_state, account_generation, stage, - title = std::move(title), detail = std::move(detail), progress, position]() { + title = std::move(title), detail = std::move(detail), position]() { if (!launch_state->launch.running()) return; if (!AppState::Instance().IsCurrentSession(account_generation)) { - if (launch_state->dialog) - launch_state->dialog->dismiss(); + if (launch_state->activity) + launch_state->activity->Dismiss(); CancelQueue(launch_state); return; } launch_state->stage = stage; launch_state->title = title; launch_state->detail = detail; - launch_state->progress = progress; launch_state->position = position; - SetLaunchProgress(launch_state->animation, launch_state->stage_label, launch_state->detail_label, - stage, title, detail, progress); + UpdateQueue(launch_state); if (launch_state->minimized && !launch_state->notified && position > 0 && - position <= LoadStreamSettings().queue_notify_threshold) { + position <= launch_state->settings.queue_notify_threshold) { launch_state->notified = true; brls::Application::notify(Tr("Queue almost ready. Tap the Queue chip to return.")); } @@ -477,14 +297,14 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, if (!launch_state->launch.running()) return; post_progress(0, "Checking your NVIDIA account", - "Renewing authorization and checking previous sessions.", 0.10f); + "Renewing authorization and checking previous sessions."); bg_client.CleanupStaleCloudSession(bg_auth); if (!launch_state->launch.running()) return; post_progress(1, "Requesting a cloud rig", - "GeForce NOW is allocating hardware for your game.", 0.24f); + "GeForce NOW is allocating hardware for your game."); SessionInfo info = bg_client.StartSession( - bg_auth, bg_app_id, bg_store, bg_internal_title); + bg_auth, bg_app_id, launch_state->settings, bg_store, bg_internal_title); if (!launch_state->launch.Adopt(info.session_id)) { try { bg_client.StopSession(bg_auth, info.session_id); } catch (...) {} return; @@ -501,15 +321,24 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, throw std::runtime_error("NVIDIA returned an unknown session status for too long."); } - const float queue_progress = info.status == 0 ? 0.34f : 0.48f; std::string queue_title; std::string queue_detail; - if (info.queue_position > 0) + if (info.app_patching) + { + queue_title = "Updating the game"; + queue_detail = SessionStatusText(info); + } + else if (info.status == 6) { - queue_title = "Position in queue: " + std::to_string(info.queue_position); - queue_detail = info.status == 0 + queue_title = "Waiting for NVIDIA confirmation"; + queue_detail = SessionStatusText(info); + } + else if (info.queue_position > 0) + { + queue_title = info.status == 0 ? "Waiting for an available cloud rig" : "Preparing your cloud rig"; + queue_detail = "Your game starts automatically when the rig is ready."; } else if (info.status == 0) { @@ -518,10 +347,10 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, } else { - queue_title = info.app_patching ? "Updating the game" : "Preparing your cloud rig"; + queue_title = info.status < 0 ? "Waiting for cloud session status" : "Preparing your cloud rig"; queue_detail = SessionStatusText(info); } - post_progress(1, queue_title, queue_detail, queue_progress, info.queue_position); + post_progress(info.status == 0 && !info.app_patching ? 1 : 2, queue_title, queue_detail, info.queue_position > 0 ? info.queue_position : -1); std::this_thread::sleep_for(std::chrono::seconds(5)); if (!launch_state->launch.running()) return; @@ -533,20 +362,20 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, } post_progress(2, "Connecting to the streaming server", - "The rig is ready. Configuring the secure network path.", 0.72f); + "The rig is ready. Configuring the secure network path."); brls::sync([=]() { if (!launch_state->launch.running()) return; if (!AppState::Instance().IsCurrentSession(account_generation)) { - if (launch_state->dialog) - launch_state->dialog->dismiss(); + if (launch_state->activity) + launch_state->activity->Dismiss(); CancelQueue(launch_state); return; } try { - SetLaunchProgress(launch_state->animation, launch_state->stage_label, - launch_state->detail_label, 3, - "Starting the video stream", - "Negotiating WebRTC and waiting for the first clean frame.", 0.86f); + launch_state->stage = 3; + launch_state->title = "Starting the video stream"; + launch_state->detail = "Negotiating WebRTC and waiting for the first clean frame."; + UpdateQueue(launch_state); auto& app_state = AppState::Instance(); if (app_state.IsCurrentSession(account_generation)) @@ -560,21 +389,26 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, brls::Logger::info("Media endpoint: {}:{}", info.media_ip, info.media_port); brls::Logger::info("ICE servers: {}", info.ice_servers.size()); - std::string jwt_token = bg_auth.tokens.id_token.empty() ? bg_auth.tokens.access_token : bg_auth.tokens.id_token; - if (launch_state->dialog) - launch_state->dialog->dismiss(); - FinishQueue(launch_state); - brls::Application::pushActivity(new brls::Activity(StreamView::create( - info.signaling_url, - jwt_token, - info.session_id, - info.media_ip, - info.media_port, - info.ice_servers, - bg_client, - bg_auth, - bg_internal_title))); - launch_state->launch.TransferToStream(); + auto handoff = [=] { + if (!launch_state->launch.running()) + return; + if (!AppState::Instance().IsCurrentSession(account_generation)) { + CancelQueue(launch_state); + return; + } + try { + FinishQueue(launch_state); + PresentCloudStream(info, bg_client, bg_auth, bg_internal_title, launch_state->settings); + launch_state->launch.TransferToStream(); + } catch (const std::exception& e) { + CancelQueue(launch_state); + ShowError("Stream startup failed", e.what()); + } + }; + if (launch_state->activity) + launch_state->activity->Dismiss(handoff); + else + handoff(); } catch (const std::exception& e) { CancelQueue(launch_state); ShowError("Stream startup failed", e.what()); @@ -590,19 +424,12 @@ void BeginLaunchSessionDialog(const GfnClient& client, const AuthSession& auth, brls::sync([=]() { if (!launch_state->launch.running()) return; CancelQueue(launch_state); - if (launch_state->animation) - launch_state->animation->SetState(0, 0.04f); - if (launch_state->stage_label) { - launch_state->stage_label->setText(Tr(error.title)); - launch_state->stage_label->setFontSize(24); - launch_state->stage_label->setTextColor(nvgRGB(255, 125, 132)); - launch_state->detail_label->setFontSize(17); - launch_state->detail_label->setText( - Tr(error.body) + "\n\n" + Tr("Press B to return to the game page.")); - launch_state->dialog->setCancelable(true); - } else { + launch_state->title = error.title; + launch_state->detail = error.body + "\n\n" + Tr("Press B to return to the game page."); + if (launch_state->view) + UpdateQueue(launch_state); + else brls::Application::notify(Tr(error.title) + ": " + Tr(error.body)); - } }); } }); diff --git a/app/src/ui_theme.cpp b/app/src/ui_theme.cpp new file mode 100644 index 00000000..19abbc88 --- /dev/null +++ b/app/src/ui_theme.cpp @@ -0,0 +1,256 @@ +#include "ui_theme.hpp" + +#include "localization.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace opennow::ui +{ +namespace +{ + +constexpr std::array kFontNames { + "regular", "opennow-semibold", "opennow-bold", "opennow-extrabold", "opennow-mono"}; + +} + +void InitializeThemeAndFonts() +{ + const int original = brls::Application::getFont(brls::FONT_REGULAR); + const std::array paths { + "font/Nunito-Medium.ttf", "font/Nunito-SemiBold.ttf", "font/Nunito-Bold.ttf", + "font/Nunito-ExtraBold.ttf", "font/IBMPlexMono-Medium.ttf"}; + for (size_t index = 0; index < paths.size(); ++index) + { + if (!brls::Application::loadFontFromFile(kFontNames[index], std::string(BRLS_ASSET("")) + paths[index])) + throw std::runtime_error("Cannot load bundled OpenNOW font: " + std::string(paths[index])); + } + if (!brls::Application::loadFontFromFile("opennow-cjk", BRLS_ASSET("font/OpenNOW-CJK.ttf"))) + throw std::runtime_error("Cannot load bundled OpenNOW Chinese fallback font"); + + std::vector fallbacks {brls::Application::getFont("opennow-cjk")}; + for (const auto& name : {brls::FONT_CHINESE_SIMPLIFIED, brls::FONT_CHINESE_SIMPLIFIED_EXT, + brls::FONT_CHINESE_TRADITIONAL, brls::FONT_KOREAN_REGULAR, + brls::FONT_SWITCH_ICONS, brls::FONT_MATERIAL_ICONS, brls::FONT_EMOJI}) + fallbacks.push_back(brls::Application::getFont(name)); + fallbacks.push_back(original); + + for (const char* name : kFontNames) + { + const int base = brls::Application::getFont(name); + for (int fallback : fallbacks) + if (fallback != brls::FONT_INVALID && fallback != base) + nvgAddFallbackFontId(brls::Application::getNVGContext(), base, fallback); + } + + for (brls::Theme* theme : {&brls::Theme::getDarkTheme(), &brls::Theme::getLightTheme()}) + { + theme->addColor("brls/background", Ground()); + theme->addColor("brls/text", Text()); + theme->addColor("brls/text_disabled", Muted()); + theme->addColor("brls/accent", Green()); + theme->addColor("brls/highlight/color1", Green()); + theme->addColor("brls/highlight/color2", Green()); + theme->addColor("brls/highlight/background", Raised()); + theme->addColor("brls/click_pulse", nvgRGBA(118, 232, 58, 36)); + theme->addColor("brls/button/primary_enabled_background", Green()); + theme->addColor("brls/button/primary_enabled_text", Ground()); + theme->addColor("brls/button/default_enabled_background", Raised()); + theme->addColor("brls/button/default_enabled_text", Text()); + theme->addColor("brls/button/highlight_enabled_text", Green()); + theme->addColor("brls/button/enabled_border_color", Rule()); + theme->addColor("brls/list/listItem_value_color", Green()); + theme->addColor("brls/sidebar/background", Ground()); + theme->addColor("brls/sidebar/active_item", Green()); + theme->addColor("brls/header/subtitle", Muted()); + } + brls::Application::getPlatform()->setThemeVariant(brls::ThemeVariant::DARK); + brls::getStyle().addMetric("brls/highlight/stroke_width", 2.0f); +} + +int Font(FontRole role) +{ + const int handle = brls::Application::getFont(kFontNames[static_cast(role)]); + return handle == brls::FONT_INVALID ? brls::Application::getDefaultFont() : handle; +} + +StyledLabel::StyledLabel(FontRole role) +{ + font = Font(role); + setAutoAnimate(false); +} + +brls::Label* MakeLabel(const std::string& text, float size, NVGcolor color, FontRole role) +{ + auto* label = new StyledLabel(role); + label->setText(text); + label->setFontSize(size); + label->setTextColor(color); + label->setLineHeight(1.35f); + return label; +} + +float TextWidth(const std::string& text, float size, FontRole role) +{ + auto* vg = brls::Application::getNVGContext(); + nvgSave(vg); + nvgFontFaceId(vg, Font(role)); + nvgFontSize(vg, size); + nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_TOP); + float bounds[4] {}; + const float advance = nvgTextBounds(vg, 0, 0, text.c_str(), nullptr, bounds); + nvgRestore(vg); + return std::ceil(std::max(advance, bounds[2]) - std::min(0.0f, bounds[0])); +} + +void FadingScrollFrame::draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style style, brls::FrameContext* ctx) +{ + brls::ScrollingFrame::draw(vg, x, y, width, height, style, ctx); + if (getContentHeight() - getContentOffsetY() <= height + 0.5f) + return; + nvgSave(vg); + nvgBeginPath(vg); + nvgRect(vg, x, y + height - 40, width, 40); + const auto ground = Ground(); + auto transparent = ground; + transparent.a = 0; + nvgFillPaint(vg, nvgLinearGradient(vg, x, y + height - 40, x, y + height, + transparent, ground)); + nvgFill(vg); + nvgRestore(vg); +} + +ActionRow::ActionRow(std::string title, std::string value, + std::function activate, ActionTone tone) + : brls::Box(brls::Axis::ROW), tone_(tone) +{ + setHeight(56); + setShrink(0); + setPadding(0, 18, 0, 18); + setAlignItems(brls::AlignItems::CENTER); + setCornerRadius(12); + setBorderThickness(1); + setFocusable(true); + setHideHighlightBackground(true); + setHighlightPadding(3); + setHighlightCornerRadius(14); + title_ = MakeLabel(title, 18, Text(), FontRole::Medium); + title_->setGrow(1); + title_->setShrink(1); + title_->setMinWidth(0); + title_->setSingleLine(true); + addView(title_); + value_ = MakeLabel(value, 16, Muted()); + value_->setMaxWidthPercentage(45); + value_->setShrink(1); + value_->setMarginLeft(16); + value_->setSingleLine(true); + value_->setVisibility(value.empty() ? brls::Visibility::GONE : brls::Visibility::VISIBLE); + addView(value_); + registerClickAction(std::move(activate)); + addGestureRecognizer(new brls::TapGestureRecognizer(this)); + updateActionHint(brls::BUTTON_A, title); + UpdateChrome(false); +} + +void ActionRow::SetTitle(const std::string& title) +{ + title_->setText(title); + updateActionHint(brls::BUTTON_A, title); +} +void ActionRow::SetValue(const std::string& value) +{ + value_->setText(value); + value_->setVisibility(value.empty() ? brls::Visibility::GONE : brls::Visibility::VISIBLE); +} +void ActionRow::SetFontSize(float size) { title_->setFontSize(size); } +void ActionRow::SetTone(ActionTone tone) +{ + tone_ = tone; + UpdateChrome(brls::Application::getCurrentFocus() == this); +} +void ActionRow::SetFocusHandler(std::function handler) { focus_handler_ = std::move(handler); } + +void ActionRow::onFocusGained() +{ + brls::Box::onFocusGained(); + UpdateChrome(true); + if (focus_handler_) + focus_handler_(); +} + +void ActionRow::onFocusLost() +{ + brls::Box::onFocusLost(); + UpdateChrome(false); +} + +void ActionRow::UpdateChrome(bool focused) +{ + const bool primary = tone_ == ActionTone::Primary; + const bool flat = tone_ == ActionTone::Flat; + setBackgroundColor(primary ? Green() : focused ? nvgRGB(30, 32, 36) : flat ? Ground() : Raised()); + setBorderColor(focused ? Green() : flat ? nvgRGBA(0, 0, 0, 0) : Rule()); + title_->setTextColor(primary ? Ground() : tone_ == ActionTone::Destructive ? Danger() : Text()); + value_->setTextColor(primary ? Ground() : focused ? Green() : Muted()); +} + +std::string StreamSummary(const StreamSettings& settings) +{ + std::string bitrate = std::to_string(settings.bitrate_kbps / 1000); + if (const int remainder = settings.bitrate_kbps % 1000; remainder != 0) + { + std::string fraction = std::to_string(1000 + remainder).substr(1); + fraction.erase(fraction.find_last_not_of('0') + 1); + bitrate += "." + fraction; + } + return std::to_string(settings.width) + "×" + std::to_string(settings.height) + " · " + + std::to_string(settings.fps) + " FPS · " + bitrate + " Mbps"; +} + +std::string ConfiguredLocation(const std::string& region) +{ + return region.empty() || region == "auto" || region == "Auto" + ? Tr("Auto") : Tr("Selected server"); +} + +NextStreamSummaryView::NextStreamSummaryView(const StreamSettings& settings) + : brls::Box(brls::Axis::COLUMN) +{ + setPadding(14, 18, 14, 18); + setCornerRadius(12); + setBorderThickness(1); + setBorderColor(Rule()); + setBackgroundColor(Raised()); + heading_ = MakeLabel(Tr("Next stream"), 14, Muted()); + heading_->setMarginBottom(8); + addView(heading_); + summary_ = MakeLabel({}, 18, Text(), FontRole::Medium); + summary_->setSingleLine(true); + addView(summary_); + detail_ = MakeLabel({}, 14, Muted()); + detail_->setMarginTop(5); + detail_->setSingleLine(true); + addView(detail_); + Update(settings); +} + +void NextStreamSummaryView::Update(const StreamSettings& settings) +{ + heading_->setText(Tr("Next stream")); + summary_->setText(StreamSummary(settings)); + detail_->setText(Tr("Configured") + " · " + settings.codec + " · " + + ConfiguredLocation(settings.region)); +} + +} diff --git a/app/src/ui_theme.hpp b/app/src/ui_theme.hpp new file mode 100644 index 00000000..8b1a72a2 --- /dev/null +++ b/app/src/ui_theme.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "stream_settings.hpp" + +#include + +#include +#include + +namespace opennow::ui +{ + +enum class FontRole { Body, Medium, Heading, Display, Mono }; +enum class ActionTone { Neutral, Primary, Destructive, Flat }; + +inline NVGcolor Ground() { return nvgRGB(11, 12, 14); } +inline NVGcolor Raised() { return nvgRGB(22, 24, 28); } +inline NVGcolor Text() { return nvgRGB(242, 244, 240); } +inline NVGcolor Muted() { return nvgRGB(163, 159, 150); } +inline NVGcolor Rule() { return nvgRGB(42, 45, 51); } +inline NVGcolor Green() { return nvgRGB(118, 232, 58); } +inline NVGcolor Danger() { return nvgRGB(255, 106, 85); } + +void InitializeThemeAndFonts(); +int Font(FontRole role); + +class StyledLabel final : public brls::Label +{ + public: + explicit StyledLabel(FontRole role = FontRole::Body); +}; + +brls::Label* MakeLabel(const std::string& text, float size = 18, + NVGcolor color = Text(), FontRole role = FontRole::Body); +float TextWidth(const std::string& text, float size, FontRole role); + +class FadingScrollFrame final : public brls::ScrollingFrame +{ + public: + void draw(NVGcontext* vg, float x, float y, float width, float height, + brls::Style style, brls::FrameContext* ctx) override; +}; + +class ActionRow : public brls::Box +{ + public: + ActionRow(std::string title, std::string value, + std::function activate, + ActionTone tone = ActionTone::Neutral); + void SetTitle(const std::string& title); + void SetValue(const std::string& value); + void SetFontSize(float size); + void SetTone(ActionTone tone); + void SetFocusHandler(std::function handler); + void onFocusGained() override; + void onFocusLost() override; + + private: + void UpdateChrome(bool focused); + brls::Label* title_; + brls::Label* value_; + ActionTone tone_; + std::function focus_handler_; +}; + +std::string StreamSummary(const StreamSettings& settings); +std::string ConfiguredLocation(const std::string& region); + +class NextStreamSummaryView final : public brls::Box +{ + public: + explicit NextStreamSummaryView(const StreamSettings& settings); + void Update(const StreamSettings& settings); + + private: + brls::Label* heading_; + brls::Label* summary_; + brls::Label* detail_; +}; + +} diff --git a/app/src/webrtc/media.cpp b/app/src/webrtc/media.cpp index 8f10fe60..8e13c56e 100644 --- a/app/src/webrtc/media.cpp +++ b/app/src/webrtc/media.cpp @@ -53,15 +53,18 @@ void WebRtcSession::decoder_loop() { if (reset_decoder && decoder_ && !decoder_->uses_hardware_frames()) decoder_->reset_stream(); - const int decoded = decoder_ + const uint64_t frames_before = decoder_ ? decoder_->decoded_frame_count() : 0; + const int decode_result = decoder_ ? decoder_->submit_decode_unit(unit.data.data(), static_cast(unit.data.size()), unit.rtp_timestamp) : -1; + const int decoded_frames = decoder_ + ? static_cast(decoder_->decoded_frame_count() - frames_before) : 0; const uint64_t decode_us = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now() - decode_started_at).count()); { std::lock_guard lock(decoder_queue_mutex_); - if (decoder_recovery_.complete(unit.generation, decoded >= 0) == + if (decoder_recovery_.complete(unit.generation, decode_result >= 0) == opennow::webrtc::DecodeRecoveryState::Completion::Failed) { decoder_queue_drops_ += static_cast(decoder_queue_.size()); clear_decoder_queue_locked(); @@ -72,9 +75,9 @@ void WebRtcSession::decoder_loop() { AtomicMax(decode_us_max_, decode_us); const int access_unit_count = packets_received_.load(); - if (decoded > 0) { + if (decoded_frames > 0) { last_decoded_frame_at_us_.store(NowUs(), std::memory_order_release); - const int frame_count = frames_decoded_.fetch_add(decoded) + decoded; + const int frame_count = frames_decoded_.fetch_add(decoded_frames) + decoded_frames; if (!first_decoded_frame_logged_) { first_decoded_frame_logged_ = true; AppendStreamLog("DECODE first_frame frames=" + std::to_string(frame_count) + @@ -84,7 +87,8 @@ void WebRtcSession::decoder_loop() { " accessUnits=" + std::to_string(access_unit_count)); last_logged_frame_count_.store(frame_count, std::memory_order_relaxed); } - } else if (decoded < 0) { + } + if (decode_result < 0) { const int error_count = decode_errors_.fetch_add(1) + 1; if (error_count <= 5 || error_count - last_logged_decode_error_count_.load(std::memory_order_relaxed) >= 20) { @@ -377,7 +381,7 @@ void WebRtcSession::on_rtp_sender_report(uint32_t ssrc, uint64_t ntp_us, uint32_ int64_t WebRtcSession::video_target_rtp_timestamp() const { std::lock_guard lock(peer_mutex_); - if (!audio_ || !have_video_sender_report_) + if (!audio_ || !audio_->is_playing() || !have_video_sender_report_) return AV_NOPTS_VALUE; const int64_t audio_ntp = audio_->playback_ntp_us(); if (audio_ntp < 0) diff --git a/app/src/webrtc/negotiation.cpp b/app/src/webrtc/negotiation.cpp index 7805dd1a..f64265ed 100644 --- a/app/src/webrtc/negotiation.cpp +++ b/app/src/webrtc/negotiation.cpp @@ -142,12 +142,27 @@ std::vector FindRtcpFbForPayload(const std::vector& li std::vector feedback; const std::string prefix = "a=rtcp-fb:" + std::to_string(payload_type) + " "; for (const auto& line : lines) { - if (line == prefix + "nack" || line == prefix + "nack pli") + if (line == prefix + "nack" || line == prefix + "nack pli" || line == prefix + "transport-cc") feedback.push_back(line); } return feedback; } +std::string FindTransportCcExtmap(const std::vector& lines) +{ + static const std::string kUri = + "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"; + for (const auto& line : lines) { + if (!StartsWithString(line, "a=extmap:") || line.find(kUri) == std::string::npos) + continue; + const size_t id_end = line.find_first_of(" /", 9); + if (id_end == std::string::npos || id_end == 9) + continue; + return line.substr(0, id_end) + " " + kUri; + } + return ""; +} + int SelectOfferH264PayloadType(const std::string& offer_sdp) { const std::vector lines = SplitSdpLines(offer_sdp); @@ -345,6 +360,7 @@ std::string AdaptAnswerSdpToOffer( const std::vector offer_lines = SplitSdpLines(offer_sdp); const std::string offer_h264_fmtp = FindFmtpForPayload(offer_lines, h264_payload_type); const std::vector offer_h264_feedback = FindRtcpFbForPayload(offer_lines, h264_payload_type); + const std::string offer_transport_cc_extmap = FindTransportCcExtmap(offer_lines); std::string offer_audio_red_rtpmap; std::string offer_audio_red_fmtp; bool offer_in_audio = false; @@ -398,6 +414,14 @@ std::string AdaptAnswerSdpToOffer( if (!offer_audio_red_fmtp.empty()) out.push_back(offer_audio_red_fmtp); } + std::vector audio_payloads{"111"}; + if (!offer_audio_red_rtpmap.empty()) + audio_payloads.push_back("63"); + for (const auto& audio_payload : audio_payloads) { + const std::string feedback = "a=rtcp-fb:" + audio_payload + " transport-cc"; + if (std::find(offer_lines.begin(), offer_lines.end(), feedback) != offer_lines.end()) + out.push_back(feedback); + } audio_red_added = true; continue; } @@ -444,6 +468,9 @@ std::string AdaptAnswerSdpToOffer( continue; } + if ((in_video || in_audio) && line == "a=rtcp-mux" && !offer_transport_cc_extmap.empty()) + out.push_back(offer_transport_cc_extmap); + out.push_back(line); } @@ -722,6 +749,25 @@ void WebRtcSession::handle_signaling_message(const std::string& msg) { nvst_offer && json_is_string(nvst_offer) ? json_string_value(nvst_offer) : ""; if (!nvst_offer_sdp.empty()) AppendSdpSummary("OFFER NVST SDP", nvst_offer_sdp); + if (settings_.debug_diagnostics) { + int logged_attributes = 0; + for (const auto& line : SplitSdpLines(offer_sdp)) { + if (logged_attributes < 24 && + (StartsWith(line, "a=rtcp-fb:") || StartsWith(line, "a=extmap:"))) { + AppendStreamLog("SDP offer_feedback " + line); + logged_attributes++; + } + } + for (const auto& line : SplitSdpLines(nvst_offer_sdp)) { + if (logged_attributes < 72 && + (line.find("itrate") != std::string::npos || + StartsWith(line, "a=bwe.") || + line.find("drc.") != std::string::npos)) { + AppendStreamLog("SDP offer_nvst " + line); + logged_attributes++; + } + } + } server_ice_ufrag_ = ExtractSdpValue(offer_sdp, "a=ice-ufrag:"); const opennow::webrtc::RiInputCapabilities ri_caps = ParseRiInputCapabilities(offer_sdp); diff --git a/app/src/webrtc/nvst_sdp.cpp b/app/src/webrtc/nvst_sdp.cpp index 8d1160fc..d511bef3 100644 --- a/app/src/webrtc/nvst_sdp.cpp +++ b/app/src/webrtc/nvst_sdp.cpp @@ -42,7 +42,6 @@ std::string BuildNvstSdp( const std::string fingerprint = ExtractSdpValue(answer_sdp, "a=fingerprint:sha-256 "); const auto tuning = video::ResolveQualityTuning(settings.image_quality_mode); const int max_bitrate = std::max(kOfficialMinimumBitrateKbps, settings.bitrate_kbps); - const int initial_bitrate = std::max(kOfficialMinimumBitrateKbps, max_bitrate / 4); const std::vector lines = { "v=0", @@ -60,9 +59,9 @@ std::string BuildNvstSdp( "a=vqos.fec.repairMinPercent:" + std::to_string(tuning.fec_repair_min_percent), "a=vqos.fec.repairPercent:" + std::to_string(tuning.fec_repair_percent), "a=vqos.fec.repairMaxPercent:" + std::to_string(tuning.fec_repair_max_percent), - "a=vqos.dynamicStreamingMode:3", + "a=vqos.dynamicStreamingMode:0", "a=vqos.bllFec.enable:0", - "a=vqos.drc.enable:1", + "a=vqos.drc.enable:0", "a=video.dx9EnableNv12:1", "a=video.dx9EnableHdr:1", "a=vqos.qpg.enable:1", @@ -77,7 +76,10 @@ std::string BuildNvstSdp( "a=vqos.adjustStreamingFpsDuringOutOfFocus:1", "a=vqos.resControl.cpmRtc.ignoreOutOfFocusWindowState:1", "a=vqos.resControl.perfHistory.rtcIgnoreOutOfFocusWindowState:1", - "a=vqos.resControl.cpmRtc.featureMask:3", + "a=vqos.resControl.cpmRtc.featureMask:0", + "a=vqos.resControl.cpmRtc.enable:0", + "a=vqos.resControl.cpmRtc.minResolutionPercent:100", + "a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999", "a=packetPacing.numGroups:" + std::to_string(tuning.pacing_groups), "a=packetPacing.maxDelayUs:" + std::to_string(tuning.pacing_max_delay_us), "a=packetPacing.minNumPacketsFrame:10", @@ -87,8 +89,8 @@ std::string BuildNvstSdp( "a=video.clientViewportWd:" + std::to_string(settings.width), "a=video.clientViewportHt:" + std::to_string(settings.height), "a=video.maxFPS:" + std::to_string(settings.fps), - "a=video.initialBitrateKbps:" + std::to_string(initial_bitrate), - "a=video.initialPeakBitrateKbps:" + std::to_string(initial_bitrate), + "a=video.initialBitrateKbps:" + std::to_string(max_bitrate), + "a=video.initialPeakBitrateKbps:" + std::to_string(max_bitrate), "a=vqos.bw.maximumBitrateKbps:" + std::to_string(max_bitrate), "a=vqos.bw.minimumBitrateKbps:" + std::to_string(kOfficialMinimumBitrateKbps), "a=video.maxNumReferenceFrames:4", diff --git a/app/src/webrtc/session.cpp b/app/src/webrtc/session.cpp index 178bd2a1..9c51f48f 100644 --- a/app/src/webrtc/session.cpp +++ b/app/src/webrtc/session.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -86,19 +87,26 @@ WebRtcSession::WebRtcSession( const std::string& session_id, const std::string& media_ip, int media_port, - const std::vector& ice_servers) + const std::vector& ice_servers, + const opennow::StreamSettings& settings) : signaling_url_(signaling_url), jwt_token_(jwt_token), session_id_(session_id), media_ip_(media_ip), media_port_(media_port), ice_servers_(ice_servers), - settings_(opennow::LoadStreamSettings()), + settings_(settings), peer_name_(MakePeerName()) { opennow::SetStreamDiagnosticsEnabled(settings_.debug_diagnostics); + peer_connection_set_diagnostic_callback([](const char* message) { + AppendStreamLog(message); + if (StartsWith(message, "SCTP ") || std::strstr(message, "sctp") || + std::strstr(message, "dtls") || std::strstr(message, "transport_completed")) + AppendInputLog(message); + }); peer_connection_set_diagnostics_enabled(settings_.debug_diagnostics ? 1 : 0); - renderer_ = std::make_unique(); + renderer_ = std::make_unique(settings_.image_quality_mode); audio_ = std::make_unique(); audio_->configure(settings_.audio_volume, settings_.audio_buffer_ms); @@ -107,14 +115,15 @@ WebRtcSession::WebRtcSession( auto_safe_mode_used_ = previous_nvdec_crash; const bool force_software = settings_.video_backend == "Software" || previous_nvdec_crash; - decoder_ = std::make_unique(); + decoder_ = std::make_unique(settings_.image_quality_mode); decoder_setup_result_ = decoder_->setup( VIDEO_FORMAT_H264, settings_.width, settings_.height, settings_.fps, nullptr, force_software ? VIDEO_DECODER_FORCE_SOFTWARE : VIDEO_DECODER_PREFER_HARDWARE); if (decoder_setup_result_ != 0 && !force_software) { + hardware_decoder_setup_result_ = decoder_setup_result_; decoder_->cleanup(); - decoder_ = std::make_unique(); + decoder_ = std::make_unique(settings_.image_quality_mode); decoder_setup_result_ = decoder_->setup( VIDEO_FORMAT_H264, settings_.width, settings_.height, settings_.fps, nullptr, VIDEO_DECODER_FORCE_SOFTWARE); @@ -196,6 +205,7 @@ void WebRtcSession::start() { " fallback=" + std::to_string(decoder_fallback_used_ ? 1 : 0) + " crashSafeMode=" + std::to_string(auto_safe_mode_used_ ? 1 : 0) + " decoderSetup=" + std::to_string(decoder_setup_result_) + + " hardwareSetup=" + std::to_string(hardware_decoder_setup_result_) + " expectedFormat=" + std::string(decoder_ && decoder_->uses_hardware_frames() ? "NVTEGRA" : "YUV420P/NV12")); diff --git a/app/src/webrtc_session.hpp b/app/src/webrtc_session.hpp index 82202258..078ecdcb 100644 --- a/app/src/webrtc_session.hpp +++ b/app/src/webrtc_session.hpp @@ -68,7 +68,8 @@ class WebRtcSession { const std::string& session_id, const std::string& media_ip, int media_port, - const std::vector& ice_servers); + const std::vector& ice_servers, + const opennow::StreamSettings& settings); ~WebRtcSession(); void start(); @@ -143,6 +144,7 @@ class WebRtcSession { std::atomic rendered_video_height_ {0}; std::unique_ptr audio_; int decoder_setup_result_ = -1; + int hardware_decoder_setup_result_ = 0; bool decoder_fallback_used_ = false; bool auto_safe_mode_used_ = false; bool nvdec_marker_owned_ = false; diff --git a/extern/libpeer/src/agent.c b/extern/libpeer/src/agent.c index 13e74124..e8f3fc39 100644 --- a/extern/libpeer/src/agent.c +++ b/extern/libpeer/src/agent.c @@ -27,6 +27,10 @@ void agent_clear_candidates(Agent* agent) { agent->nominated_pair = NULL; agent->last_binding_request_sent_ms = 0; agent->smoothed_binding_rtt_ms = 0; + agent->last_consent_request_ms = 0; + agent->consent_attempts = 0; + agent->consent_requests_sent = 0; + agent->binding_responses_received = 0; } int agent_create(Agent* agent) { @@ -327,17 +331,20 @@ static void agent_create_binding_response(Agent* agent, StunMessage* msg, Addres stun_msg_finish(msg, STUN_CREDENTIAL_SHORT_TERM, agent->local_upwd, strlen(agent->local_upwd)); } -static void agent_create_binding_request(Agent* agent, StunMessage* msg) { +static void agent_create_binding_request(Agent* agent, StunMessage* msg, int nominate, uint32_t sequence) { uint64_t tie_breaker = 0; // always be controlled // send binding request stun_msg_create(msg, STUN_CLASS_REQUEST | STUN_METHOD_BINDING); + if (sequence != 0) + ((StunHeader*)msg->buf)->transaction_id[2] = htonl(sequence); char username[584]; memset(username, 0, sizeof(username)); snprintf(username, sizeof(username), "%s:%s", agent->remote_ufrag, agent->local_ufrag); stun_msg_write_attr(msg, STUN_ATTR_TYPE_USERNAME, strlen(username), username); stun_msg_write_attr(msg, STUN_ATTR_TYPE_PRIORITY, 4, (char*)&agent->nominated_pair->priority); if (agent->mode == AGENT_MODE_CONTROLLING) { - stun_msg_write_attr(msg, STUN_ATTR_TYPE_USE_CANDIDATE, 0, NULL); + if (nominate) + stun_msg_write_attr(msg, STUN_ATTR_TYPE_USE_CANDIDATE, 0, NULL); stun_msg_write_attr(msg, STUN_ATTR_TYPE_ICE_CONTROLLING, 8, (char*)&tie_breaker); } else { stun_msg_write_attr(msg, STUN_ATTR_TYPE_ICE_CONTROLLED, 8, (char*)&tie_breaker); @@ -379,6 +386,7 @@ void agent_process_stun_response(Agent* agent, StunMessage* stun_msg) { } agent->last_binding_request_sent_ms = 0; } + agent->binding_responses_received++; agent->nominated_pair->state = ICE_CANDIDATE_STATE_SUCCEEDED; agent->binding_request_time = now_ms; } @@ -533,7 +541,7 @@ int agent_connectivity_check(Agent* agent) { if (agent->nominated_pair->conncheck % AGENT_CONNCHECK_PERIOD == 0) { addr_to_string(&agent->nominated_pair->remote->addr, addr_string, sizeof(addr_string)); LOGD("send binding request to remote ip: %s, port: %d", addr_string, agent->nominated_pair->remote->addr.port); - agent_create_binding_request(agent, &msg); + agent_create_binding_request(agent, &msg, 1, 0); if (agent_socket_send( agent, &agent->nominated_pair->remote->addr, msg.buf, msg.size) >= 0) { agent->last_binding_request_sent_ms = ports_get_epoch_time(); @@ -550,6 +558,26 @@ int agent_connectivity_check(Agent* agent) { return -1; } +int agent_maybe_send_consent_request(Agent* agent, uint32_t now_ms) { + StunMessage msg; + + if (!agent->selected_pair || !agent->selected_pair->remote) + return 0; + if (agent->consent_attempts > 0 && + (uint32_t)(now_ms - agent->last_consent_request_ms) < CONFIG_ICE_CONSENT_INTERVAL_MS) + return 0; + + agent->consent_attempts++; + agent->last_consent_request_ms = now_ms; + memset(&msg, 0, sizeof(msg)); + agent_create_binding_request(agent, &msg, 0, agent->consent_attempts); + if (agent_socket_send(agent, &agent->selected_pair->remote->addr, msg.buf, msg.size) < 0) + return -1; + agent->consent_requests_sent++; + agent->last_binding_request_sent_ms = now_ms; + return 1; +} + int agent_select_candidate_pair(Agent* agent) { int i; for (i = 0; i < agent->candidate_pairs_num; i++) { diff --git a/extern/libpeer/src/agent.h b/extern/libpeer/src/agent.h index 26a3ef52..3fcee127 100644 --- a/extern/libpeer/src/agent.h +++ b/extern/libpeer/src/agent.h @@ -61,6 +61,10 @@ struct Agent { uint64_t binding_request_time; uint32_t last_binding_request_sent_ms; uint32_t smoothed_binding_rtt_ms; + uint32_t last_consent_request_ms; + uint32_t consent_attempts; + uint32_t consent_requests_sent; + uint32_t binding_responses_received; AgentState state; AgentMode mode; @@ -102,6 +106,8 @@ int agent_fail_nominated_remote(Agent* agent); int agent_connectivity_check(Agent* agent); +int agent_maybe_send_consent_request(Agent* agent, uint32_t now_ms); + void agent_clear_candidates(Agent* agent); int agent_create(Agent* agent); diff --git a/extern/libpeer/src/config.h b/extern/libpeer/src/config.h index c655d386..2db8f8ba 100644 --- a/extern/libpeer/src/config.h +++ b/extern/libpeer/src/config.h @@ -53,6 +53,10 @@ #define CONFIG_KEEPALIVE_TIMEOUT 10000 #endif +#ifndef CONFIG_ICE_CONSENT_INTERVAL_MS +#define CONFIG_ICE_CONSENT_INTERVAL_MS 2500 +#endif + #ifndef CONFIG_AUDIO_DURATION #define CONFIG_AUDIO_DURATION 20 #endif diff --git a/extern/libpeer/src/peer_connection.c b/extern/libpeer/src/peer_connection.c index 5a391813..5aa3ad41 100644 --- a/extern/libpeer/src/peer_connection.c +++ b/extern/libpeer/src/peer_connection.c @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include #include @@ -15,6 +17,7 @@ #include "rtp.h" #include "sctp.h" #include "sdp.h" +#include "twcc.h" #define STATE_CHANGED(pc, curr_state) \ if (pc->state != curr_state) { \ @@ -61,6 +64,8 @@ struct PeerConnection { uint32_t video_nack_requests; uint32_t video_nack_packets_requested; RtcpReceiverStats video_receiver_stats; + TwccReceiver twcc; + uint32_t twcc_send_failures; int dtls_handshake_attempts; uint32_t dtls_handshake_started_ms; @@ -78,7 +83,14 @@ struct PeerConnection { static int peer_connection_send_rtcp_nack(PeerConnection* pc, uint32_t ssrc, uint16_t packet_id, uint16_t bitmask); -static int peer_connection_diagnostics_enabled; +static atomic_int peer_connection_diagnostics_enabled; +typedef void (*PeerDiagnosticCallback)(const char*); +static _Atomic(PeerDiagnosticCallback) peer_diagnostic_callback; + +void peer_connection_set_diagnostic_callback(void (*callback)(const char*)) { + atomic_store(&peer_diagnostic_callback, callback); + sctp_set_diagnostic_callback(callback); +} void peer_connection_set_diagnostics_enabled(int enabled) { peer_connection_diagnostics_enabled = enabled != 0; @@ -88,54 +100,17 @@ void peer_connection_set_diagnostics_enabled(int enabled) { static void peer_connection_diag_log(const char* fmt, ...) { if (!peer_connection_diagnostics_enabled) return; - FILE* file = fopen("sdmc:/switch/OpenNOWSwitch/signaling.log", "a"); - FILE* trace = fopen("sdmc:/switch/OpenNOWSwitch/stream_trace.log", "a"); - const int input_relevant = strstr(fmt, "sctp") != NULL || - strstr(fmt, "dtls") != NULL || - strstr(fmt, "transport_completed") != NULL; - FILE* input = input_relevant ? fopen("sdmc:/switch/OpenNOWSwitch/input.log", "a") : NULL; - - if (file) { - fputs("LIBPEER ", file); - } - if (trace) { - fputs("LIBPEER ", trace); - } - if (input) { - fputs("LIBPEER ", input); - } - + PeerDiagnosticCallback callback = atomic_load(&peer_diagnostic_callback); + if (!callback) + return; + const int saved_errno = errno; + char message[2048] = "LIBPEER "; va_list args; va_start(args, fmt); - if (file) { - va_list copy; - va_copy(copy, args); - vfprintf(file, fmt, copy); - va_end(copy); - } - if (trace) { - va_list copy; - va_copy(copy, args); - vfprintf(trace, fmt, copy); - va_end(copy); - } - if (input) { - vfprintf(input, fmt, args); - } + vsnprintf(message + 8, sizeof(message) - 8, fmt, args); va_end(args); - - if (file) { - fputc('\n', file); - fclose(file); - } - if (trace) { - fputc('\n', trace); - fclose(trace); - } - if (input) { - fputc('\n', input); - fclose(input); - } + callback(message); + errno = saved_errno; } static int peer_connection_video_packet_buffered(const RtpDecoder* decoder, @@ -431,6 +406,30 @@ static void peer_connection_maybe_send_receiver_report(PeerConnection* pc) { rtcp_receiver_report_sent(&pc->video_receiver_stats, now_ms, success); } +static void peer_connection_maybe_send_transport_feedback(PeerConnection* pc) { + const uint64_t now_us = ports_get_monotonic_time_us(); + if (pc->state != PEER_CONNECTION_COMPLETED || !twcc_receiver_feedback_due(&pc->twcc, now_us)) + return; + for (int i = 0; i < 4; i++) { + uint8_t packet[TWCC_MAX_FEEDBACK_SIZE + 32]; + int size = twcc_receiver_build_feedback(&pc->twcc, pc->vrtp_encoder.ssrc, + packet, TWCC_MAX_FEEDBACK_SIZE); + if (size <= 0) + break; + if (dtls_srtp_encrypt_rctp_packet(&pc->dtls_srtp, packet, &size) != 0 || + agent_send(&pc->agent, packet, size) != size) { + pc->twcc_send_failures++; + break; + } + } + twcc_receiver_feedback_sent(&pc->twcc, now_us); + if (pc->twcc.feedback_sent <= 3 || pc->twcc.feedback_sent % 1200 == 0) { + peer_connection_diag_log("twcc_feedback sent=%" PRIu32 " recorded=%" PRIu32 " late=%" PRIu32 " failures=%" PRIu32, + pc->twcc.feedback_sent, pc->twcc.packets_recorded, + pc->twcc.late_packets, pc->twcc_send_failures); + } +} + const char* peer_connection_state_to_string(PeerConnectionState state) { switch (state) { case PEER_CONNECTION_NEW: @@ -736,6 +735,7 @@ int peer_connection_loop(PeerConnection* pc) { break; } if ((pc->agent_ret = agent_recv_nonblocking(&pc->agent, pc->agent_buf, sizeof(pc->agent_buf))) > 0) { + const uint64_t arrival_us = ports_get_monotonic_time_us(); packet_processed = 1; LOGD("agent_recv %d", pc->agent_ret); pc->completed_udp_packets++; @@ -796,6 +796,7 @@ int peer_connection_loop(PeerConnection* pc) { break; } + twcc_receiver_record(&pc->twcc, pc->agent_buf, pc->agent_ret, arrival_us); ssrc = rtp_get_ssrc(pc->agent_buf); RtpPacket* rtp = (RtpPacket*)pc->agent_buf; const int is_audio_payload = rtp->header.type == PT_PCMU || @@ -867,8 +868,19 @@ int peer_connection_loop(PeerConnection* pc) { LOGI("binding request timeout; keeping completed transport alive"); pc->agent.binding_request_time = ports_get_epoch_time(); } + int consent_ret = agent_maybe_send_consent_request(&pc->agent, ports_get_epoch_time()); + if (consent_ret != 0 && + (pc->agent.consent_attempts <= 3 || pc->agent.consent_attempts % 24 == 0 || consent_ret < 0)) { + peer_connection_diag_log("ice_consent ret=%d attempts=%" PRIu32 " sent=%" PRIu32 " responses=%" PRIu32 " rttMs=%" PRIu32, + consent_ret, + pc->agent.consent_attempts, + pc->agent.consent_requests_sent, + pc->agent.binding_responses_received, + pc->agent.smoothed_binding_rtt_ms); + } rtp_decoder_poll(&pc->vrtp_decoder); peer_connection_maybe_send_receiver_report(pc); + peer_connection_maybe_send_transport_feedback(pc); break; case PEER_CONNECTION_FAILED: @@ -918,6 +930,12 @@ void peer_connection_set_remote_description(PeerConnection* pc, const char* sdp, is_update = 1; } + const int twcc_extension_id = twcc_parse_extension_id(buf); + if (twcc_extension_id > 0 && pc->twcc.extension_id == 0) { + twcc_receiver_init(&pc->twcc, (uint8_t)twcc_extension_id); + peer_connection_diag_log("twcc_extension id=%d", twcc_extension_id); + } + if (strstr(buf, "m=video")) { ssrc = &pc->remote_vssrc; } else if (strstr(buf, "m=audio")) { diff --git a/extern/libpeer/src/peer_connection.h b/extern/libpeer/src/peer_connection.h index 8f8cedbe..439d7eac 100644 --- a/extern/libpeer/src/peer_connection.h +++ b/extern/libpeer/src/peer_connection.h @@ -129,6 +129,7 @@ void* peer_connection_get_sctp(PeerConnection* pc); PeerConnection* peer_connection_create(PeerConfiguration* config); void peer_connection_set_diagnostics_enabled(int enabled); +void peer_connection_set_diagnostic_callback(void (*callback)(const char*)); void peer_connection_destroy(PeerConnection* pc); diff --git a/extern/libpeer/src/ports.c b/extern/libpeer/src/ports.c index 0da4e47e..bf6ea145 100644 --- a/extern/libpeer/src/ports.c +++ b/extern/libpeer/src/ports.c @@ -169,6 +169,17 @@ uint32_t ports_get_monotonic_time(void) { #endif } +uint64_t ports_get_monotonic_time_us(void) { +#if CONFIG_USE_LWIP + return (uint64_t)sys_now() * 1000; +#else + struct timespec time; + if (clock_gettime(CLOCK_MONOTONIC, &time) != 0) + return 0; + return (uint64_t)time.tv_sec * 1000000 + (uint64_t)(time.tv_nsec / 1000); +#endif +} + void ports_sleep_ms(int ms) { #if CONFIG_USE_LWIP sys_msleep(ms); diff --git a/extern/libpeer/src/ports.h b/extern/libpeer/src/ports.h index 2b9f58ff..aebb05ba 100644 --- a/extern/libpeer/src/ports.h +++ b/extern/libpeer/src/ports.h @@ -14,6 +14,8 @@ uint32_t ports_get_epoch_time(); uint32_t ports_get_monotonic_time(void); +uint64_t ports_get_monotonic_time_us(void); + void ports_sleep_ms(int ms); #endif // PORTS_H_ diff --git a/extern/libpeer/src/sctp.c b/extern/libpeer/src/sctp.c index b3ae37b5..b5bfc3c3 100644 --- a/extern/libpeer/src/sctp.c +++ b/extern/libpeer/src/sctp.c @@ -24,6 +24,12 @@ _Static_assert(sizeof(((struct sockaddr_conn*)0)->sconn_family) == #endif static atomic_int sctp_diagnostics_enabled; +typedef void (*SctpDiagnosticCallback)(const char*); +static _Atomic(SctpDiagnosticCallback) sctp_diagnostic_callback; + +void sctp_set_diagnostic_callback(void (*callback)(const char*)) { + atomic_store(&sctp_diagnostic_callback, callback); +} #if CONFIG_USE_USRSCTP static unsigned sctp_runtime_users; static int sctp_runtime_initialized; @@ -62,36 +68,17 @@ void sctp_set_diagnostics_enabled(int enabled) { static void sctp_diag_log(const char* fmt, ...) { if (!sctp_diagnostics_enabled) return; - FILE* file = fopen("sdmc:/switch/OpenNOWSwitch/signaling.log", "a"); - FILE* trace = fopen("sdmc:/switch/OpenNOWSwitch/stream_trace.log", "a"); - FILE* input = fopen("sdmc:/switch/OpenNOWSwitch/input.log", "a"); + SctpDiagnosticCallback callback = atomic_load(&sctp_diagnostic_callback); + if (!callback) + return; + const int saved_errno = errno; + char message[2048] = "SCTP "; va_list args; va_start(args, fmt); - if (file) { - fputs("SCTP ", file); - va_list copy; - va_copy(copy, args); - vfprintf(file, fmt, copy); - va_end(copy); - fputc('\n', file); - fclose(file); - } - if (input) { - fputs("SCTP ", input); - va_list copy; - va_copy(copy, args); - vfprintf(input, fmt, copy); - va_end(copy); - fputc('\n', input); - fclose(input); - } - if (trace) { - fputs("SCTP ", trace); - vfprintf(trace, fmt, args); - fputc('\n', trace); - fclose(trace); - } + vsnprintf(message + 5, sizeof(message) - 5, fmt, args); va_end(args); + callback(message); + errno = saved_errno; } #if CONFIG_USE_USRSCTP @@ -916,7 +903,11 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { #if CONFIG_USE_USRSCTP pthread_mutex_lock(&sctp_runtime_mutex); if (!sctp || !dtls_srtp || !sctp_runtime_initialized || sctp->sock) { + const int initialized = sctp_runtime_initialized; + const int socket_present = sctp && sctp->sock != NULL; pthread_mutex_unlock(&sctp_runtime_mutex); + sctp_diag_log("create_failed step=runtime initialized=%d context=%d dtls=%d socket=%d errno=%d", + initialized, sctp != NULL, dtls_srtp != NULL, socket_present, EINVAL); errno = EINVAL; return -1; } @@ -928,6 +919,8 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { sctp->message_buf = NULL; sctp->transport = NULL; pthread_mutex_unlock(&sctp_runtime_mutex); + sctp_diag_log("create_failed step=allocation errno=%d", ENOMEM); + errno = ENOMEM; return -1; } sctp->message_len = 0; @@ -949,6 +942,8 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { sctp_incoming_data_cb, NULL, 0, sctp); if (!sock) { + const int socket_errno = errno; + sctp_diag_log("create_failed step=socket errno=%d", socket_errno); LOGE("usrsctp_socket failed"); usrsctp_deregister_address(sctp); free(sctp->message_buf); @@ -957,10 +952,12 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { sctp->transport = NULL; sctp->dtls_srtp = NULL; pthread_mutex_unlock(&sctp_runtime_mutex); + errno = socket_errno; return -1; } sctp->sock = sock; + const char* setup_step = "nonblocking"; do { if (usrsctp_set_non_blocking(sock, 1) < 0) { LOGE("usrsctp_set_non_blocking failed"); @@ -970,20 +967,27 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { struct linger lopt; lopt.l_onoff = 1; lopt.l_linger = 0; + setup_step = "linger"; if (usrsctp_setsockopt(sock, SOL_SOCKET, SO_LINGER, &lopt, sizeof(lopt)) < 0) break; const int send_buffer = SCTP_SEND_BUFFER_SIZE; const int receive_buffer = SCTP_RECEIVE_BUFFER_SIZE; const int interleave = 0; - if (usrsctp_setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &send_buffer, sizeof(send_buffer)) < 0 || - usrsctp_setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &receive_buffer, sizeof(receive_buffer)) < 0 || - usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_FRAGMENT_INTERLEAVE, &interleave, sizeof(interleave)) < 0) + setup_step = "send_buffer"; + if (usrsctp_setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &send_buffer, sizeof(send_buffer)) < 0) + break; + setup_step = "receive_buffer"; + if (usrsctp_setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &receive_buffer, sizeof(receive_buffer)) < 0) + break; + setup_step = "fragment_interleave"; + if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_FRAGMENT_INTERLEAVE, &interleave, sizeof(interleave)) < 0) break; struct sctp_paddrparams peer_param = {0}; peer_param.spp_address.ss_family = AF_CONN; peer_param.spp_flags = SPP_PMTUD_DISABLE; peer_param.spp_pathmtu = SCTP_MTU - 12; + setup_step = "path_mtu"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_PEER_ADDR_PARAMS, &peer_param, sizeof(peer_param)) < 0) break; @@ -991,16 +995,19 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { rto.srto_initial = 200; rto.srto_min = 100; rto.srto_max = 1000; + setup_step = "retransmission_timeout"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_RTOINFO, &rto, sizeof(rto)) < 0) break; struct sctp_assoc_value av; av.assoc_id = SCTP_ALL_ASSOC; av.assoc_value = SCTP_ENABLE_RESET_STREAM_REQ | SCTP_ENABLE_CHANGE_ASSOC_REQ; + setup_step = "stream_reset"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_ENABLE_STREAM_RESET, &av, sizeof(av)) < 0) break; uint32_t nodelay = 1; + setup_step = "nodelay"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_NODELAY, &nodelay, sizeof(nodelay)) < 0) break; @@ -1009,6 +1016,7 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { event.se_assoc_id = SCTP_ALL_ASSOC; event.se_on = 1; event.se_type = SCTP_ASSOC_CHANGE; + setup_step = "association_events"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_EVENT, &event, sizeof(event)) < 0) break; @@ -1016,6 +1024,7 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { memset(&init_msg, 0, sizeof init_msg); init_msg.sinit_num_ostreams = 300; init_msg.sinit_max_instreams = 300; + setup_step = "initial_streams"; if (usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_INITMSG, &init_msg, sizeof init_msg) < 0) break; @@ -1027,6 +1036,7 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { sconn.sconn_family = AF_CONN; sconn.sconn_port = htons(sctp->local_port); sconn.sconn_addr = (void*)sctp; + setup_step = "bind"; ret = usrsctp_bind(sock, (struct sockaddr*)&sconn, sizeof(sconn)); sctp_diag_log("bind ret=%d errno=%d localPort=%d", ret, ret < 0 ? errno : 0, sctp->local_port); if (ret < 0) @@ -1041,11 +1051,14 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { rconn.sconn_family = AF_CONN; rconn.sconn_port = htons(sctp->remote_port); rconn.sconn_addr = (void*)sctp; + setup_step = "connect"; ret = usrsctp_connect(sock, (struct sockaddr*)&rconn, sizeof(struct sockaddr_conn)); - sctp_diag_log("connect ret=%d errno=%d remotePort=%d", ret, ret < 0 ? errno : 0, sctp->remote_port); + const int connect_errno = errno; + sctp_diag_log("connect ret=%d errno=%d remotePort=%d", ret, ret < 0 ? connect_errno : 0, sctp->remote_port); - if (ret < 0 && errno != EINPROGRESS) { + if (ret < 0 && connect_errno != EINPROGRESS) { LOGE("connect error"); + errno = connect_errno; break; } @@ -1053,9 +1066,12 @@ int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp) { } while (0); + const int setup_errno = errno; pthread_mutex_unlock(&sctp_runtime_mutex); if (ret < 0) { + sctp_diag_log("create_failed step=%s errno=%d", setup_step, setup_errno); sctp_destroy_association(sctp); + errno = setup_errno; return -1; } diff --git a/extern/libpeer/src/sctp.h b/extern/libpeer/src/sctp.h index f0bf1ad7..4932e87a 100644 --- a/extern/libpeer/src/sctp.h +++ b/extern/libpeer/src/sctp.h @@ -189,6 +189,7 @@ typedef struct Sctp { int sctp_create_association(Sctp* sctp, DtlsSrtp* dtls_srtp); void sctp_set_diagnostics_enabled(int enabled); +void sctp_set_diagnostic_callback(void (*callback)(const char*)); void sctp_destroy_association(Sctp* sctp); diff --git a/extern/libpeer/src/sdp.c b/extern/libpeer/src/sdp.c index b7661a4c..01cbd1fe 100644 --- a/extern/libpeer/src/sdp.c +++ b/extern/libpeer/src/sdp.c @@ -2,6 +2,7 @@ #include #include "sdp.h" +#include "sctp.h" int sdp_append(char* sdp, const char* format, ...) { va_list argptr; @@ -74,7 +75,7 @@ void sdp_append_datachannel(char* sdp) { sdp_append(sdp, "c=IN IP4 0.0.0.0"); sdp_append(sdp, "a=mid:datachannel"); sdp_append(sdp, "a=sctp-port:5000"); - sdp_append(sdp, "a=max-message-size:262144"); + sdp_append(sdp, "a=max-message-size:%u", (unsigned)SCTP_MAX_MESSAGE_SIZE); } void sdp_create(char* sdp, int b_video, int b_audio, int b_datachannel) { diff --git a/extern/libpeer/src/twcc.c b/extern/libpeer/src/twcc.c new file mode 100644 index 00000000..54a0834c --- /dev/null +++ b/extern/libpeer/src/twcc.c @@ -0,0 +1,229 @@ +#include "twcc.h" + +#include +#include + +#define TWCC_HISTORY_MASK (TWCC_HISTORY_SIZE - 1) +#define TWCC_HEADER_SIZE 20 +#define TWCC_DELTA_TICK_US 250 +#define TWCC_REFERENCE_TICK_US 64000 + +_Static_assert((TWCC_HISTORY_SIZE & TWCC_HISTORY_MASK) == 0, "TWCC history must be a power of two"); + +static uint16_t twcc_read_u16(const uint8_t* data) { + return (uint16_t)(((uint16_t)data[0] << 8) | data[1]); +} + +static uint32_t twcc_read_u32(const uint8_t* data) { + return ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | data[3]; +} + +static void twcc_write_u16(uint8_t* data, uint16_t value) { + data[0] = (uint8_t)(value >> 8); + data[1] = (uint8_t)value; +} + +static void twcc_write_u32(uint8_t* data, uint32_t value) { + data[0] = (uint8_t)(value >> 24); + data[1] = (uint8_t)(value >> 16); + data[2] = (uint8_t)(value >> 8); + data[3] = (uint8_t)value; +} + +int twcc_parse_extension_id(const char* sdp_line) { + static const char prefix[] = "a=extmap:"; + if (!sdp_line || strncmp(sdp_line, prefix, sizeof(prefix) - 1) != 0 || + !strstr(sdp_line, TWCC_EXTENSION_URI)) + return 0; + char* end = NULL; + const long id = strtol(sdp_line + sizeof(prefix) - 1, &end, 10); + if (end == sdp_line + sizeof(prefix) - 1 || (*end != ' ' && *end != '/')) + return 0; + return id >= 1 && id <= 255 && id != 15 ? (int)id : 0; +} + +int twcc_read_sequence(const uint8_t* packet, size_t size, uint8_t extension_id, uint16_t* sequence) { + if (!packet || extension_id == 0 || size < 12 || (packet[0] & 0xC0) != 0x80 || !(packet[0] & 0x10)) + return 0; + size_t pos = 12 + 4 * (size_t)(packet[0] & 0x0F); + if (pos + 4 > size) + return 0; + const uint16_t profile = twcc_read_u16(packet + pos); + const size_t end = pos + 4 + 4 * (size_t)twcc_read_u16(packet + pos + 2); + pos += 4; + if (end > size) + return 0; + + const int one_byte = profile == 0xBEDE; + if (!one_byte && (profile & 0xFFF0) != 0x1000) + return 0; + while (pos < end) { + if (packet[pos] == 0) { + pos++; + continue; + } + uint8_t id; + size_t length; + if (one_byte) { + id = packet[pos] >> 4; + length = (size_t)(packet[pos] & 0x0F) + 1; + if (id == 15) + return 0; + pos++; + } else { + if (pos + 2 > end) + return 0; + id = packet[pos]; + length = packet[pos + 1]; + pos += 2; + } + if (pos + length > end) + return 0; + if (id == extension_id) { + if (length != 2) + return 0; + *sequence = twcc_read_u16(packet + pos); + return 1; + } + pos += length; + } + return 0; +} + +void twcc_receiver_init(TwccReceiver* receiver, uint8_t extension_id) { + memset(receiver, 0, sizeof(*receiver)); + receiver->extension_id = extension_id; +} + +int twcc_receiver_record(TwccReceiver* receiver, const uint8_t* packet, size_t size, uint64_t arrival_us) { + uint16_t sequence; + if (!twcc_read_sequence(packet, size, receiver->extension_id, &sequence)) + return 0; + + if (!receiver->has_packets) { + receiver->next_sequence = (int64_t)sequence + 0x10000; + receiver->max_sequence = receiver->next_sequence - 1; + receiver->has_packets = 1; + } + const int64_t unwrapped = + receiver->max_sequence + (int16_t)(uint16_t)(sequence - (uint16_t)receiver->max_sequence); + if (unwrapped < receiver->next_sequence) { + receiver->late_packets++; + return 0; + } + if (unwrapped > receiver->max_sequence) { + int64_t clear_from = receiver->max_sequence + 1; + if (unwrapped - clear_from >= TWCC_HISTORY_SIZE) + clear_from = unwrapped - TWCC_HISTORY_SIZE + 1; + for (int64_t s = clear_from; s <= unwrapped; s++) + receiver->received[s & TWCC_HISTORY_MASK] = 0; + receiver->max_sequence = unwrapped; + if (receiver->max_sequence - receiver->next_sequence >= TWCC_HISTORY_SIZE) + receiver->next_sequence = receiver->max_sequence - TWCC_HISTORY_SIZE + 1; + } + + const size_t slot = (size_t)(unwrapped & TWCC_HISTORY_MASK); + if (receiver->received[slot]) + return 0; + receiver->received[slot] = 1; + receiver->arrival_us[slot] = arrival_us; + receiver->media_ssrc = twcc_read_u32(packet + 8); + receiver->packets_recorded++; + return 1; +} + +int twcc_receiver_feedback_due(const TwccReceiver* receiver, uint64_t now_us) { + return receiver->has_packets && receiver->next_sequence <= receiver->max_sequence && + now_us - receiver->last_feedback_us >= TWCC_FEEDBACK_INTERVAL_US; +} + +static size_t twcc_padded_size(size_t status_count, size_t delta_size) { + const size_t size = TWCC_HEADER_SIZE + ((status_count + 6) / 7) * 2 + delta_size; + return (size + 3) & ~(size_t)3; +} + +int twcc_receiver_build_feedback(TwccReceiver* receiver, uint32_t sender_ssrc, + uint8_t* packet, size_t capacity) { + if (!receiver->has_packets || receiver->next_sequence > receiver->max_sequence) + return 0; + if (capacity > TWCC_MAX_FEEDBACK_SIZE) + capacity = TWCC_MAX_FEEDBACK_SIZE; + if (capacity < twcc_padded_size(1, 2)) + return -1; + + const int64_t base = receiver->next_sequence; + int64_t first_received = base; + while (!receiver->received[first_received & TWCC_HISTORY_MASK]) + first_received++; + const uint64_t reference = receiver->arrival_us[first_received & TWCC_HISTORY_MASK] / + TWCC_REFERENCE_TICK_US; + int64_t last_us = (int64_t)(reference * TWCC_REFERENCE_TICK_US); + + uint8_t symbols[TWCC_HISTORY_SIZE]; + uint8_t deltas[TWCC_MAX_FEEDBACK_SIZE]; + size_t count = 0; + size_t delta_size = 0; + for (int64_t s = base; s <= receiver->max_sequence; s++) { + const size_t slot = (size_t)(s & TWCC_HISTORY_MASK); + uint8_t symbol = 0; + int64_t ticks = 0; + if (receiver->received[slot]) { + const int64_t elapsed = (int64_t)receiver->arrival_us[slot] - last_us; + ticks = elapsed >= 0 ? (elapsed + TWCC_DELTA_TICK_US / 2) / TWCC_DELTA_TICK_US + : -((-elapsed + TWCC_DELTA_TICK_US / 2) / TWCC_DELTA_TICK_US); + if (ticks < INT16_MIN || ticks > INT16_MAX) + break; + symbol = ticks >= 0 && ticks <= 255 ? 1 : 2; + } + if (twcc_padded_size(count + 1, delta_size + symbol) > capacity) + break; + symbols[count++] = symbol; + if (symbol == 1) { + deltas[delta_size++] = (uint8_t)ticks; + } else if (symbol == 2) { + twcc_write_u16(deltas + delta_size, (uint16_t)(int16_t)ticks); + delta_size += 2; + } + if (symbol) + last_us += ticks * TWCC_DELTA_TICK_US; + } + + const size_t unpadded = TWCC_HEADER_SIZE + ((count + 6) / 7) * 2 + delta_size; + const size_t total = twcc_padded_size(count, delta_size); + const size_t padding = total - unpadded; + packet[0] = (uint8_t)(0x80 | (padding ? 0x20 : 0) | 15); + packet[1] = 205; + twcc_write_u16(packet + 2, (uint16_t)(total / 4 - 1)); + twcc_write_u32(packet + 4, sender_ssrc); + twcc_write_u32(packet + 8, receiver->media_ssrc); + twcc_write_u16(packet + 12, (uint16_t)base); + twcc_write_u16(packet + 14, (uint16_t)count); + packet[16] = (uint8_t)(reference >> 16); + packet[17] = (uint8_t)(reference >> 8); + packet[18] = (uint8_t)reference; + packet[19] = receiver->feedback_count++; + + size_t pos = TWCC_HEADER_SIZE; + for (size_t first = 0; first < count; first += 7) { + uint16_t chunk = 0xC000; + for (size_t i = 0; i < 7 && first + i < count; i++) + chunk |= (uint16_t)(symbols[first + i] << (12 - 2 * i)); + twcc_write_u16(packet + pos, chunk); + pos += 2; + } + memcpy(packet + pos, deltas, delta_size); + pos += delta_size; + if (padding) { + memset(packet + pos, 0, padding); + packet[total - 1] = (uint8_t)padding; + } + + receiver->next_sequence = base + (int64_t)count; + return (int)total; +} + +void twcc_receiver_feedback_sent(TwccReceiver* receiver, uint64_t now_us) { + receiver->last_feedback_us = now_us; + receiver->feedback_sent++; +} diff --git a/extern/libpeer/src/twcc.h b/extern/libpeer/src/twcc.h new file mode 100644 index 00000000..e4b731a6 --- /dev/null +++ b/extern/libpeer/src/twcc.h @@ -0,0 +1,36 @@ +#ifndef TWCC_H_ +#define TWCC_H_ + +#include +#include + +#define TWCC_EXTENSION_URI "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01" +#define TWCC_HISTORY_SIZE 2048 +#define TWCC_FEEDBACK_INTERVAL_US 50000 +#define TWCC_MAX_FEEDBACK_SIZE 1200 + +typedef struct TwccReceiver { + uint8_t extension_id; + uint8_t feedback_count; + uint8_t has_packets; + uint32_t media_ssrc; + int64_t next_sequence; + int64_t max_sequence; + uint64_t last_feedback_us; + uint32_t packets_recorded; + uint32_t late_packets; + uint32_t feedback_sent; + uint64_t arrival_us[TWCC_HISTORY_SIZE]; + uint8_t received[TWCC_HISTORY_SIZE]; +} TwccReceiver; + +int twcc_parse_extension_id(const char* sdp_line); +int twcc_read_sequence(const uint8_t* packet, size_t size, uint8_t extension_id, uint16_t* sequence); +void twcc_receiver_init(TwccReceiver* receiver, uint8_t extension_id); +int twcc_receiver_record(TwccReceiver* receiver, const uint8_t* packet, size_t size, uint64_t arrival_us); +int twcc_receiver_feedback_due(const TwccReceiver* receiver, uint64_t now_us); +int twcc_receiver_build_feedback(TwccReceiver* receiver, uint32_t sender_ssrc, + uint8_t* packet, size_t capacity); +void twcc_receiver_feedback_sent(TwccReceiver* receiver, uint64_t now_us); + +#endif // TWCC_H_ diff --git a/extern/libpeer/third_party/usrsctp/usrsctplib/netinet/sctp_crc32.c b/extern/libpeer/third_party/usrsctp/usrsctplib/netinet/sctp_crc32.c index 51b4e49d..94345490 100644 --- a/extern/libpeer/third_party/usrsctp/usrsctplib/netinet/sctp_crc32.c +++ b/extern/libpeer/third_party/usrsctp/usrsctplib/netinet/sctp_crc32.c @@ -56,6 +56,16 @@ __FBSDID("$FreeBSD$"); #include #endif +#if !defined(BYTE_ORDER) && defined(__BYTE_ORDER__) +#define BYTE_ORDER __BYTE_ORDER__ +#endif +#if !defined(BIG_ENDIAN) && defined(__ORDER_BIG_ENDIAN__) +#define BIG_ENDIAN __ORDER_BIG_ENDIAN__ +#endif +#if !defined(BYTE_ORDER) || !defined(BIG_ENDIAN) +#error "SCTP CRC32c requires BYTE_ORDER and BIG_ENDIAN" +#endif + #if !(defined(__FreeBSD__) && !defined(__Userspace__)) /** * diff --git a/resources/font/IBMPlexMono-Medium.ttf b/resources/font/IBMPlexMono-Medium.ttf new file mode 100644 index 00000000..33c546f6 Binary files /dev/null and b/resources/font/IBMPlexMono-Medium.ttf differ diff --git a/resources/font/IBMPlexMono-OFL.txt b/resources/font/IBMPlexMono-OFL.txt new file mode 100644 index 00000000..c35c4c61 --- /dev/null +++ b/resources/font/IBMPlexMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/font/Nunito-Bold.ttf b/resources/font/Nunito-Bold.ttf new file mode 100644 index 00000000..79e5401d Binary files /dev/null and b/resources/font/Nunito-Bold.ttf differ diff --git a/resources/font/Nunito-ExtraBold.ttf b/resources/font/Nunito-ExtraBold.ttf new file mode 100644 index 00000000..5cc5ddfb Binary files /dev/null and b/resources/font/Nunito-ExtraBold.ttf differ diff --git a/resources/font/Nunito-Medium.ttf b/resources/font/Nunito-Medium.ttf new file mode 100644 index 00000000..161b99b6 Binary files /dev/null and b/resources/font/Nunito-Medium.ttf differ diff --git a/resources/font/Nunito-OFL.txt b/resources/font/Nunito-OFL.txt new file mode 100644 index 00000000..c8210f08 --- /dev/null +++ b/resources/font/Nunito-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/font/Nunito-SemiBold.ttf b/resources/font/Nunito-SemiBold.ttf new file mode 100644 index 00000000..395f2538 Binary files /dev/null and b/resources/font/Nunito-SemiBold.ttf differ diff --git a/resources/font/OpenNOW-CJK-OFL.txt b/resources/font/OpenNOW-CJK-OFL.txt new file mode 100644 index 00000000..1c9f4328 --- /dev/null +++ b/resources/font/OpenNOW-CJK-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/font/OpenNOW-CJK.ttf b/resources/font/OpenNOW-CJK.ttf new file mode 100644 index 00000000..1803e512 Binary files /dev/null and b/resources/font/OpenNOW-CJK.ttf differ diff --git a/resources/font/README.md b/resources/font/README.md new file mode 100644 index 00000000..dca02422 --- /dev/null +++ b/resources/font/README.md @@ -0,0 +1,40 @@ +# OpenNOW UI fonts + +The client bundles static Nunito Medium, SemiBold, Bold and ExtraBold faces at +weights 500, 600, 700 and 800. NanoVG does not select variable-font weight axes, +so the original variable font must not replace these static files. IBM Plex Mono +Medium is used for tabular stream diagnostics. + +OpenNOW-CJK is a weight-500 Noto Sans SC derivative containing the GB2312 +repertoire. It supplies common Simplified Chinese glyphs without depending on +fonts installed on a development machine. Each application font also receives +direct Switch shared-font fallbacks for additional Chinese, Korean and controller +symbols. The inherited Switch fonts remain available as fallbacks. + +The three OFL files in this directory cover the bundled assets. The Nunito and +IBM Plex inputs came from OpenCloudGaming/OpenNOW commit +`6e00a91dcaac53a9510f2ebf31d0e812f5b8e615`, under +`opennow-qt/res/fonts`. The Noto Sans SC input came from the Google Fonts +`ofl/notosanssc` directory. Input SHA-256 values are: + +| Input | SHA-256 | +| --- | --- | +| Nunito-Variable.ttf | `bb55a5ca5c2042335b3991af27c4d0705d0ef41cac6164ac737fd8f2a1e85207` | +| IBMPlexMono-Medium.ttf | `a9b4c49bb299e05b5f6c481e7fb5e78943d2793249a0c8874ab574a2d1ea6755` | +| NotoSansSC variable | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` | + +Normal Switch builds use the committed static assets. To regenerate them, +install FontTools in a separate Python environment and run: + +```sh +python scripts/build-ui-fonts.py \ + --nunito /path/to/Nunito-Variable.ttf \ + --mono /path/to/IBMPlexMono-Medium.ttf \ + --cjk /path/to/NotoSansSC-Variable.ttf \ + --output resources/font +python tests/ui_font_assets_test.py +``` + +The generator preserves font copyright and license records and renames the +subset CJK family. Runtime registration fails explicitly if a required bundled +font cannot be loaded. CMake copies this directory into the NRO's RomFS. diff --git a/resources/img/opennow-logo-mark-small.png b/resources/img/opennow-logo-mark-small.png new file mode 100644 index 00000000..9c2324d2 Binary files /dev/null and b/resources/img/opennow-logo-mark-small.png differ diff --git a/resources/img/opennow-logo-mark.png b/resources/img/opennow-logo-mark.png index ba6c1f01..c02da4af 100644 Binary files a/resources/img/opennow-logo-mark.png and b/resources/img/opennow-logo-mark.png differ diff --git a/scripts/build-ui-fonts.py b/scripts/build-ui-fonts.py new file mode 100644 index 00000000..3ac375e4 --- /dev/null +++ b/scripts/build-ui-fonts.py @@ -0,0 +1,58 @@ +import argparse +from pathlib import Path +import shutil + +from fontTools.ttLib import TTFont +from fontTools.varLib.instancer import instantiateVariableFont +from fontTools import subset + + +def build_fonts(source, mono, cjk, output): + output.mkdir(parents=True, exist_ok=True) + for weight, style in ((500, "Medium"), (600, "SemiBold"), (700, "Bold"), (800, "ExtraBold")): + variable = TTFont(source, recalcTimestamp=False) + font = instantiateVariableFont(variable, {"wght": weight}, inplace=True) + names = { + 1: "Nunito", + 2: style, + 4: f"Nunito {style}", + 6: f"Nunito-{style}", + 16: "Nunito", + 17: style, + } + for record in font["name"].names: + if record.nameID in names: + font["name"].setName(names[record.nameID], record.nameID, + record.platformID, record.platEncID, record.langID) + font["OS/2"].usWeightClass = weight + font.save(output / f"Nunito-{style}.ttf") + shutil.copyfile(mono, output / "IBMPlexMono-Medium.ttf") + font = instantiateVariableFont(TTFont(cjk, recalcTimestamp=False), {"wght": 500}, inplace=True) + characters = set(range(32, 127)) + for lead in range(0xA1, 0xF8): + for trail in range(0xA1, 0xFF): + try: + characters.add(ord(bytes((lead, trail)).decode("gb2312"))) + except UnicodeDecodeError: + pass + options = subset.Options() + options.name_IDs = [0, 1, 2, 3, 4, 5, 6, 13, 14, 16, 17] + options.name_languages = ["*"] + subsetter = subset.Subsetter(options=options) + subsetter.populate(unicodes=characters) + subsetter.subset(font) + for record in font["name"].names: + if record.nameID in (1, 4, 6, 16): + font["name"].setName("OpenNOWCJK" if record.nameID == 6 else "OpenNOW CJK", record.nameID, + record.platformID, record.platEncID, record.langID) + font.save(output / "OpenNOW-CJK.ttf") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Build static OpenNOW UI fonts for NanoVG.") + parser.add_argument("--nunito", type=Path, required=True) + parser.add_argument("--mono", type=Path, required=True) + parser.add_argument("--cjk", type=Path, required=True) + parser.add_argument("--output", type=Path, default=Path("resources/font")) + args = parser.parse_args() + build_fonts(args.nunito, args.mono, args.cjk, args.output) diff --git a/scripts/build-ui-logo.py b/scripts/build-ui-logo.py new file mode 100644 index 00000000..9ee4901d --- /dev/null +++ b/scripts/build-ui-logo.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from PIL import Image + + +root = Path(__file__).resolve().parents[1] +source = root / "resources/img/opennow-logo-mark.png" +target = root / "resources/img/opennow-logo-mark-small.png" +with Image.open(source) as image: + height = round(image.height * 128 / image.width) + image.convert("RGBA").resize((128, height), Image.Resampling.LANCZOS).save( + target, optimize=True + ) +print(f"Generated {target.relative_to(root)}: 128x{height}") diff --git a/scripts/test-host.sh b/scripts/test-host.sh index 94a30ce9..aa9ddb68 100644 --- a/scripts/test-host.sh +++ b/scripts/test-host.sh @@ -2,6 +2,7 @@ set -euo pipefail cd "$(dirname "$0")/.." +python3 tests/ui_font_assets_test.py bash scripts/test-streaming-host.sh out="$(mktemp -d)" @@ -22,9 +23,9 @@ run_cpp() { for name in app_launch_mode_policy atomic_file_replace auth_policy catalog_paging_policy cloud_launch_state cover_image_worker \ community_proxy_policy device_identity_policy game_detail_policy \ - game_grid_navigation home_shortcut_policy library_sort membership_label \ + game_grid_navigation home_shortcut_policy library_sort library_timetable_policy membership_label \ membership_tier_policy nro_shortcut_policy remote_candidate_policy \ - server_location_policy session_error_policy startup_callback_policy subscription_display \ + server_location_policy session_error_policy settings_tab_equality startup_callback_policy subscription_display \ ui_refresh_policy ui_text_policy; do run_cpp "$name" done @@ -34,6 +35,7 @@ run_cpp localization app/src/localization.cpp run_cpp nte_credentials app/src/nte_credentials.cpp run_cpp play_history_policy app/src/play_history.cpp -ljansson run_cpp stream_settings_persistence app/src/stream_settings.cpp app/src/localization.cpp -ljansson +run_cpp community_proxy_retirement app/src/stream_settings.cpp app/src/localization.cpp -ljansson run_cpp queue_notify_settings app/src/stream_settings.cpp app/src/localization.cpp -ljansson run_cpp app_state_session_generation app/src/app_state.cpp run_cpp auth_client_token_refresh -ffunction-sections -fdata-sections -Wl,--gc-sections \ diff --git a/scripts/test-streaming-host.sh b/scripts/test-streaming-host.sh index 8dbd6fd8..d0bfb8ff 100644 --- a/scripts/test-streaming-host.sh +++ b/scripts/test-streaming-host.sh @@ -42,6 +42,7 @@ for name in audio_latency_policy audio_playback_timeline audio_rtp_utils control done run_cpp network_monitor app/src/network_monitor.cpp app/src/network_utils.cpp +run_cpp stream_network_evidence run_cpp diagnostic_writer app/src/webrtc/diagnostic_writer.cpp crypto=() for name in sha1 base64 platform_util constant_time; do @@ -61,12 +62,18 @@ run_cpp webrtc_input_handshake -Iextern/libpeer/src \ -Iextern/borealis/library/include/borealis/extern/nanovg "${sections[@]}" \ app/src/webrtc/input.cpp -ljansson run_cpp av_frame_queue -Itests/stream_stubs app/src/stream/ffmpeg/AVFrameHolder.cpp -lavcodec -lavutil +bash tests/run_webrtc_decode_output_accounting_test.sh +printf 'PASS webrtc_decode_output_accounting\n' run_cpp gpu_frame_queue -lavutil python3 tests/run_deko_renderer_reconfiguration_test.py printf 'PASS deko_renderer_reconfiguration\n' python3 tests/run_deko_renderer_reconfiguration_test.py deko_renderer_color_range_test.cpp printf 'PASS deko_renderer_color_range\n' +python3 tests/run_deko_renderer_reconfiguration_test.py deko_renderer_quality_snapshot_test.cpp +printf 'PASS deko_renderer_quality_snapshot\n' run_cpp audio_pipeline -Itests/stream_stubs -Iextern/libpeer/src app/src/stream/audio/AudioPipeline.cpp +bash tests/run_audio_video_underrun_test.sh +printf 'PASS audio_video_underrun\n' for scenario in timeline ssrc red; do "$out/audio_pipeline" "$scenario" printf 'PASS audio_pipeline/%s\n' "$scenario" @@ -95,18 +102,37 @@ run_c rtp_h264_assembly extern/libpeer/src/rtp.c run_c rtp_reorder extern/libpeer/src/rtp.c run_c rtcp_nack extern/libpeer/src/rtcp.c run_c rtcp_receiver_report extern/libpeer/src/rtcp.c +run_c twcc_feedback extern/libpeer/src/twcc.c run_c sctp_socket_address -Iextern/libpeer/third_party/usrsctp/usrsctplib +run_c sdp_sctp_message_limit extern/libpeer/src/sdp.c +run_c peer_diagnostic_callback "${sections[@]}" -Wno-unused-but-set-variable \ + -Iextern/libpeer/third_party/mbedtls/include \ + -Iextern/libpeer/third_party/usrsctp/usrsctplib -pthread run_c peer_rtcp_receive "${sections[@]}" -Wno-unused-but-set-variable \ - -Iextern/libpeer/third_party/mbedtls/include extern/libpeer/src/rtcp.c + -Iextern/libpeer/third_party/mbedtls/include extern/libpeer/src/rtcp.c extern/libpeer/src/twcc.c run_c dtls_nonblocking_read "${sections[@]}" -Wno-unused-parameter -Wno-empty-body \ -Iextern/libpeer/third_party/mbedtls/include extern/libpeer/src/dtls_srtp.c run_c peer_dtls_receive "${sections[@]}" -Wno-unused-but-set-variable \ -Iextern/libpeer/third_party/mbedtls/include run_c peer_dtls_loop "${sections[@]}" -Wno-unused-but-set-variable \ - -Iextern/libpeer/third_party/mbedtls/include extern/libpeer/src/rtcp.c + -Iextern/libpeer/third_party/mbedtls/include extern/libpeer/src/rtcp.c extern/libpeer/src/twcc.c run_c agent_socket_poll "${sections[@]}" +stun_deps=() +for source in extern/libpeer/src/{stun,utils,address}.c \ + extern/libpeer/third_party/mbedtls/library/{md,md5,sha1,sha256,sha512,ripemd160,platform_util,constant_time}.c; do + object="$out/stun_dep_$(basename "$source" .c).o" + "$cc" "${cflags[@]}" -w -Iextern/libpeer/third_party/mbedtls/include "${sections[@]}" -c "$source" -o "$object" + stun_deps+=("$object") +done +run_c agent_consent "${sections[@]}" -Iextern/libpeer/third_party/mbedtls/include "${stun_deps[@]}" "$cc" "${cflags[@]}" -Iextern/libpeer/third_party/mbedtls/include \ -c extern/libpeer/src/peer.c -o "$out/peer_runtime.o" run_cpp peer_runtime -Iextern/libpeer/src "$out/peer_runtime.o" -lsrtp2 +SCTP_TEST_SANITIZERS="${OPENNOW_SANITIZERS:-}" bash tests/run_usrsctp_crc32c_test.sh +printf 'PASS usrsctp_crc32c\n' SCTP_TEST_SANITIZERS="${OPENNOW_SANITIZERS:-}" bash tests/run_sctp_reliability_test.sh printf 'PASS sctp_reliability\n' +SCTP_TEST_SANITIZERS="${OPENNOW_SANITIZERS:-}" bash tests/run_sctp_reliability_test.sh sctp_setup_diagnostic_test.c +printf 'PASS sctp_setup_diagnostic\n' +SCTP_TEST_SANITIZERS="${OPENNOW_SANITIZERS:-}" bash tests/run_sctp_reliability_test.sh sctp_diagnostic_connect_test.c +printf 'PASS sctp_diagnostic_connect\n' diff --git a/scripts/verify-nro-assets.py b/scripts/verify-nro-assets.py new file mode 100644 index 00000000..dbbe39e7 --- /dev/null +++ b/scripts/verify-nro-assets.py @@ -0,0 +1,59 @@ +import argparse +from pathlib import Path +import struct + + +def verify(nro_path, resources): + data = nro_path.read_bytes() + if data[0x10:0x14] != b"NRO0": + raise ValueError("Not a Nintendo Switch NRO") + executable_size = struct.unpack_from(" + +#include "../extern/libpeer/src/agent.c" + +static uint32_t now_ms; +static int send_calls; +static int send_result; +static uint8_t sent[1400]; +static int sent_size; + +uint32_t ports_get_epoch_time(void) { + return now_ms; +} + +int udp_socket_sendto(UdpSocket* socket, Address* addr, const uint8_t* buf, int len) { + assert(socket->fd == 3); + assert(addr->port == 5004); + assert(len <= (int)sizeof(sent)); + send_calls++; + memcpy(sent, buf, len); + sent_size = len; + return send_result < 0 ? send_result : len; +} + +static StunMessage parse_sent(void) { + StunMessage msg; + memset(&msg, 0, sizeof(msg)); + memcpy(msg.buf, sent, sent_size); + msg.size = sent_size; + stun_parse_msg_buf(&msg); + return msg; +} + +static int has_attribute(uint16_t wanted) { + size_t offset = sizeof(StunHeader); + while (offset + sizeof(StunAttribute) <= (size_t)sent_size) { + StunAttribute* attr = (StunAttribute*)(sent + offset); + if (ntohs(attr->type) == wanted) + return 1; + offset += sizeof(StunAttribute) + ((ntohs(attr->length) + 3) & ~3u); + } + return 0; +} + +static void respond_to_last_request(Agent* agent) { + StunMessage request = parse_sent(); + StunMessage response; + memset(&response, 0, sizeof(response)); + stun_msg_create(&response, STUN_CLASS_RESPONSE | STUN_METHOD_BINDING); + memcpy(((StunHeader*)response.buf)->transaction_id, + ((StunHeader*)request.buf)->transaction_id, + sizeof(((StunHeader*)request.buf)->transaction_id)); + stun_msg_finish(&response, STUN_CREDENTIAL_SHORT_TERM, agent->remote_upwd, + strlen(agent->remote_upwd)); + stun_parse_msg_buf(&response); + agent_process_stun_response(agent, &response); +} + +int main(void) { + Agent agent = {0}; + IceCandidate remote = {0}; + IceCandidatePair pair = {0}; + uint32_t first_transaction[3]; + + agent.udp_sockets[0].fd = 3; + agent.udp_sockets[1].fd = -1; + agent.mode = AGENT_MODE_CONTROLLING; + strcpy(agent.local_ufrag, "loc1"); + strcpy(agent.local_upwd, "local-password-0123456789"); + strcpy(agent.remote_ufrag, "2efecf37"); + strcpy(agent.remote_upwd, "26b335b899a84ffab9aaf38ddad1e2b4"); + remote.addr.family = AF_INET; + remote.addr.port = 5004; + pair.remote = &remote; + pair.priority = 0x6e7f1eff; + pair.state = ICE_CANDIDATE_STATE_SUCCEEDED; + + assert(agent_maybe_send_consent_request(&agent, 0) == 0); + assert(send_calls == 0); + + agent.nominated_pair = &pair; + agent.selected_pair = &pair; + now_ms = 1000; + assert(agent_maybe_send_consent_request(&agent, now_ms) == 1); + assert(send_calls == 1); + StunMessage first = parse_sent(); + assert(first.stunclass == STUN_CLASS_REQUEST); + assert(first.stunmethod == STUN_METHOD_BINDING); + assert(stun_msg_is_valid(sent, sent_size, agent.remote_upwd) == 0); + assert(has_attribute(STUN_ATTR_TYPE_ICE_CONTROLLING)); + assert(has_attribute(STUN_ATTR_TYPE_PRIORITY)); + assert(!has_attribute(STUN_ATTR_TYPE_USE_CANDIDATE)); + memcpy(first_transaction, ((StunHeader*)sent)->transaction_id, sizeof(first_transaction)); + + now_ms = 1000 + CONFIG_ICE_CONSENT_INTERVAL_MS - 1; + assert(agent_maybe_send_consent_request(&agent, now_ms) == 0); + assert(send_calls == 1); + + now_ms = 1040; + respond_to_last_request(&agent); + assert(agent.binding_responses_received == 1); + assert(agent.smoothed_binding_rtt_ms == 40); + assert(agent.binding_request_time == 1040); + + now_ms = 1000 + CONFIG_ICE_CONSENT_INTERVAL_MS; + assert(agent_maybe_send_consent_request(&agent, now_ms) == 1); + assert(send_calls == 2); + assert(stun_msg_is_valid(sent, sent_size, agent.remote_upwd) == 0); + assert(memcmp(first_transaction, ((StunHeader*)sent)->transaction_id, + sizeof(first_transaction)) != 0); + + send_result = -1; + now_ms += CONFIG_ICE_CONSENT_INTERVAL_MS; + assert(agent_maybe_send_consent_request(&agent, now_ms) == -1); + assert(agent_maybe_send_consent_request(&agent, now_ms + 1) == 0); + assert(send_calls == 3); + assert(agent.consent_attempts == 3 && agent.consent_requests_sent == 2); + + send_result = 0; + now_ms += CONFIG_ICE_CONSENT_INTERVAL_MS; + assert(agent_maybe_send_consent_request(&agent, now_ms) == 1); + assert(agent.consent_requests_sent == 3); + + agent.mode = AGENT_MODE_CONTROLLED; + now_ms += CONFIG_ICE_CONSENT_INTERVAL_MS; + assert(agent_maybe_send_consent_request(&agent, now_ms) == 1); + assert(has_attribute(STUN_ATTR_TYPE_ICE_CONTROLLED)); + assert(!has_attribute(STUN_ATTR_TYPE_USE_CANDIDATE)); + + agent_clear_candidates(&agent); + assert(agent.consent_attempts == 0 && agent.consent_requests_sent == 0); + assert(agent.binding_responses_received == 0); + assert(agent_maybe_send_consent_request(&agent, now_ms) == 0); + return 0; +} diff --git a/tests/audio_video_underrun_test.cpp b/tests/audio_video_underrun_test.cpp new file mode 100644 index 00000000..b4a0e061 --- /dev/null +++ b/tests/audio_video_underrun_test.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include "webrtc_session.hpp" +#undef private +#include "stream/ffmpeg/AVFrameHolder.hpp" + +#define main existing_audio_pipeline_test_main +#include "audio_pipeline_test.cpp" +#undef main + +extern "C" { +int peer_init() { return 0; } +void peer_deinit() {} +} + +WebRtcSession::WebRtcSession(const std::string&, const std::string&, const std::string&, + const std::string&, int, const std::vector&, + const opennow::StreamSettings&) {} +WebRtcSession::~WebRtcSession() = default; +SignalingClient::~SignalingClient() = default; +WebSocketClient::~WebSocketClient() = default; +FFmpegVideoDecoder::~FFmpegVideoDecoder() = default; +int FFmpegVideoDecoder::setup(int, int, int, int, void*, int) { return 0; } +void FFmpegVideoDecoder::cleanup() {} +int FFmpegVideoDecoder::submit_decode_unit(uint8_t*, int, int64_t) { return 0; } +int FFmpegVideoDecoder::capabilities() const { return 0; } +VideoDecodeStats* FFmpegVideoDecoder::video_decode_stats() { return nullptr; } + +namespace { + +template +void wait_until(Predicate predicate) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!predicate()) { + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +void send_audio(AudioPipeline& pipeline, uint32_t ssrc, uint16_t sequence, uint32_t timestamp) +{ + uint8_t payload[] = {0xf0, static_cast(sequence)}; + PeerAudioPacket packet {}; + packet.data = payload; + packet.size = sizeof(payload); + packet.timestamp = timestamp; + packet.ssrc = ssrc; + packet.sequence = sequence; + packet.payload_type = 111; + pipeline.submit(packet); +} + +void push_video(AVFrameQueue& frames, int64_t pts) +{ + AVFrame* frame = av_frame_alloc(); + assert(frame); + frame->format = AV_PIX_FMT_YUV420P; + frame->width = 16; + frame->height = 16; + frame->pts = pts; + assert(av_frame_get_buffer(frame, 0) == 0); + frames.push(frame); + av_frame_free(&frame); + assert(frames.size() <= 3); +} + +void check_video_progress(WebRtcSession& session, AVFrameQueue& frames, int64_t& pts) +{ + int presented = 0; + const size_t holds_before = frames.getTimingHoldStat(); + const size_t drops_before = frames.getOverflowDropStat(); + for (int i = 0; i < 180; ++i) { + pts += 1500; + push_video(frames, pts); + bool reused = false; + uint64_t generation = 0; + const auto* frame = frames.pop(reused, generation, session.video_target_rtp_timestamp()); + assert(frame); + if (!reused) { + assert(frame->pts == pts); + ++presented; + } + } + std::printf("Audio unavailable: new_video_frames=180 presented=%d held=%zu overflow_drops=%zu\n", + presented, frames.getTimingHoldStat() - holds_before, + frames.getOverflowDropStat() - drops_before); + std::fflush(stdout); + assert(presented == 180); + assert(frames.getTimingHoldStat() == holds_before); + assert(frames.getOverflowDropStat() == drops_before); + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); +} + +void drain_audio(AudioPipeline& pipeline, int underrun) +{ + { + std::lock_guard lock(mutex); + assert(output.size() <= 5); + release_output = true; + } + await([underrun] { return stops == underrun; }); + wait_until([&] { return !pipeline.is_playing(); }); + { + std::lock_guard lock(mutex); + assert(output.empty()); + release_output = false; + } +} + +} + +int main() +{ + WebRtcSession session("", "", "", "", 0, {}, {}); + session.audio_ = std::make_unique(); + auto& audio = *session.audio_; + audio.configure(1200, 30); + session.have_video_sender_report_ = true; + session.video_sr_ntp_us_ = 1000000; + session.video_sr_rtp_timestamp_ = 90000; + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + assert(audio.start()); + send_audio(audio, 1, 0, 0); + send_audio(audio, 1, 1, 480); + audio.set_sender_report(1, 1000000, 0); + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + { + std::lock_guard lock(mutex); + assert(starts == 0); + } + send_audio(audio, 1, 2, 960); + await([] { return starts == 1; }); + wait_until([&] { return session.video_target_rtp_timestamp() != AV_NOPTS_VALUE; }); + + AVFrameQueue frames; + int64_t pts = 92700; + push_video(frames, pts); + bool reused = false; + uint64_t generation = 0; + assert(frames.pop(reused, generation)->pts == pts && !reused); + drain_audio(audio, 1); + assert(audio.playback_ntp_us() == 1030000); + check_video_progress(session, frames, pts); + + send_audio(audio, 1, 3, 145440); + send_audio(audio, 1, 4, 145920); + await([] { return output.size() == 2; }); + { + std::lock_guard lock(mutex); + assert(starts == 1); + } + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + send_audio(audio, 1, 5, 146400); + await([] { return starts == 2; }); + wait_until([&] { return session.video_target_rtp_timestamp() != AV_NOPTS_VALUE; }); + const auto resumed_target = session.video_target_rtp_timestamp(); + assert(resumed_target >= pts && resumed_target <= pts + 2700); + push_video(frames, pts + 90000); + assert(frames.pop(reused, generation, resumed_target)->pts == pts && reused); + assert(frames.size() == 1); + frames.cleanup(); + push_video(frames, pts); + assert(frames.pop(reused, generation)->pts == pts && !reused); + + send_audio(audio, 2, 0, 0); + send_audio(audio, 2, 1, 480); + audio.set_sender_report(2, 6000000, 0); + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + check_video_progress(session, frames, pts); + send_audio(audio, 2, 2, 960); + wait_until([] { + std::lock_guard lock(mutex); + return decoded_sequences.size() == 9 && reclaim_queries >= 1; + }); + { + std::lock_guard lock(mutex); + assert(output.size() == 5); + } + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + drain_audio(audio, 2); + assert(audio.playback_ntp_us() == 6020000); + check_video_progress(session, frames, pts); + + send_audio(audio, 2, 3, 146880); + send_audio(audio, 2, 4, 147360); + await([] { return output.size() == 2; }); + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + send_audio(audio, 2, 5, 147840); + await([] { return starts == 3; }); + wait_until([&] { return session.video_target_rtp_timestamp() != AV_NOPTS_VALUE; }); + assert(audio.playback_ntp_us() >= 9060000); + audio.stop(); + assert(session.video_target_rtp_timestamp() == AV_NOPTS_VALUE); + check_video_progress(session, frames, pts); + std::puts("Audio underrun, video progress and synchronized resume: PASS"); +} diff --git a/tests/cloud_session_protocol_test.cpp b/tests/cloud_session_protocol_test.cpp index 9f201cb0..1229d26c 100644 --- a/tests/cloud_session_protocol_test.cpp +++ b/tests/cloud_session_protocol_test.cpp @@ -28,7 +28,7 @@ int main() assert(json_integer_value(json_object_get(features, "maxBitrateKbps")) == 12000); assert(json_integer_value(json_object_get(features, "codec")) == 1); assert(json_is_false(json_object_get(features, "vsync"))); - assert(json_integer_value(json_object_get(features, "dynamicStreamingMode")) == 3); + assert(json_object_get(features, "dynamicStreamingMode") == nullptr); assert(json_integer_value(json_object_get(features, "audioChannelCount")) == 2); return 0; } diff --git a/tests/community_proxy_retirement_test.cpp b/tests/community_proxy_retirement_test.cpp new file mode 100644 index 00000000..66c0c06c --- /dev/null +++ b/tests/community_proxy_retirement_test.cpp @@ -0,0 +1,45 @@ +#include "app_paths.hpp" +#include "stream_settings.hpp" + +#include +#include +#include +#include +#include + +namespace +{ +std::string home; +} + +namespace opennow +{ +const std::string& AppHomePath() { return home; } +void PrepareAppStorage() { std::filesystem::create_directories(home); } +} + +int main() +{ + std::string pattern = (std::filesystem::temp_directory_path() / "opennow-retired-proxy-XXXXXX").string(); + char* directory = mkdtemp(pattern.data()); + assert(directory); + home = directory; + for (const std::string suffix : {"", ".bak"}) + { + std::filesystem::remove(home + "/stream_settings.json"); + std::ofstream file(home + "/stream_settings.json" + suffix); + file << R"({"community_proxy_enabled":true,"community_proxy_url":"http://legacy-client:legacy-pass@opennow-proxy-tcp.zortos.me:3128","fps":30,"interface_language":"ru","queue_notify_threshold":20})"; + file.close(); + const auto settings = opennow::LoadStreamSettings(); + assert(!settings.community_proxy_enabled && settings.community_proxy_url.empty()); + assert(settings.fps == 30 && settings.interface_language == "ru" && settings.queue_notify_threshold == 20); + } + auto settings = opennow::LoadStreamSettings(); + settings.community_proxy_enabled = true; + settings.community_proxy_url = "http://legacy-client:legacy-pass@217.76.50.166:3128"; + assert(opennow::SaveStreamSettings(settings)); + const auto saved = opennow::LoadStreamSettings(); + assert(!saved.community_proxy_enabled && saved.community_proxy_url.empty()); + assert(saved.fps == 30 && saved.interface_language == "ru" && saved.queue_notify_threshold == 20); + std::filesystem::remove_all(home); +} diff --git a/tests/deko_renderer_color_range_test.cpp b/tests/deko_renderer_color_range_test.cpp index b1298d5a..8c249771 100644 --- a/tests/deko_renderer_color_range_test.cpp +++ b/tests/deko_renderer_color_range_test.cpp @@ -13,7 +13,7 @@ int main() frame->colorspace = AVCOL_SPC_BT709; assert(av_frame_get_buffer(frame, 32) == 0); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); for (const auto range : {AVCOL_RANGE_MPEG, AVCOL_RANGE_JPEG, AVCOL_RANGE_MPEG}) { frame->color_range = range; deko_test::completed = deko_test::submitted; diff --git a/tests/deko_renderer_quality_snapshot_test.cpp b/tests/deko_renderer_quality_snapshot_test.cpp new file mode 100644 index 00000000..8a83791b --- /dev/null +++ b/tests/deko_renderer_quality_snapshot_test.cpp @@ -0,0 +1,31 @@ +#include "deko_renderer_under_test.hpp" + +#include + +int main() +{ + for (const std::string mode : {"Original", "Clarity", "Adaptive"}) + { + deko_test::reset(); + AVFrame* frame = av_frame_alloc(); + assert(frame); + frame->format = AV_PIX_FMT_YUV420P; + frame->width = 64; + frame->height = 32; + assert(av_frame_get_buffer(frame, 32) == 0); + const auto expected = opennow::video::ResolveQualityTuning(mode); + { + DKVideoRenderer renderer(mode); + for (const int screen_width : {1280, 1920, 1280}) + { + deko_test::completed = deko_test::submitted; + renderer.drawLatest(nullptr, screen_width, screen_width * 9 / 16, frame, 0, 1); + assert(deko_test::last_transform[22] == expected.denoise_strength); + assert(deko_test::last_transform[23] == expected.sharpen_strength); + } + } + av_frame_free(&frame); + assert(deko_test::Memory::live.empty() && deko_test::descriptors.empty()); + } + std::cout << "Deko3D quality snapshot through screen reconfiguration: PASS\n"; +} diff --git a/tests/deko_renderer_reconfiguration_test.cpp b/tests/deko_renderer_reconfiguration_test.cpp index 1e76fcb9..c48e5a81 100644 --- a/tests/deko_renderer_reconfiguration_test.cpp +++ b/tests/deko_renderer_reconfiguration_test.cpp @@ -67,7 +67,7 @@ void failure_sweep(bool hardware) auto resized = hardware ? hardware_frame(32, 32) : software_frame(32, 32); int initialization_allocations = 0; { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, resized); initialization_allocations = allocation; assert(draws == 1); @@ -75,7 +75,7 @@ void failure_sweep(bool hardware) for (int failure = 1; failure <= initialization_allocations; ++failure) { reset(); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); fail_at = failure; draw(renderer, resized); assert(draws == 0 && submissions == 0); @@ -89,7 +89,7 @@ void failure_sweep(bool hardware) reset(); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, initial); const size_t active_memory = Memory::live.size(); fail_at = allocation + failure; @@ -115,7 +115,7 @@ void resize_retirement_and_screen() auto second = software_frame(32, 32, AV_PIX_FMT_NV12); auto third = software_frame(32, 64); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, first, 0); draw(renderer, first, 0); const auto first_fence = submitted; @@ -154,7 +154,7 @@ void backend_retirement() auto software = software_frame(64, 32); auto hardware = hardware_frame(64, 32); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, hardware, 1); const auto hardware_fence = submitted; hardware.reset(); @@ -184,7 +184,7 @@ void bounded_frame_updates() reset(); auto software = software_frame(64, 32); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, software, 1); const int attempts = allocation; for (uint64_t generation = 2; generation <= 30; ++generation) @@ -197,7 +197,7 @@ void bounded_frame_updates() } reset(); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); for (uint64_t generation = 1; generation <= 30; ++generation) { auto hardware = hardware_frame(64, 32); draw(renderer, hardware, generation); @@ -220,7 +220,7 @@ void allocation_retry() reset(); auto frame = software_frame(64, 32); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); fail_at = 3; draw(renderer, frame); assert(draws == 0); @@ -246,7 +246,7 @@ void hardware_mapping_allocation_exception() reset(); auto initial = hardware_frame(64, 32); { - DKVideoRenderer renderer; + DKVideoRenderer renderer("Adaptive"); draw(renderer, initial); auto next = hardware_frame(64, 32); fail_at = allocation + 1; diff --git a/tests/ffmpeg_decode_integration_test.cpp b/tests/ffmpeg_decode_integration_test.cpp index 02f9b889..10b5b350 100644 --- a/tests/ffmpeg_decode_integration_test.cpp +++ b/tests/ffmpeg_decode_integration_test.cpp @@ -65,7 +65,7 @@ static void decode_stream(FFmpegVideoDecoder& decoder, const char* path, int main(int argc, char** argv) { assert(argc == 3); - FFmpegVideoDecoder decoder; + FFmpegVideoDecoder decoder("Adaptive"); assert(decoder.setup(VIDEO_FORMAT_H264, 64, 32, 60, nullptr, VIDEO_DECODER_FORCE_SOFTWARE) == 0); assert(!decoder.uses_hardware_frames()); diff --git a/tests/ffmpeg_packet_ownership_test.cpp b/tests/ffmpeg_packet_ownership_test.cpp index 0167bc1e..f87ffb9c 100644 --- a/tests/ffmpeg_packet_ownership_test.cpp +++ b/tests/ffmpeg_packet_ownership_test.cpp @@ -60,7 +60,7 @@ int __wrap_avcodec_receive_frame(AVCodecContext*, AVFrame*) int main() { - FFmpegVideoDecoder decoder; + FFmpegVideoDecoder decoder("Adaptive"); assert(decoder.setup(VIDEO_FORMAT_H264, 16, 16, 60, nullptr, VIDEO_DECODER_FORCE_SOFTWARE) == 0); uint8_t input[] = {0x55}; diff --git a/tests/ffmpeg_video_decoder_test.cpp b/tests/ffmpeg_video_decoder_test.cpp index 2e12feab..1a467645 100644 --- a/tests/ffmpeg_video_decoder_test.cpp +++ b/tests/ffmpeg_video_decoder_test.cpp @@ -126,7 +126,7 @@ int main(int argc, char** argv) if (scenario == "eof") receive_error = AVERROR_EOF; - FFmpegVideoDecoder decoder; + FFmpegVideoDecoder decoder("Adaptive"); const int result = decoder.setup( VIDEO_FORMAT_H264, 16, 16, 60, nullptr, VIDEO_DECODER_FORCE_SOFTWARE); if (packet_allocation_failure || array_allocation_failure || frame_allocation_failure >= 0) { diff --git a/tests/library_timetable_policy_test.cpp b/tests/library_timetable_policy_test.cpp new file mode 100644 index 00000000..b6ca708a --- /dev/null +++ b/tests/library_timetable_policy_test.cpp @@ -0,0 +1,32 @@ +#include "library_timetable_policy.hpp" + +#include +#include + +int main() +{ + using namespace std::chrono; + using opennow::library::DateBucket; + using opennow::library::PresentTimestamp; + const auto now = sys_days{year{2026} / October / 2} + hours{12}; + assert(PresentTimestamp("2026-10-02T00:00:00Z", now).bucket == DateBucket::Today); + assert(PresentTimestamp("2026-10-02T11:59:59.999Z", now).label == "11:59"); + assert(PresentTimestamp("2026-10-01T23:59:59Z", now).bucket == DateBucket::ThisWeek); + assert(PresentTimestamp("2026-09-28T00:00:00Z", now).label == "Mon"); + assert(PresentTimestamp("2026-09-27T23:59:59Z", now).bucket == DateBucket::Earlier); + assert(PresentTimestamp("2025-10-02T09:15:00Z", now).label == "2025-10-02"); + assert(PresentTimestamp("", now).bucket == DateBucket::Unplayed); + for (const auto* text : {"2026-10-02T12:00:01Z", "2026-10-02T12:00:00.001Z", + "2026-02-29T09:00:00Z", "2026-13-01T00:00:00Z", "2026-04-31T00:00:00Z", + "2026-10-02T24:00:00Z", "2026-10-02T10:60:00Z", "2026-10-02T10:00:60Z", + "2026-10-02T10:00:00", "2026-10-02T10:00:00+00:00", "2026-10-02T10:00:00.Z", + "2026-10-02T10:00:00.1234567890Z", "2026-10-02T10:00:00.aZ", + "2026-10-02T10:00:00Zextra", "unknown", "0000-01-01T00:00:00Z"}) + assert(PresentTimestamp(text, now).bucket == DateBucket::Unknown); + assert(PresentTimestamp("2024-02-29T09:00:00Z", now).bucket == DateBucket::Earlier); + assert(PresentTimestamp("2026-10-02T12:00:00.000000000Z", now).bucket == DateBucket::Today); + const auto midnight = sys_days{year{2026} / October / 5}; + assert(PresentTimestamp("2026-10-04T23:59:59Z", midnight).bucket == DateBucket::Earlier); + assert(PresentTimestamp("2026-10-05T00:00:00Z", midnight).bucket == DateBucket::Today); + assert(PresentTimestamp("2026-10-05T00:00:00.1Z", midnight).bucket == DateBucket::Unknown); +} diff --git a/tests/nvst_sdp_test.cpp b/tests/nvst_sdp_test.cpp index b9f2800d..11995603 100644 --- a/tests/nvst_sdp_test.cpp +++ b/tests/nvst_sdp_test.cpp @@ -37,13 +37,16 @@ int main() for (const auto& line : { "a=video.maxFPS:60", - "a=video.initialBitrateKbps:4000", - "a=video.initialPeakBitrateKbps:4000", + "a=video.initialBitrateKbps:12000", + "a=video.initialPeakBitrateKbps:12000", "a=vqos.bw.maximumBitrateKbps:12000", "a=vqos.bw.minimumBitrateKbps:4000", - "a=vqos.dynamicStreamingMode:3", - "a=vqos.drc.enable:1", - "a=vqos.resControl.cpmRtc.featureMask:3", + "a=vqos.dynamicStreamingMode:0", + "a=vqos.drc.enable:0", + "a=vqos.resControl.cpmRtc.featureMask:0", + "a=vqos.resControl.cpmRtc.enable:0", + "a=vqos.resControl.cpmRtc.minResolutionPercent:100", + "a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999", }) { assert(HasLine(sdp, line)); } @@ -57,9 +60,6 @@ int main() "vqos.grc.enable:", "vqos.dfc.enable:", "vqos.dfc.adjustResAndFps:", - "vqos.resControl.cpmRtc.enable:", - "vqos.resControl.cpmRtc.minResolutionPercent:", - "vqos.resControl.cpmRtc.resolutionChangeHoldonMs:", }) { assert(!HasAttribute(sdp, attribute)); } @@ -67,7 +67,7 @@ int main() settings.bitrate_kbps = 20000; const std::string quality_sdp = opennow::webrtc::BuildNvstSdp( answer, settings, opennow::webrtc::RiInputCapabilities {}); - assert(HasLine(quality_sdp, "a=video.initialBitrateKbps:5000")); + assert(HasLine(quality_sdp, "a=video.initialBitrateKbps:20000")); assert(HasLine(quality_sdp, "a=vqos.bw.maximumBitrateKbps:20000")); return 0; } diff --git a/tests/peer_diagnostic_callback_test.c b/tests/peer_diagnostic_callback_test.c new file mode 100644 index 00000000..2251061c --- /dev/null +++ b/tests/peer_diagnostic_callback_test.c @@ -0,0 +1,46 @@ +#include "../extern/libpeer/src/peer_connection.c" +#include "../extern/libpeer/src/sctp.c" + +#include + +static unsigned calls; +static char recorded[2048]; + +static void record(const char* message) { + assert(strlen(message) < sizeof(recorded)); + strcpy(recorded, message); + calls++; + errno = ERANGE; +} + +int main(void) { + peer_connection_set_diagnostic_callback(record); + peer_connection_set_diagnostics_enabled(0); + peer_connection_diag_log("hidden"); + sctp_diag_log("hidden"); + assert(calls == 0); + + peer_connection_set_diagnostics_enabled(1); + errno = EINPROGRESS; + peer_connection_diag_log("sctp_create_done ret=%d", -1); + assert(calls == 1 && strcmp(recorded, "LIBPEER sctp_create_done ret=-1") == 0); + assert(errno == EINPROGRESS); + errno = EINPROGRESS; + sctp_diag_log("create_failed step=%s errno=%d", "send_buffer", 22); + assert(calls == 2 && strcmp(recorded, "SCTP create_failed step=send_buffer errno=22") == 0); + assert(errno == EINPROGRESS); + + char long_message[4096]; + memset(long_message, 'x', sizeof(long_message) - 1); + long_message[sizeof(long_message) - 1] = 0; + peer_connection_diag_log("%s", long_message); + assert(calls == 3 && strlen(recorded) == sizeof(recorded) - 1); + sctp_diag_log("%s", long_message); + assert(calls == 4 && strlen(recorded) == sizeof(recorded) - 1); + + peer_connection_set_diagnostic_callback(NULL); + peer_connection_diag_log("no sink"); + sctp_diag_log("no sink"); + assert(calls == 4); + return 0; +} diff --git a/tests/peer_dtls_loop_test.c b/tests/peer_dtls_loop_test.c index c90f04ea..4efc2aba 100644 --- a/tests/peer_dtls_loop_test.c +++ b/tests/peer_dtls_loop_test.c @@ -91,6 +91,7 @@ void sctp_onmessage(Sctp* sctp, void (*callback)(char*, size_t, void*, uint16_t) int sctp_is_connected(Sctp* sctp) { (void)sctp; return 1; } uint32_t ports_get_epoch_time(void) { return 1; } uint32_t ports_get_monotonic_time(void) { return 1; } +uint64_t ports_get_monotonic_time_us(void) { return 1; } int addr_to_string(const Address* address, char* buffer, size_t size) { (void)address; if (size) buffer[0] = 0; @@ -99,6 +100,10 @@ int addr_to_string(const Address* address, char* buffer, size_t size) { int agent_select_candidate_pair(Agent* agent) { (void)agent; abort(); } int agent_connectivity_check(Agent* agent) { (void)agent; abort(); } int agent_fail_nominated_remote(Agent* agent) { (void)agent; abort(); } +static int consent_checks; +int agent_maybe_send_consent_request(Agent* agent, uint32_t now_ms) { + (void)agent; (void)now_ms; ++consent_checks; return 0; +} int dtls_srtp_handshake(DtlsSrtp* dtls, Address* address) { (void)dtls; (void)address; return next_handshake_result; } @@ -308,8 +313,10 @@ int main(void) { next_dtls_result = MBEDTLS_ERR_SSL_WANT_READ; assert(peer_connection_loop(peer) == 1); assert(dtls_reads == 5 && !peer->dtls_pending); + const int consent_checks_before_idle_loop = consent_checks; assert(peer_connection_loop(peer) == 0); assert(dtls_reads == 5 && socket_reads == 4); + assert(consent_checks == consent_checks_before_idle_loop + 1); memcpy(socket_packet, "\x80\x60\x00\x02\x00\x00\x00\x00\x00\x00\x00\x2a\x65\x80", 14); socket_size = 14; diff --git a/tests/peer_rtcp_receive_test.c b/tests/peer_rtcp_receive_test.c index 6ca0d8c9..ab2ea3ee 100644 --- a/tests/peer_rtcp_receive_test.c +++ b/tests/peer_rtcp_receive_test.c @@ -3,6 +3,7 @@ #include "../extern/libpeer/src/peer_connection.c" static uint32_t now_ms; +static uint64_t now_us; static int sender_reports; static int keyframe_requests; static int protect_calls; @@ -16,6 +17,10 @@ uint32_t ports_get_monotonic_time(void) { return now_ms; } +uint64_t ports_get_monotonic_time_us(void) { + return now_us; +} + int dtls_srtp_encrypt_rctp_packet(DtlsSrtp* dtls, uint8_t* packet, int* bytes) { (void)dtls; protect_calls++; @@ -128,6 +133,52 @@ static void sending_reports(PeerConnection* pc) { assert(protect_calls == 4 && send_calls == 3); } +static void transport_feedback(PeerConnection* pc) { + pc->state = PEER_CONNECTION_COMPLETED; + protect_result = 0; + send_result = 0; + const int sends = send_calls; + now_us = 1000000; + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends); + + twcc_receiver_init(&pc->twcc, 3); + uint8_t rtp[20] = {0x90, 96, 0, 1, 0, 0, 0, 0, 0, 0, 0, 42, + 0xBE, 0xDE, 0, 1, 0x31, 0x00, 0x07, 0}; + assert(twcc_receiver_record(&pc->twcc, rtp, sizeof(rtp), now_us)); + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 1); + assert(sent_size == 24 + 4); + assert(sent_packet[0] == 0xaf && sent_packet[1] == RTCP_RTPFB); + assert(sent_packet[7] == 1 && sent_packet[11] == 42); + assert(sent_packet[12] == 0 && sent_packet[13] == 7); + assert(sent_packet[14] == 0 && sent_packet[15] == 1); + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 1); + + rtp[18] = 8; + now_us += 10000; + assert(twcc_receiver_record(&pc->twcc, rtp, sizeof(rtp), now_us)); + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 1); + now_us += TWCC_FEEDBACK_INTERVAL_US; + send_result = -1; + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 2 && pc->twcc_send_failures == 1); + assert(sent_packet[13] == 8); + + send_result = 0; + rtp[18] = 9; + now_us += TWCC_FEEDBACK_INTERVAL_US; + assert(twcc_receiver_record(&pc->twcc, rtp, sizeof(rtp), now_us)); + pc->state = PEER_CONNECTION_DISCONNECTED; + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 2); + pc->state = PEER_CONNECTION_COMPLETED; + peer_connection_maybe_send_transport_feedback(pc); + assert(send_calls == sends + 3 && sent_packet[13] == 9); +} + int main(void) { PeerConnection pc = {0}; pc.remote_vssrc = 42; @@ -137,5 +188,6 @@ int main(void) { pc.config.on_request_keyframe = on_keyframe_request; incoming_validation(&pc); sending_reports(&pc); + transport_feedback(&pc); return 0; } diff --git a/tests/run_audio_video_underrun_test.sh b/tests/run_audio_video_underrun_test.sh new file mode 100644 index 00000000..0f1418aa --- /dev/null +++ b/tests/run_audio_video_underrun_test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +out="$(mktemp -d)" +trap 'rm -rf "$out"' EXIT +cxxflags=(-std=c++20 -Wall -Wextra -Werror -pthread -g -O1 + -fno-omit-frame-pointer -ffunction-sections -fdata-sections + -Iapp/src -Itests/stream_stubs -Iextern/libpeer/src + -Iextern/libpeer/third_party/mbedtls/include + -Iextern/borealis/library/include/borealis/extern/nanovg) +if [[ -n "${OPENNOW_SANITIZERS:-}" ]]; then + cxxflags+=("-fsanitize=$OPENNOW_SANITIZERS") +fi +"${CXX:-g++}" "${cxxflags[@]}" tests/audio_video_underrun_test.cpp \ + app/src/webrtc/media.cpp app/src/stream/audio/AudioPipeline.cpp \ + app/src/stream/ffmpeg/AVFrameHolder.cpp \ + -Wl,--gc-sections -lavcodec -lavutil -ljansson -o "$out/test" +"$out/test" diff --git a/tests/run_sctp_reliability_test.sh b/tests/run_sctp_reliability_test.sh index d023e89e..37dff0a9 100644 --- a/tests/run_sctp_reliability_test.sh +++ b/tests/run_sctp_reliability_test.sh @@ -18,6 +18,11 @@ for name in ("user_malloc.h", "user_socketvar.h"): PY flags=(-g -fno-omit-frame-pointer) +test_source="${1:-sctp_reliability_test.c}" +link_flags=() +if [[ "$test_source" == sctp_setup_diagnostic_test.c ]]; then + link_flags+=(-Wl,--wrap=usrsctp_setsockopt,--wrap=usrsctp_connect) +fi if [[ -n "${SCTP_TEST_SANITIZERS:-}" ]]; then flags+=("-fsanitize=$SCTP_TEST_SANITIZERS") fi @@ -30,7 +35,7 @@ cmake --build "$work/build" -j "${SCTP_TEST_JOBS:-4}" "${CC:-cc}" -std=gnu11 -Wall -Wextra -Werror "${flags[@]}" \ -DCONFIG_USE_USRSCTP=1 \ -I"$root/extern/libpeer/src" -I"$work/usrsctp/usrsctplib" \ - "$root/tests/sctp_reliability_test.c" "$root/extern/libpeer/src/sctp.c" \ - "$work/build/usrsctplib/libusrsctp.a" -pthread -Wl,--wrap=gettimeofday \ + "$root/tests/$test_source" "$root/extern/libpeer/src/sctp.c" \ + "$work/build/usrsctplib/libusrsctp.a" -pthread -Wl,--wrap=gettimeofday "${link_flags[@]}" \ -o "$work/sctp_reliability_test" "$work/sctp_reliability_test" diff --git a/tests/run_usrsctp_crc32c_test.sh b/tests/run_usrsctp_crc32c_test.sh new file mode 100755 index 00000000..68d5889c --- /dev/null +++ b/tests/run_usrsctp_crc32c_test.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +cp -R "$root/extern/libpeer/third_party/usrsctp" "$work/usrsctp" +sed -i '/^#define __SWITCH__ 1$/d' "$work/usrsctp/usrsctplib/user_malloc.h" \ + "$work/usrsctp/usrsctplib/user_socketvar.h" + +flags=(-g -fno-omit-frame-pointer) +if [[ -n "${SCTP_TEST_SANITIZERS:-}" ]]; then + flags+=("-fsanitize=$SCTP_TEST_SANITIZERS") +fi + +"${CC:-cc}" -std=c99 "${flags[@]}" -include "$root/tests/usrsctp_no_byte_order.h" \ + -DINET -DHAVE_STDATOMIC_H -DHAVE_SYS_QUEUE_H -DSCTP_PROCESS_LEVEL_LOCKS \ + -DSCTP_SIMPLE_ALLOCATOR -D__Userspace__ -ffunction-sections -fdata-sections \ + -I"$work/usrsctp/usrsctplib" -c "$work/usrsctp/usrsctplib/netinet/sctp_crc32.c" \ + -o "$work/sctp_crc32.o" +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror "${flags[@]}" "$root/tests/usrsctp_crc32c_test.c" \ + "$work/sctp_crc32.o" -Wl,--gc-sections -o "$work/usrsctp_crc32c_test" +"$work/usrsctp_crc32c_test" diff --git a/tests/run_webrtc_decode_output_accounting_test.sh b/tests/run_webrtc_decode_output_accounting_test.sh new file mode 100644 index 00000000..2621127c --- /dev/null +++ b/tests/run_webrtc_decode_output_accounting_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +out="$(mktemp -d)" +trap 'rm -rf "$out"' EXIT +cxxflags=(-std=c++20 -Wall -Wextra -Werror -pthread -g -O1 + -fno-omit-frame-pointer -ffunction-sections -fdata-sections + -Iapp/src -Itests/stream_stubs -Iextern/libpeer/src + -Iextern/libpeer/third_party/mbedtls/include + -Iextern/borealis/library/include/borealis/extern/nanovg) +if [[ -n "${OPENNOW_SANITIZERS:-}" ]]; then + cxxflags+=("-fsanitize=$OPENNOW_SANITIZERS") +fi +"${CXX:-g++}" "${cxxflags[@]}" tests/webrtc_decode_output_accounting_test.cpp \ + app/src/webrtc/media.cpp app/src/webrtc/shared.cpp \ + app/src/stream/ffmpeg/FFmpegVideoDecoder.cpp app/src/stream/ffmpeg/AVFrameHolder.cpp \ + -Wl,--gc-sections,--wrap=avcodec_send_packet,--wrap=avcodec_receive_frame \ + -lavcodec -lavutil -ljansson -o "$out/test" +"$out/test" diff --git a/tests/sctp_diagnostic_connect_test.c b/tests/sctp_diagnostic_connect_test.c new file mode 100644 index 00000000..118cb97d --- /dev/null +++ b/tests/sctp_diagnostic_connect_test.c @@ -0,0 +1,28 @@ +#define main existing_sctp_reliability_main +#include "sctp_reliability_test.c" +#undef main + +static unsigned diagnostics; + +static void failing_log_sink(const char* message) { + assert(message && *message); + diagnostics++; + errno = ENOENT; +} + +int main(void) { + owner = pthread_self(); + sctp_usrsctp_init(); + sctp_set_diagnostic_callback(failing_log_sink); + sctp_set_diagnostics_enabled(1); + create_pair(); + assert(opened == 2); + assert(diagnostics > 0); + assert(send_message(0, 0, 42, 32) == 32); + pump(); + assert(delivered == 1 && deliveries[0].value == 42); + destroy_pair(); + sctp_usrsctp_deinit(); + puts("SCTP connects and transfers with a failing diagnostic sink: PASS"); + return 0; +} diff --git a/tests/sctp_setup_diagnostic_test.c b/tests/sctp_setup_diagnostic_test.c new file mode 100644 index 00000000..ec523c14 --- /dev/null +++ b/tests/sctp_setup_diagnostic_test.c @@ -0,0 +1,71 @@ +#define main existing_sctp_reliability_main +#include "sctp_reliability_test.c" +#undef main + +static int failing_option; +static int failing_connect; +static char last_diagnostic[2048]; + +int __real_usrsctp_setsockopt(struct socket*, int, int, const void*, socklen_t); +int __wrap_usrsctp_setsockopt(struct socket* socket, int level, int option, + const void* value, socklen_t size) { + if (option == failing_option) { + errno = EINVAL; + return -1; + } + return __real_usrsctp_setsockopt(socket, level, option, value, size); +} + +int __real_usrsctp_connect(struct socket*, struct sockaddr*, socklen_t); +int __wrap_usrsctp_connect(struct socket* socket, struct sockaddr* address, socklen_t size) { + if (failing_connect) { + errno = ECONNREFUSED; + return -1; + } + return __real_usrsctp_connect(socket, address, size); +} + +static void diagnostic(const char* message) { + if (strstr(message, "create_failed")) { + assert(strlen(message) < sizeof(last_diagnostic)); + strcpy(last_diagnostic, message); + } +} + +int main(void) { + owner = pthread_self(); + sctp_set_diagnostic_callback(diagnostic); + sctp_set_diagnostics_enabled(1); + const struct { int option; const char* stage; } cases[] = { + {SO_LINGER, "linger"}, {SO_SNDBUF, "send_buffer"}, {SO_RCVBUF, "receive_buffer"}, + {SCTP_FRAGMENT_INTERLEAVE, "fragment_interleave"}, {SCTP_PEER_ADDR_PARAMS, "path_mtu"}, + {SCTP_RTOINFO, "retransmission_timeout"}, {SCTP_ENABLE_STREAM_RESET, "stream_reset"}, + {SCTP_NODELAY, "nodelay"}, {SCTP_EVENT, "association_events"}, {SCTP_INITMSG, "initial_streams"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + sctp_usrsctp_init(); + memset(&endpoints[0], 0, sizeof(endpoints[0])); + last_diagnostic[0] = 0; + failing_option = cases[i].option; + assert(sctp_create_association(&endpoints[0], &transports[0]) == -1); + assert(errno == EINVAL); + char expected[128]; + snprintf(expected, sizeof(expected), "SCTP create_failed step=%s errno=%d", cases[i].stage, EINVAL); + assert(strcmp(last_diagnostic, expected) == 0); + assert(!endpoints[0].sock && !endpoints[0].transport && !endpoints[0].message_buf); + sctp_usrsctp_deinit(); + } + sctp_usrsctp_init(); + failing_option = 0; + failing_connect = 1; + memset(&endpoints[0], 0, sizeof(endpoints[0])); + assert(sctp_create_association(&endpoints[0], &transports[0]) == -1); + assert(errno == ECONNREFUSED); + char expected[128]; + snprintf(expected, sizeof(expected), "SCTP create_failed step=connect errno=%d", ECONNREFUSED); + assert(strcmp(last_diagnostic, expected) == 0); + assert(!endpoints[0].sock && !endpoints[0].transport && !endpoints[0].message_buf); + sctp_usrsctp_deinit(); + puts("SCTP setup failure diagnostics and cleanup: PASS"); + return 0; +} diff --git a/tests/sdp_sctp_message_limit_test.c b/tests/sdp_sctp_message_limit_test.c new file mode 100644 index 00000000..75e1c8b1 --- /dev/null +++ b/tests/sdp_sctp_message_limit_test.c @@ -0,0 +1,18 @@ +#include "sctp.h" +#include "sdp.h" + +#include +#include +#include + +int main(void) { + char sdp[CONFIG_SDP_BUFFER_SIZE] = {0}; + sdp_create(sdp, 1, 1, 1); + sdp_append_datachannel(sdp); + const char* attribute = strstr(sdp, "a=max-message-size:"); + assert(attribute != NULL); + const unsigned long advertised = strtoul(attribute + strlen("a=max-message-size:"), NULL, 10); + assert(advertised == SCTP_MAX_MESSAGE_SIZE); + assert(advertised <= SCTP_RECEIVE_BUFFER_SIZE); + return 0; +} diff --git a/tests/settings_tab_equality_test.cpp b/tests/settings_tab_equality_test.cpp new file mode 100644 index 00000000..30d72550 --- /dev/null +++ b/tests/settings_tab_equality_test.cpp @@ -0,0 +1,42 @@ +#include "stream_settings.hpp" + +#include + +int main() +{ + const opennow::StreamSettings saved; + assert(saved == saved); + + const auto check = [&saved](auto member, auto replacement) { + auto draft = saved; + draft.*member = replacement; + assert(draft != saved); + assert(saved != draft); + draft.*member = saved.*member; + assert(draft == saved); + }; + using Settings = opennow::StreamSettings; + check(&Settings::preset_id, "custom"); + check(&Settings::label, "Custom"); + check(&Settings::width, 1920); + check(&Settings::height, 1080); + check(&Settings::fps, 30); + check(&Settings::bitrate_kbps, 12500); + check(&Settings::codec, "H265"); + check(&Settings::region, "https://example.invalid/region"); + check(&Settings::audio_enabled, false); + check(&Settings::audio_volume, 1000); + check(&Settings::audio_buffer_ms, 80); + check(&Settings::video_backend, "Software"); + check(&Settings::debug_diagnostics, true); + check(&Settings::stats_overlay_enabled, true); + check(&Settings::game_language, "fr_FR"); + check(&Settings::persist_game_settings, false); + check(&Settings::controller_layout, "Switch"); + check(&Settings::image_quality_mode, "Original"); + check(&Settings::interface_language, "zh-CN"); + check(&Settings::community_proxy_enabled, true); + check(&Settings::community_proxy_url, "https://example.invalid/proxy"); + for (int threshold : {5, 20, 50}) + check(&Settings::queue_notify_threshold, threshold); +} diff --git a/tests/stream_bitrate_pipeline_test.cpp b/tests/stream_bitrate_pipeline_test.cpp index 12980d06..060ddfb0 100644 --- a/tests/stream_bitrate_pipeline_test.cpp +++ b/tests/stream_bitrate_pipeline_test.cpp @@ -43,7 +43,7 @@ void CheckNegotiatedSettings(const opennow::StreamSettings& settings) }; assert(has_line("a=vqos.bw.maximumBitrateKbps:" + std::to_string(settings.bitrate_kbps))); assert(has_line("a=vqos.bw.minimumBitrateKbps:4000")); - const std::string startup = std::to_string(std::max(4000, settings.bitrate_kbps / 4)); + const std::string startup = std::to_string(std::max(4000, settings.bitrate_kbps)); assert(has_line("a=video.initialBitrateKbps:" + startup)); assert(has_line("a=video.initialPeakBitrateKbps:" + startup)); assert(has_line("a=video.maxFPS:" + std::to_string(settings.fps))); diff --git a/tests/stream_network_evidence_test.cpp b/tests/stream_network_evidence_test.cpp new file mode 100644 index 00000000..63864cd7 --- /dev/null +++ b/tests/stream_network_evidence_test.cpp @@ -0,0 +1,68 @@ +#include "stream_end_policy.hpp" + +#include +#include + +using namespace std::chrono_literals; + +int main() +{ + using namespace opennow; + StreamEndSignals signals; + signals.peer_completed = true; + signals.video_started = true; + + const auto detect = [&](bool internet_connected) { + signals.internet_connected = HasStreamNetworkConnection( + internet_connected, signals.video_started, signals.video_idle); + return DetectStreamEnd(signals); + }; + + for (const auto idle : {0ms, 16ms, 1000ms, 2000ms, 2999ms}) + { + signals.video_idle = idle; + assert(detect(false) == StreamEndReason::None); + } + + signals.video_idle = 3000ms; + assert(detect(false) == StreamEndReason::NetworkLost); + assert(detect(true) == StreamEndReason::None); + + signals.video_idle = 0ms; + for (int sample = 0; sample < 30; ++sample) + assert(detect(false) == StreamEndReason::None); + assert(detect(true) == StreamEndReason::None); + + signals.peer_terminal = PeerTerminalKind::Failed; + assert(detect(false) == StreamEndReason::ServerDisconnected); + signals.peer_terminal = PeerTerminalKind::Disconnected; + assert(detect(false) == StreamEndReason::ServerDisconnected); + signals.peer_terminal = PeerTerminalKind::Closed; + assert(detect(false) == StreamEndReason::StreamEnded); + signals.peer_terminal = PeerTerminalKind::None; + + signals.signaling_connected = false; + signals.video_idle = 7999ms; + assert(detect(true) == StreamEndReason::None); + signals.video_idle = 8000ms; + assert(detect(true) == StreamEndReason::ServerDisconnected); + signals.signaling_connected = true; + signals.video_idle = 14999ms; + assert(detect(true) == StreamEndReason::None); + signals.video_idle = 15000ms; + assert(detect(true) == StreamEndReason::VideoTimedOut); + + signals.video_idle = 0ms; + signals.free_tier = true; + signals.session_elapsed = 1h; + assert(detect(false) == StreamEndReason::FreeSessionEnded); + + signals = {}; + assert(detect(false) == StreamEndReason::None); + signals.peer_completed = true; + assert(detect(false) == StreamEndReason::NetworkLost); + assert(detect(true) == StreamEndReason::None); + signals.peer_completed = false; + signals.peer_terminal = PeerTerminalKind::Failed; + assert(detect(true) == StreamEndReason::ConnectionFailed); +} diff --git a/tests/twcc_feedback_test.c b/tests/twcc_feedback_test.c new file mode 100644 index 00000000..07b0cf75 --- /dev/null +++ b/tests/twcc_feedback_test.c @@ -0,0 +1,290 @@ +#include "twcc.h" + +#include +#include +#include + +#define MEDIA_SSRC 0x11223344u +#define SENDER_SSRC 0xA1B2C3D4u + +typedef struct ParsedFeedback { + uint32_t sender_ssrc; + uint32_t media_ssrc; + uint16_t base_sequence; + uint16_t count; + int32_t reference; + uint8_t feedback_count; + uint8_t received[TWCC_HISTORY_SIZE]; + int64_t arrival_us[TWCC_HISTORY_SIZE]; +} ParsedFeedback; + +static uint16_t read_u16(const uint8_t* data) { + return (uint16_t)((data[0] << 8) | data[1]); +} + +static uint32_t read_u32(const uint8_t* data) { + return ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | data[3]; +} + +static size_t rtp_one_byte(uint8_t* packet, uint8_t id, uint16_t sequence, int with_csrc) { + memset(packet, 0, 64); + packet[0] = (uint8_t)(0x90 | (with_csrc ? 1 : 0)); + packet[1] = 96; + packet[8] = (uint8_t)(MEDIA_SSRC >> 24); + packet[9] = (uint8_t)(MEDIA_SSRC >> 16); + packet[10] = (uint8_t)(MEDIA_SSRC >> 8); + packet[11] = (uint8_t)MEDIA_SSRC; + size_t pos = 12 + (with_csrc ? 4 : 0); + packet[pos++] = 0xBE; + packet[pos++] = 0xDE; + packet[pos++] = 0; + packet[pos++] = 2; + packet[pos++] = 0x72; + packet[pos++] = 1; + packet[pos++] = 2; + packet[pos++] = 3; + packet[pos++] = 0; + packet[pos++] = (uint8_t)((id << 4) | 1); + packet[pos++] = (uint8_t)(sequence >> 8); + packet[pos++] = (uint8_t)sequence; + packet[pos++] = 0x65; + return pos; +} + +static size_t rtp_two_byte(uint8_t* packet, uint8_t id, uint16_t sequence) { + memset(packet, 0, 64); + packet[0] = 0x90; + packet[1] = 96; + packet[8] = (uint8_t)(MEDIA_SSRC >> 24); + packet[9] = (uint8_t)(MEDIA_SSRC >> 16); + packet[10] = (uint8_t)(MEDIA_SSRC >> 8); + packet[11] = (uint8_t)MEDIA_SSRC; + size_t pos = 12; + packet[pos++] = 0x10; + packet[pos++] = 0x00; + packet[pos++] = 0; + packet[pos++] = 2; + packet[pos++] = 1; + packet[pos++] = 1; + packet[pos++] = 9; + packet[pos++] = id; + packet[pos++] = 2; + packet[pos++] = (uint8_t)(sequence >> 8); + packet[pos++] = (uint8_t)sequence; + packet[pos++] = 0; + return pos; +} + +static void parse_feedback(const uint8_t* packet, int size, ParsedFeedback* parsed) { + memset(parsed, 0, sizeof(*parsed)); + assert(size >= 20 && size % 4 == 0); + assert((packet[0] >> 6) == 2); + assert((packet[0] & 0x1F) == 15); + assert(packet[1] == 205); + assert((size_t)(read_u16(packet + 2) + 1) * 4 == (size_t)size); + int end = size; + if (packet[0] & 0x20) { + const uint8_t padding = packet[size - 1]; + assert(padding > 0 && padding < 4); + end -= padding; + } + + parsed->sender_ssrc = read_u32(packet + 4); + parsed->media_ssrc = read_u32(packet + 8); + parsed->base_sequence = read_u16(packet + 12); + parsed->count = read_u16(packet + 14); + parsed->reference = (int32_t)(((uint32_t)packet[16] << 16) | ((uint32_t)packet[17] << 8) | packet[18]); + parsed->feedback_count = packet[19]; + + uint8_t symbols[TWCC_HISTORY_SIZE]; + int pos = 20; + size_t decoded = 0; + while (decoded < parsed->count) { + assert(pos + 2 <= end); + const uint16_t chunk = read_u16(packet + pos); + pos += 2; + if (!(chunk & 0x8000)) { + const uint8_t symbol = (uint8_t)((chunk >> 13) & 3); + for (uint16_t i = 0; i < (chunk & 0x1FFF) && decoded < parsed->count; i++) + symbols[decoded++] = symbol; + } else if (chunk & 0x4000) { + for (int i = 0; i < 7 && decoded < parsed->count; i++) + symbols[decoded++] = (uint8_t)((chunk >> (12 - 2 * i)) & 3); + } else { + for (int i = 0; i < 14 && decoded < parsed->count; i++) + symbols[decoded++] = (uint8_t)((chunk >> (13 - i)) & 1); + } + } + + int64_t time_us = (int64_t)parsed->reference * 64000; + for (size_t i = 0; i < parsed->count; i++) { + if (symbols[i] == 0) + continue; + int32_t ticks; + if (symbols[i] == 1) { + assert(pos + 1 <= end); + ticks = packet[pos++]; + } else { + assert(symbols[i] == 2); + assert(pos + 2 <= end); + ticks = (int16_t)read_u16(packet + pos); + pos += 2; + } + time_us += (int64_t)ticks * 250; + parsed->received[i] = 1; + parsed->arrival_us[i] = time_us; + } + assert(pos == end); +} + +static void assert_arrival(const ParsedFeedback* parsed, size_t index, uint64_t expected_us) { + assert(parsed->received[index]); + const int64_t error = parsed->arrival_us[index] - (int64_t)expected_us; + assert(error >= -125 && error <= 125); +} + +static void sdp_extension_id(void) { + assert(twcc_parse_extension_id("a=extmap:3 " TWCC_EXTENSION_URI) == 3); + assert(twcc_parse_extension_id("a=extmap:5/recvonly " TWCC_EXTENSION_URI) == 5); + assert(twcc_parse_extension_id("a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing") == 0); + assert(twcc_parse_extension_id("a=extmap: " TWCC_EXTENSION_URI) == 0); + assert(twcc_parse_extension_id("a=rtcp-fb:96 transport-cc") == 0); +} + +static void header_extension_parsing(void) { + uint8_t packet[64]; + uint16_t sequence = 0; + size_t size = rtp_one_byte(packet, 3, 0xBEEF, 0); + assert(twcc_read_sequence(packet, size, 3, &sequence) && sequence == 0xBEEF); + assert(!twcc_read_sequence(packet, size, 4, &sequence)); + size = rtp_one_byte(packet, 3, 0x1234, 1); + assert(twcc_read_sequence(packet, size, 3, &sequence) && sequence == 0x1234); + assert(!twcc_read_sequence(packet, 20, 3, &sequence)); + size = rtp_two_byte(packet, 3, 0x0102); + assert(twcc_read_sequence(packet, size, 3, &sequence) && sequence == 0x0102); + packet[0] &= (uint8_t)~0x10; + assert(!twcc_read_sequence(packet, size, 3, &sequence)); +} + +static void loss_reorder_and_padding(void) { + static TwccReceiver receiver; + twcc_receiver_init(&receiver, 3); + uint8_t rtp[64]; + const uint64_t start_us = 10ull * 1000 * 1000 + 12345; + const uint16_t sequences[] = {65530, 65531, 65533, 65535, 65534, 0, 1, 3}; + const uint64_t offsets[] = {0, 1000, 2000, 2600, 2700, 90000, 90250, 91000}; + for (size_t i = 0; i < sizeof(sequences) / sizeof(sequences[0]); i++) { + const size_t size = rtp_one_byte(rtp, 3, sequences[i], 0); + assert(twcc_receiver_record(&receiver, rtp, size, start_us + offsets[i])); + } + assert(!twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 1, 0), start_us + 95000)); + assert(!twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 4, 9, 0), start_us + 95000)); + + assert(twcc_receiver_feedback_due(&receiver, start_us + 91000)); + uint8_t packet[TWCC_MAX_FEEDBACK_SIZE]; + const int size = twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet)); + assert(size > 0); + twcc_receiver_feedback_sent(&receiver, start_us + 91000); + assert(!twcc_receiver_feedback_due(&receiver, start_us + 91000 + TWCC_FEEDBACK_INTERVAL_US)); + + ParsedFeedback parsed; + parse_feedback(packet, size, &parsed); + assert(parsed.sender_ssrc == SENDER_SSRC); + assert(parsed.media_ssrc == MEDIA_SSRC); + assert(parsed.base_sequence == 65530); + assert(parsed.count == 10); + assert(parsed.feedback_count == 0); + assert(parsed.reference == (int32_t)(start_us / 64000)); + assert_arrival(&parsed, 0, start_us); + assert_arrival(&parsed, 1, start_us + 1000); + assert(!parsed.received[2]); + assert_arrival(&parsed, 3, start_us + 2000); + assert_arrival(&parsed, 4, start_us + 2700); + assert_arrival(&parsed, 5, start_us + 2600); + assert_arrival(&parsed, 6, start_us + 90000); + assert_arrival(&parsed, 7, start_us + 90250); + assert(!parsed.received[8]); + assert_arrival(&parsed, 9, start_us + 91000); + + assert(twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet)) == 0); + assert(!twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 2, 0), start_us + 96000)); + assert(receiver.late_packets == 1); + + assert(twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 4, 0), start_us + 97000)); + const int next_size = twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet)); + parse_feedback(packet, next_size, &parsed); + assert(parsed.base_sequence == 4 && parsed.count == 1 && parsed.feedback_count == 1); + assert_arrival(&parsed, 0, start_us + 97000); +} + +static void large_gap_splits_feedback(void) { + static TwccReceiver receiver; + twcc_receiver_init(&receiver, 3); + uint8_t rtp[64]; + const uint64_t start_us = 5ull * 1000 * 1000; + assert(twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 100, 0), start_us)); + assert(twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 101, 0), start_us + 9000000)); + + uint8_t packet[TWCC_MAX_FEEDBACK_SIZE]; + ParsedFeedback parsed; + int size = twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet)); + parse_feedback(packet, size, &parsed); + assert(parsed.base_sequence == 100 && parsed.count == 1); + size = twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet)); + parse_feedback(packet, size, &parsed); + assert(parsed.base_sequence == 101 && parsed.count == 1); + assert_arrival(&parsed, 0, start_us + 9000000); +} + +static void bounded_history_and_size(void) { + static TwccReceiver receiver; + twcc_receiver_init(&receiver, 3); + uint8_t rtp[64]; + const uint64_t start_us = 1000000; + for (uint32_t i = 0; i < TWCC_HISTORY_SIZE + 500; i++) { + const uint16_t sequence = (uint16_t)(40000 + i); + assert(twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, sequence, 0), + start_us + (uint64_t)i * 300)); + } + assert(receiver.max_sequence - receiver.next_sequence + 1 == TWCC_HISTORY_SIZE); + + uint8_t packet[TWCC_MAX_FEEDBACK_SIZE]; + ParsedFeedback parsed; + size_t reported = 0; + uint16_t expected_base = (uint16_t)(40000 + 500); + int size; + while ((size = twcc_receiver_build_feedback(&receiver, SENDER_SSRC, packet, sizeof(packet))) > 0) { + assert(size <= TWCC_MAX_FEEDBACK_SIZE); + parse_feedback(packet, size, &parsed); + assert(parsed.base_sequence == expected_base); + for (size_t i = 0; i < parsed.count; i++) + assert_arrival(&parsed, i, start_us + (uint64_t)(500 + reported + i) * 300); + reported += parsed.count; + expected_base = (uint16_t)(expected_base + parsed.count); + } + assert(size == 0); + assert(reported == TWCC_HISTORY_SIZE); + + uint8_t tiny[16]; + assert(twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, expected_base, 0), start_us + 10000000)); + assert(twcc_receiver_build_feedback(&receiver, SENDER_SSRC, tiny, sizeof(tiny)) < 0); +} + +static void disabled_without_extension(void) { + static TwccReceiver receiver; + twcc_receiver_init(&receiver, 0); + uint8_t rtp[64]; + assert(!twcc_receiver_record(&receiver, rtp, rtp_one_byte(rtp, 3, 1, 0), 1000)); + assert(!twcc_receiver_feedback_due(&receiver, 1000000)); +} + +int main(void) { + sdp_extension_id(); + header_extension_parsing(); + loss_reorder_and_padding(); + large_gap_splits_feedback(); + bounded_history_and_size(); + disabled_without_extension(); + return 0; +} diff --git a/tests/ui_font_assets_test.py b/tests/ui_font_assets_test.py new file mode 100644 index 00000000..55da0b35 --- /dev/null +++ b/tests/ui_font_assets_test.py @@ -0,0 +1,76 @@ +from pathlib import Path +import struct + + +def tables(path): + data = path.read_bytes() + count = struct.unpack_from(">H", data, 4)[0] + result = {} + for index in range(count): + tag, _, offset, size = struct.unpack_from(">4sIII", data, 12 + index * 16) + result[tag] = data[offset:offset + size] + return result + + +def has_glyph(font, codepoint): + cmap = font[b"cmap"] + count = struct.unpack_from(">H", cmap, 2)[0] + for index in range(count): + _, _, offset = struct.unpack_from(">HHI", cmap, 4 + index * 8) + format_id = struct.unpack_from(">H", cmap, offset)[0] + if format_id == 12: + groups = struct.unpack_from(">I", cmap, offset + 12)[0] + for group in range(groups): + start, end, glyph = struct.unpack_from(">III", cmap, offset + 16 + group * 12) + if start <= codepoint <= end and glyph + codepoint - start != 0: + return True + if format_id != 4 or codepoint > 0xFFFF: + continue + segments = struct.unpack_from(">H", cmap, offset + 6)[0] // 2 + ends = offset + 14 + starts = ends + segments * 2 + 2 + deltas = starts + segments * 2 + ranges = deltas + segments * 2 + for segment in range(segments): + end = struct.unpack_from(">H", cmap, ends + segment * 2)[0] + start = struct.unpack_from(">H", cmap, starts + segment * 2)[0] + if not start <= codepoint <= end: + continue + delta = struct.unpack_from(">h", cmap, deltas + segment * 2)[0] + distance = struct.unpack_from(">H", cmap, ranges + segment * 2)[0] + if distance == 0: + glyph = (codepoint + delta) & 0xFFFF + else: + address = ranges + segment * 2 + distance + (codepoint - start) * 2 + glyph = struct.unpack_from(">H", cmap, address)[0] + if glyph: + glyph = (glyph + delta) & 0xFFFF + if glyph: + return True + return False + + +root = Path(__file__).resolve().parents[1] / "resources" / "font" +for style, weight in (("Medium", 500), ("SemiBold", 600), ("Bold", 700), ("ExtraBold", 800)): + font = tables(root / f"Nunito-{style}.ttf") + assert b"fvar" not in font, f"Nunito {style} must be static for NanoVG" + assert struct.unpack_from(">H", font[b"OS/2"], 4)[0] == weight + for character in "OpenNOWéЖїŁ": + assert has_glyph(font, ord(character)), f"Nunito {style} lacks {character}" + print(f"PASS Nunito {style}: static weight {weight}, Latin/Cyrillic glyphs") + +mono = tables(root / "IBMPlexMono-Medium.ttf") +assert b"fvar" not in mono +assert struct.unpack_from(">H", mono[b"OS/2"], 4)[0] == 500 +assert all(has_glyph(mono, ord(character)) for character in "0123456789./:%") +print("PASS IBM Plex Mono Medium: static weight 500, metric glyphs") + +fallback = tables(root / "OpenNOW-CJK.ttf") +assert b"fvar" not in fallback +assert struct.unpack_from(">H", fallback[b"OS/2"], 4)[0] == 500 +assert all(has_glyph(fallback, ord(character)) for character in "玩家设置游戏") +print("PASS bundled fallback: Chinese UI glyphs") + +for name in ("Nunito-OFL.txt", "IBMPlexMono-OFL.txt", "OpenNOW-CJK-OFL.txt"): + assert "SIL OPEN FONT LICENSE" in (root / name).read_text() +print("PASS bundled font licenses") diff --git a/tests/ui_layout/CMakeLists.txt b/tests/ui_layout/CMakeLists.txt index 4a184b56..c788886a 100644 --- a/tests/ui_layout/CMakeLists.txt +++ b/tests/ui_layout/CMakeLists.txt @@ -1,5 +1,10 @@ cmake_minimum_required(VERSION 3.20) project(opennow_ui_layout LANGUAGES C CXX) +option(OPENNOW_UI_SANITIZERS "Instrument the native harness and Borealis with ASan/UBSan" OFF) +if(OPENNOW_UI_SANITIZERS) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() get_filename_component(ROOT "../.." ABSOLUTE) set(PLATFORM_DESKTOP ON) set(USE_SDL2 ON) @@ -9,7 +14,26 @@ set(BRLS_RESOURCES_DIR "${ROOT}") include("${ROOT}/extern/borealis/library/cmake/commonOption.cmake") include("${ROOT}/extern/borealis/library/cmake/toolchain.cmake") add_subdirectory("${ROOT}/extern/borealis/library" borealis) +find_package(PkgConfig REQUIRED) +pkg_check_modules(JANSSON REQUIRED IMPORTED_TARGET jansson) add_executable(ui_layout ui_layout.cpp ui_layout_stubs.cpp + "${ROOT}/app/src/library_tab.cpp" + "${ROOT}/app/src/library_row_view.cpp" + "${ROOT}/app/src/catalog_tab.cpp" + "${ROOT}/app/src/settings_tab.cpp" + "${ROOT}/app/src/settings_tab_pages.cpp" + "${ROOT}/app/src/settings_tab_actions.cpp" + "${ROOT}/app/src/settings_tab_account.cpp" + "${ROOT}/app/src/providers_tab.cpp" + "${ROOT}/app/src/qr_login_dialog.cpp" + "${ROOT}/app/src/qrcodegen.c" + "${ROOT}/app/src/ui_action_guard.cpp" + "${ROOT}/app/src/ui_helpers.cpp" + "${ROOT}/app/src/queue_view.cpp" + "${ROOT}/app/src/stream_overlay_view.cpp" + "${ROOT}/app/src/ui_theme.cpp" + "${ROOT}/app/src/stream_settings.cpp" + "${ROOT}/app/src/stream_diagnostics.cpp" "${ROOT}/app/src/top_bar_frame.cpp" "${ROOT}/app/src/game_browser_header.cpp" "${ROOT}/app/src/game_card_view.cpp" @@ -18,4 +42,7 @@ add_executable(ui_layout ui_layout.cpp ui_layout_stubs.cpp "${ROOT}/app/src/localization.cpp") target_compile_features(ui_layout PRIVATE cxx_std_20) target_include_directories(ui_layout PRIVATE "${ROOT}/app/src") -target_link_libraries(ui_layout PRIVATE borealis) +target_link_libraries(ui_layout PRIVATE borealis PkgConfig::JANSSON) +target_compile_definitions(ui_layout PRIVATE OPENNOW_UI_SOURCE_ROOT="${ROOT}") +target_link_options(ui_layout PRIVATE "-Wl,--wrap=_ZN4brls11Application6notifyERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE") +target_link_options(ui_layout PRIVATE "-Wl,--wrap=nvgText" "-Wl,--wrap=nvgTextWithCursor") diff --git a/tests/ui_layout/README.md b/tests/ui_layout/README.md index a719b599..09185f84 100644 --- a/tests/ui_layout/README.md +++ b/tests/ui_layout/README.md @@ -1,44 +1,123 @@ -# Native UI layout regression test +# Production native UI verification -This target renders the production `TopBarFrame`, browser header, game cards and -game detail view with the vendored Borealis SDL/OpenGL backend. It is not an HTML -mock or a full application build. Only account fixtures, cover loading, persistence -and network/launch actions are stubbed, so it needs no NVIDIA account and writes no -runtime account files. The placeholder cover and blank avatar are intentional. +This target compiles actual LibraryTab, CatalogTab, SettingsTab and their actions, +GameDetailView, the shell, queue UI and launch worker, and the pure stream overlay +renderer with vendored Borealis SDL/OpenGL. It does not build an HTML mock or add +a demo path to the application. Production InitializeThemeAndFonts runs after +window creation and before any view constructor. -On Ubuntu, install the host dependencies and build outside the repository: +Only external NVIDIA, image/avatar downloads, account/launcher-preference +persistence, shortcut installation, network information, play-history writes, +and the hardware stream handoff use test-link fixtures. Stream settings use the +real atomic implementation with isolated test AppHomePath storage. Queue tests +run real start/poll/cancel/adopt/minimize/restore code with scripted service +replies and a recording PresentCloudStream bridge. Notification instrumentation +records calls and forwards to the real Borealis notification implementation. +No fixture contains credentials or belongs to the NRO target. + +## Build and run + +On Ubuntu, build outside the repository: ```sh -sudo apt-get install cmake ninja-build libsdl2-dev libgl1-mesa-dev libcurl4-openssl-dev xvfb xauth -cmake -S tests/ui_layout -B /tmp/opennow-ui-layout -G Ninja -DCMAKE_BUILD_TYPE=Debug -cmake --build /tmp/opennow-ui-layout -j4 -bash tests/ui_layout/run.sh /tmp/opennow-ui-layout/ui_layout /tmp/opennow-ui-evidence +sudo apt-get install cmake ninja-build libsdl2-dev libgl1-mesa-dev \ + libcurl4-openssl-dev libjansson-dev xvfb xauth imagemagick +cmake -S tests/ui_layout -B ~/.capy/work/opennow-native-ui -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug +cmake --build ~/.capy/work/opennow-native-ui -j4 +python3 tests/ui_font_assets_test.py +python3 tests/ui_layout/font_chain_test.py +bash tests/ui_layout/run.sh ~/.capy/work/opennow-native-ui/ui_layout \ + ~/.capy/work/opennow-native-evidence ``` -The runner tests every supported interface language with a long account name, -subscription chips and a minimized queue at 1280×720 and 1920×1080 physical -resolution. Both must retain the same 1280×720 logical coordinates. Guest and game -detail cases also run at both sizes. Each run checks child containment, horizontal -sibling separation, one-line tabs, card widths and vertical grid navigation routes. -The two-pixel tolerance allows Borealis/Yoga text and pixel rounding. Scrolling -frame scrollbars intentionally overlay content and are excluded from sibling checks. -The programmatic resize also updates the OpenGL viewport, matching Borealis' -native resize callback. The test checks drawable dimensions, viewport coverage -and the bottom-right framebuffer pixel before accepting a capture. +The full runner has 104 cases: six screens, all eight supported languages, and +1280×720/1920×1080 physical resolution, plus guest and empty Library/Store at both +sizes. Every case must retain 1280×720 logical geometry. Settings has four extra +category captures; queue has initial-position and restored-snapshot captures. + +The executable accepts `language account-name screenshot-path physical-width +scenario`. Use `guest` for the signed-out fixture. Scenarios are `library`, +`store`, `settings`, `detail`, `queue`, `overlay`, `empty`, and `empty-store`. +Pass scenario names after the runner's output directory for a narrow matrix. +For parallel isolated output directories, `--screens-only` before scenario names +omits only separately assigned guest/empty cases. Run one group without that +flag so all eight extra cases remain covered. Each attempted case gets a log; +failures do not stop subsequent cases, and any failure makes the runner nonzero. + +## Assertions -Logs contain the measured rectangles, and `.ppm` files contain native framebuffer -captures. View them directly or convert them with ImageMagick: +- Child containment, flow sibling separation, one-line tabs, a single shell + footer, exact Store 220×210 cards with 245-pixel origin spacing, and containment + in 1200-pixel rows. Absolute art is not a flow sibling, and scrolling content + may exceed its viewport. Two-pixel Yoga containment tolerance does not relax + exact card dimensions. +- Actual NanoVG glyph bounds and font measurements for fixed Library captions + in every 4-sort × 6-filter combination; row title/value containment inside + content padding; focused Settings rows inside their scissor viewport; and no + visible scrollbar over cards, labels or focus borders. Library/Store long + game titles must use complete static ellipses, with identical cropped native + framebuffer pixels before and after the native scroll timer. The test-link + NanoVG observers forward every draw to the real implementation. Geometry-only + passes do not establish readable text or visual acceptance. + Bearings are measured at the recorded NanoVG transform and raster origin with + the label's font quality. Fontstash rounds glyph quads to physical pixels, so + only the label's own ink boundary allows one physical pixel (`1/windowScale`). + Draw-anchor tolerance remains 0.5 logical pixels; full-caption fit, padded-row + containment, no overlap, ancestor-scissor bounds and static-title pixel + stability are unchanged. +- Public Borealis controller dispatch, real focus routing, hit-testing and + START/END gestures, actual SDL IME text-input/submission, Library focus-preview + identity, filter/sort/search, bounded fifteen-row local pages with previous + restoration and no extra fetch, Store order/query/cursor effects, and detail + activity navigation. +- Settings A cycle, X Save, Y Revert and reminder-only dirty/save behavior through + actual persistence. All categories get geometry/font checks and captures. + Category construction and help focus must not start implicit region probes. +- Detail Play and shortcut argument recording, multi-variant selector and saved + preference, actual selected-store launch, stale-account deferred-dismiss guard, + and overflowing descriptions scrolled through native held SDL input. +- Real queue ownership, position 123/unknown, minimize/restore/cancel, adopted and + after-cancel session cleanup, repeated polling, exactly one reminder, blocked + second launch, patch/ad/error priority, and account-generation cleanup. Entire + StartSession and PresentCloudStream settings must stay frozen when separately + saved settings change while minimized. A covering real dialog must remain + intact during handoff, then remove the stale queue after it closes. +- Named font IDs, stock-label font initialization, actual bundled glyph presence + for rendered text, mixed Latin/Cyrillic/Chinese NanoVG measurement, complete UI + translation codepoint coverage in every named face plus direct CJK fallback, + static weights and licenses. Missing Chinese glyphs are failures. +- Production overlay formatting for unavailable RTT/no packets, sequence-gap + loss estimate, fractional measured p95 latency, queue high-water and elapsed. +- Drawable/viewport coverage and bottom-right framebuffer coverage before native + PPM capture and PNG conversion. Final captures wait for notification toasts. + +Waits have finite deadlines; assertion failures are not skipped. Fixture covers +use the authentic OpenNOW logo and avatars are blank, so screenshots do not +prove real image download lifetime/cropping. + +## Sanitizers ```sh -convert /tmp/opennow-ui-evidence/fr-1280.ppm /tmp/opennow-ui-evidence/fr-1280.png +cmake -S tests/ui_layout -B ~/.capy/work/opennow-native-asan -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug -DOPENNOW_UI_SANITIZERS=ON +cmake --build ~/.capy/work/opennow-native-asan -j4 +xvfb-run -a -s '-screen 0 1920x1080x24' \ + ~/.capy/work/opennow-native-asan/ui_layout en Player \ + ~/.capy/work/opennow-native-asan/library.ppm 1280 library ``` -The executable accepts `language account-name screenshot-path physical-width -[detail]`. Use `guest` as the account name for the signed-out fixture. A nonzero exit -means initialization or a geometry assertion failed. Switch/Deko3D rendering, -real controller input, asynchronous cover loading, settings pages and live catalog -contents still require separate application and hardware verification. - -The Linux Borealis backend does not load a system Chinese fallback font. Some -Chinese glyphs appear as missing-character boxes in these captures. The Chinese -cases check geometry, not glyph coverage or the Switch shared-font fallback. +Run detail and queue similarly. Keep sanitizer diagnostics. If SDL/Mesa reports +a dependency leak, distinguish that report from view/application lifetime +failures instead of broadly suppressing it. GNU linker notification observation +is intended for this Linux host target. + +## Limits + +Fixtures do not prove NVIDIA authentication/entitlement or remote allocation, +real cover workers, live WebRTC sampling, Switch/Deko3D rendering, physical input +coordinates, remote reports, inline-keyboard capture or gameplay latency. The +overlay uses production rendering with test samples; the handoff bridge records +a call instead of creating a hardware stream. Run the supported full Switch +build, inspect RomFS, sideload the real SwitchNOW.nro, and verify login, playback, +dock/undock, queue cancellation and input release on real Switch hardware. diff --git a/tests/ui_layout/font_chain_test.py b/tests/ui_layout/font_chain_test.py new file mode 100644 index 00000000..e57ffa07 --- /dev/null +++ b/tests/ui_layout/font_chain_test.py @@ -0,0 +1,21 @@ +from pathlib import Path +import re +import runpy + +root = Path(__file__).resolve().parents[2] +asset_check = runpy.run_path(str(root / "tests/ui_font_assets_test.py")) +tables = asset_check["tables"] +has_glyph = asset_check["has_glyph"] +font_dir = root / "resources/font" +fallback = tables(font_dir / "OpenNOW-CJK.ttf") +source = (root / "app/src/localization.cpp").read_text() +literals = re.findall(r'"((?:\\.|[^"\\])*)"', source) +characters = set("".join(literals)) +characters.update("OpenNOW Жї 玩家设置游戏 123.45%") +characters = sorted(character for character in characters if ord(character) > 32) +for filename in ("Nunito-Medium.ttf", "Nunito-SemiBold.ttf", "Nunito-Bold.ttf", "Nunito-ExtraBold.ttf", "IBMPlexMono-Medium.ttf"): + base = tables(font_dir / filename) + missing = [character for character in characters + if not has_glyph(base, ord(character)) and not has_glyph(fallback, ord(character))] + assert not missing, f"{filename} direct bundled fallback misses: {missing}" + print(f"PASS {filename} + direct OpenNOW-CJK: {len(characters)} UI codepoints") diff --git a/tests/ui_layout/run.sh b/tests/ui_layout/run.sh index 8598ad45..3f11838f 100644 --- a/tests/ui_layout/run.sh +++ b/tests/ui_layout/run.sh @@ -2,19 +2,49 @@ set -euo pipefail binary="$(realpath "$1")" output="$(realpath -m "$2")" +python3 "$(dirname "$(realpath "$0")")/font_chain_test.py" mkdir -p "$output" +shift 2 +include_extra=1 +if [[ "${1:-}" == --screens-only ]]; then + include_extra=0 + shift + printf 'INFO Running named screens only; guest/empty cases run in the separate main matrix.\n' +fi +if (( $# )); then scenarios=("$@"); else scenarios=(library store settings detail queue overlay); fi +status=0 +capture_case() { + local language="$1" width="$2" account="$3" scenario="$4" name="$5" + rm -f "$output/$name.ppm" "$output/$name.png" "$output/$name.ppm.queue.ppm" "$output/$name.ppm.queue.png" \ + "$output/$name.ppm.failure.ppm" "$output/$name.ppm.failure.png" "$output/$name.ppm.restored.ppm" "$output/$name.ppm.restored.png" + rm -f "$output/$name.ppm".settings-*.ppm "$output/$name.ppm".settings-*.png + if xvfb-run -a -s '-screen 0 1920x1080x24' "$binary" "$language" "$account" \ + "$output/$name.ppm" "$width" "$scenario" > "$output/$name.log" 2>&1; then + printf 'PASS %s\n' "$name" + else + printf 'FAIL %s (see %s.log)\n' "$name" "$name" + status=1 + fi + if [[ -f "$output/$name.ppm" ]]; then convert "$output/$name.ppm" "$output/$name.png"; fi + if [[ -f "$output/$name.ppm.queue.ppm" ]]; then convert "$output/$name.ppm.queue.ppm" "$output/$name.ppm.queue.png"; fi + if [[ -f "$output/$name.ppm.failure.ppm" ]]; then convert "$output/$name.ppm.failure.ppm" "$output/$name.ppm.failure.png"; fi + if [[ -f "$output/$name.ppm.restored.ppm" ]]; then convert "$output/$name.ppm.restored.ppm" "$output/$name.ppm.restored.png"; fi + find "$output" -maxdepth 1 -name "$name.ppm.settings-*.ppm" -print0 | + while IFS= read -r -d '' path; do convert "$path" "${path%.ppm}.png"; done + find "$output" -maxdepth 1 -name "$name.ppm.*-title-*.ppm" -print0 | + while IFS= read -r -d '' path; do convert "$path" "${path%.ppm}.png"; done +} for width in 1280 1920; do for language in en zh-CN es ru it fr pl uk; do - name="${language}-${width}" - xvfb-run -a -s '-screen 0 1920x1080x24' "$binary" "$language" \ - 'A player with a very long account display name' \ - "$output/$name.ppm" "$width" > "$output/$name.log" 2>&1 - printf 'PASS %s\n' "$name" + for scenario in "${scenarios[@]}"; do + capture_case "$language" "$width" 'A player with a very long account display name' "$scenario" "$language-$width-$scenario" + done done - xvfb-run -a -s '-screen 0 1920x1080x24' "$binary" en guest \ - "$output/guest-$width.ppm" "$width" > "$output/guest-$width.log" 2>&1 - printf 'PASS guest-%s\n' "$width" - xvfb-run -a -s '-screen 0 1920x1080x24' "$binary" fr Player \ - "$output/detail-$width.ppm" "$width" detail > "$output/detail-$width.log" 2>&1 - printf 'PASS detail-%s\n' "$width" + if (( include_extra )); then + capture_case en "$width" guest library "guest-$width" + capture_case en "$width" Player empty "empty-$width" + capture_case en "$width" guest store "guest-store-$width" + capture_case en "$width" Player empty-store "empty-store-$width" + fi done +exit "$status" diff --git a/tests/ui_layout/ui_layout.cpp b/tests/ui_layout/ui_layout.cpp index 9cd44641..49952c55 100644 --- a/tests/ui_layout/ui_layout.cpp +++ b/tests/ui_layout/ui_layout.cpp @@ -1,30 +1,169 @@ #include "app_state.hpp" -#include "game_browser_header.hpp" +#include "catalog_tab.hpp" +#include "cover_image_cache.hpp" #include "game_card_view.hpp" #include "game_detail_view.hpp" -#include "game_grid_navigation.hpp" +#include "library_tab.hpp" +#include "library_row_view.hpp" #include "localization.hpp" +#include "queue_view.hpp" +#include "settings_tab.hpp" +#include "stream_overlay_view.hpp" +#include "stream_settings.hpp" #include "top_bar_frame.hpp" +#include "ui_layout_fixtures.hpp" +#include "ui_theme.hpp" +#include "ui_helpers.hpp" #include #include +#include #include - +#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include #include #include namespace { int failures = 0; - +std::string capture_path; +void Screenshot(const std::string& path); void Check(bool passed, const std::string& message) { std::printf("%s %s\n", passed ? "PASS" : "FAIL", message.c_str()); failures += !passed; } - +void Pump(int frames = 24) +{ + for (int index = 0; index < frames; ++index) + if (!brls::Application::mainLoop()) + throw std::runtime_error("Native event loop exited before assertions completed"); +} +void DrainToasts() +{ + auto style = brls::Application::getStyle(); + const auto duration = static_cast(style.getMetric("brls/animations/notification_timeout") + + 2 * style.getMetric("brls/animations/notification_show") + 100); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(duration); + while (std::chrono::steady_clock::now() < deadline) Pump(1); +} +void Until(const std::function& predicate, const std::string& message, int seconds = 5) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(seconds); + while (!predicate() && std::chrono::steady_clock::now() < deadline) Pump(1); + Check(predicate(), message); + Pump(); +} +brls::View* CurrentRoot() +{ + const auto stack = brls::Application::getActivitiesStack(); + return stack.empty() ? nullptr : stack.back()->getContentView(); +} +void Walk(brls::View* root, const std::function& visit) +{ + if (!root || root->getVisibility() == brls::Visibility::GONE) return; + visit(root); + if (auto* box = dynamic_cast(root)) + for (auto* child : box->getChildren()) Walk(child, visit); +} +std::string Text(brls::View* root) +{ + std::string text; + Walk(root, [&](brls::View* view) { + if (auto* label = dynamic_cast(view)) text += label->getFullText() + "\n"; + }); + return text; +} +brls::View* Required(brls::View* root, const std::string& id) +{ + auto* view = root ? root->getView(id) : nullptr; + Check(view != nullptr, "semantic view exists: " + id); + if (!view) throw std::runtime_error("Missing production view ID: " + id); + return view; +} +brls::View* Action(brls::View* root, const std::string& title) +{ + brls::View* result = nullptr; + Walk(root, [&](brls::View* view) { + if (view->isFocusable() && Text(view).find(opennow::Tr(title)) != std::string::npos) result = view; + }); + Check(result != nullptr, "production action exists: " + title); + if (!result) throw std::runtime_error("Missing production action: " + title); + return result; +} +void Key(brls::ControllerButton button) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (brls::Application::isInputBlocks() && std::chrono::steady_clock::now() < deadline) Pump(1); + Check(!brls::Application::isInputBlocks(), "controller dispatch is not blocked by a transition"); + std::printf("INPUT button=%d focus=%s\n", static_cast(button), + brls::Application::getCurrentFocus() ? brls::Application::getCurrentFocus()->describe().c_str() : "none"); + brls::Application::onControllerButtonPressed(button, false); + Pump(); +} +void Activate(brls::View* view) +{ + brls::Application::giveFocus(view); + Pump(); + Check(brls::Application::getCurrentFocus() == view->getDefaultFocus(), "action owns native focus before A"); + Key(brls::BUTTON_A); +} +void Tap(brls::View* target) +{ + const auto frame = target->getFrame(); + brls::Point point(frame.getMinX() + frame.getWidth() * 0.5f, frame.getMinY() + frame.getHeight() * 0.5f); + auto* responder = CurrentRoot()->hitTest(point); + Check(responder != nullptr, "native hitTest finds touch responder"); + if (!responder) throw std::runtime_error("Touch target is outside the production activity"); + auto* ancestor = responder; + while (ancestor && ancestor != target) ancestor = ancestor->hasParent() ? ancestor->getParent() : nullptr; + Check(ancestor == target, "touch responder belongs to the painted action"); + brls::TouchState touch; + touch.position = point; + touch.view = responder; + touch.phase = brls::TouchPhase::START; + responder->gestureRecognizerRequest(touch, {}, responder); + touch.phase = brls::TouchPhase::END; + responder->gestureRecognizerRequest(touch, {}, responder); + Pump(); +} +void Search(brls::View* root, const std::string& query) +{ + const std::string id = root->getView("catalog") ? "catalog-search" : "library-search"; + brls::Application::giveFocus(Required(root, id)); + Pump(); + const auto count = brls::Application::getActivitiesStack().size(); + Key(brls::BUTTON_Y); + Check(brls::Application::getActivitiesStack().size() == count + 1, "Y opens actual native SDL IME dialog"); + auto* input = dynamic_cast(Required(CurrentRoot(), "brls/dialog/label")); + auto* count_label = dynamic_cast(Required(CurrentRoot(), "brls/dialog/count")); + if (!input || !count_label) throw std::runtime_error("Native IME text/count label missing"); + size_t deletes = 0; + while (!count_label->getFullText().starts_with("0/") && deletes++ < 64) + brls::Application::onControllerButtonPressed(brls::BUTTON_BACK, false); + Check(count_label->getFullText().starts_with("0/"), "native IME deletion clears previous query"); + if (!query.empty()) + { + SDL_Event event {}; + event.type = SDL_TEXTINPUT; + std::snprintf(event.text.text, sizeof(event.text.text), "%s", query.c_str()); + Check(SDL_PushEvent(&event) == 1, "SDL accepts actual text-input event"); + Pump(); + Check(input->getFullText() == query, "native IME receives SDL text-input query"); + } + Key(brls::BUTTON_A); + Check(brls::Application::getActivitiesStack().size() == count, "native IME submit returns to production page"); +} void CheckChildren(brls::Box* box, const std::string& path) { const auto bounds = box->getFrame(); @@ -32,148 +171,784 @@ void CheckChildren(brls::Box* box, const std::string& path) size_t index = 0; for (auto* child : box->getChildren()) { - if (child->getVisibility() == brls::Visibility::GONE) - continue; + if (child->getVisibility() == brls::Visibility::GONE) continue; const auto rect = child->getFrame(); const auto name = path + "/" + std::to_string(index++); - std::printf("RECT %s %.1f %.1f %.1f %.1f\n", name.c_str(), - rect.getMinX(), rect.getMinY(), rect.getWidth(), rect.getHeight()); - Check(rect.getMinX() >= bounds.getMinX() - 2.0f && - rect.getMaxX() <= bounds.getMaxX() + 2.0f, name + " horizontal containment"); - Check(rect.getMinY() >= bounds.getMinY() - 2.0f && - rect.getMaxY() <= bounds.getMaxY() + 2.0f, name + " vertical containment"); - if (box->getAxis() == brls::Axis::ROW && !dynamic_cast(box)) - Check(rect.getMinX() >= previous_right - 2.0f, name + " no sibling overlap"); - previous_right = rect.getMaxX(); - if (auto* nested = dynamic_cast(child)) - CheckChildren(nested, name); + std::printf("RECT %s %.1f %.1f %.1f %.1f\n", name.c_str(), rect.getMinX(), rect.getMinY(), rect.getWidth(), rect.getHeight()); + Check(rect.getMinX() >= bounds.getMinX() - 2 && rect.getMaxX() <= bounds.getMaxX() + 2, name + " horizontal containment"); + if (!dynamic_cast(box)) + Check(rect.getMinY() >= bounds.getMinY() - 2 && rect.getMaxY() <= bounds.getMaxY() + 2, name + " vertical containment"); + const bool flow = YGNodeStyleGetPositionType(child->getYGNode()) != YGPositionTypeAbsolute; + if (box->getAxis() == brls::Axis::ROW && !dynamic_cast(box) && flow) + Check(rect.getMinX() >= previous_right - 2, name + " no flow sibling overlap"); + if (flow) previous_right = rect.getMaxX(); + if (auto* nested = dynamic_cast(child)) CheckChildren(nested, name); } } - +struct FontFile +{ + std::vector bytes; + stbtt_fontinfo info {}; + explicit FontFile(const std::string& path) + { + std::ifstream file(path, std::ios::binary); + bytes.assign(std::istreambuf_iterator(file), {}); + if (bytes.empty() || !stbtt_InitFont(&info, bytes.data(), stbtt_GetFontOffsetForIndex(bytes.data(), 0))) + throw std::runtime_error("Cannot inspect bundled font: " + path); + } +}; +std::vector Codepoints(const std::string& text) +{ + std::vector result; + for (size_t offset = 0; offset < text.size();) + { + const unsigned char first = text[offset++]; + int count = first < 0x80 ? 0 : (first & 0xe0) == 0xc0 ? 1 : (first & 0xf0) == 0xe0 ? 2 : 3; + int code = first & (count == 0 ? 0x7f : count == 1 ? 0x1f : count == 2 ? 0x0f : 0x07); + while (count-- && offset < text.size()) code = (code << 6) | (static_cast(text[offset++]) & 0x3f); + result.push_back(code); + } + return result; +} +void CheckFonts(brls::View* root) +{ + const std::string dir = std::string(OPENNOW_UI_SOURCE_ROOT) + "/resources/"; + const std::array paths {"font/Nunito-Medium.ttf", "font/Nunito-SemiBold.ttf", "font/Nunito-Bold.ttf", "font/Nunito-ExtraBold.ttf", "font/IBMPlexMono-Medium.ttf"}; + const std::array names {"regular", "opennow-semibold", "opennow-bold", "opennow-extrabold", "opennow-mono"}; + std::vector bases; + std::vector handles; + for (size_t index = 0; index < paths.size(); ++index) + { + handles.push_back(opennow::ui::Font(static_cast(index))); + Check(handles.back() != brls::FONT_INVALID && brls::Application::getFont(names[index]) == handles.back(), "real named OpenNOW font role registered: " + std::to_string(index)); + bases.emplace_back(dir + paths[index]); + } + Check(brls::Application::getDefaultFont() == handles.front(), "stock label default uses initialized Nunito 500"); + std::vector fallbacks; + for (const char* path : {"font/OpenNOW-CJK.ttf", "font/switch_font.ttf", "font/switch_icons.ttf", "material/MaterialIcons-Regular.ttf"}) fallbacks.emplace_back(dir + path); + Check(brls::Application::getFont("opennow-cjk") != brls::FONT_INVALID, "bundled Chinese fallback registered"); + Walk(root, [&](brls::View* view) { + auto* label = dynamic_cast(view); + if (!label) return; + const auto found = std::find(handles.begin(), handles.end(), label->getFont()); + Check(found != handles.end(), "production label uses an initialized OpenNOW font"); + if (found == handles.end()) return; + const size_t index = found - handles.begin(); + for (int code : Codepoints(label->getFullText())) + { + if (code <= 32) continue; + bool present = stbtt_FindGlyphIndex(&bases[index].info, code) != 0; + for (auto& fallback : fallbacks) present = present || stbtt_FindGlyphIndex(&fallback.info, code) != 0; + if (!present) Check(false, "actual bundled font chain lacks codepoint " + std::to_string(code)); + } + }); + auto* vg = brls::Application::getNVGContext(); + for (int handle : handles) + { + nvgFontFaceId(vg, handle); + nvgFontSize(vg, 18); + float bounds[4] {}; + const float measured = nvgTextBounds(vg, 0, 0, "OpenNOW Жї 玩家设置", nullptr, bounds); + Check(std::isfinite(measured) && measured > 100 && bounds[3] > bounds[1], "actual font chain measures mixed Latin/Cyrillic/Chinese text"); + } +} +struct GlyphMetrics { float width, left_bearing, right_overhang; }; +GlyphMetrics MeasureGlyphs(brls::Label* label, const std::string& text, const ui_fixture::TextDraw* draw = nullptr) +{ + auto* vg = brls::Application::getNVGContext(); + nvgSave(vg); + nvgFontFaceId(vg, label->getFont()); + nvgFontSize(vg, label->getFontSize()); + nvgFontQuality(vg, label->getFontQuality()); + nvgTextLineHeight(vg, label->getLineHeight()); + nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); + if (draw) + { + nvgResetTransform(vg); + const auto& transform = draw->transform; + nvgTransform(vg, transform[0], transform[1], transform[2], transform[3], transform[4], transform[5]); + } + const float x = draw ? draw->raw_x : 0, y = draw ? draw->raw_y : 0; + float bounds[4] {}; + const float advance = nvgTextBounds(vg, x, y, text.c_str(), nullptr, bounds); + nvgRestore(vg); + return {bounds[2] - bounds[0], std::min(0.0f, bounds[0] - x), std::max(0.0f, bounds[2] - x - advance)}; +} +float GlyphWidth(brls::Label* label) { return MeasureGlyphs(label, label->getFullText()).width; } +std::vector CaptureText() +{ + ui_fixture::BeginTextCapture(); + Pump(1); + return ui_fixture::EndTextCapture(); +} +void CheckPaintedLabel(brls::Label* label, const std::vector& draws, + const std::string& name, bool require_ellipsis = false, + const brls::Rect* row_bounds = nullptr, const brls::Rect* scissor_bounds = nullptr) +{ + const auto full = label->getFullText(); + if (full.empty()) return; + const auto frame = label->getFrame(); + bool found = false; + for (const auto& draw : draws) + { + const bool ellipsis = draw.text.ends_with("…"); + const bool identity = draw.text == full || + (ellipsis && full.starts_with(draw.text.substr(0, draw.text.size() - std::string("…").size()))); + if (!identity || std::abs(draw.y - frame.getMidY()) > 1 || + draw.bounds[2] < frame.getMinX() || draw.bounds[0] > frame.getMaxX()) continue; + found = true; + const auto glyphs = MeasureGlyphs(label, draw.text, &draw); + std::printf("GLYPHS %s text=[%s] ink=%.2f,%.2f,%.2f,%.2f frame=%.2f,%.2f,%.2f,%.2f scrolling=%d\n", + name.c_str(), draw.text.c_str(), draw.bounds[0], draw.bounds[1], draw.bounds[2], draw.bounds[3], + frame.getMinX(), frame.getMinY(), frame.getMaxX(), frame.getMaxY(), draw.scrolling_path); + Check(!draw.scrolling_path, name + " paints static text, not a scissored marquee"); + Check(std::abs(draw.x - frame.getMinX()) <= 0.5f, name + " native text anchor remains at allocated label origin"); + const float raster_pixel = 1.0f / brls::Application::windowScale; + Check(draw.bounds[0] >= frame.getMinX() + glyphs.left_bearing - raster_pixel && + draw.bounds[2] <= frame.getMaxX() + glyphs.right_overhang + raster_pixel, + name + " painted glyphs fit label width with actual measured bearings"); + std::printf("BEARINGS %s anchor=%.2f expected=%.2f left=%.2f right=%.2f raster_pixel=%.5f\n", + name.c_str(), draw.x, frame.getMinX(), glyphs.left_bearing, glyphs.right_overhang, raster_pixel); + for (const auto* bounds : {row_bounds, scissor_bounds}) + if (bounds) Check(draw.bounds[0] >= bounds->getMinX() - 0.5f && draw.bounds[2] <= bounds->getMaxX() + 0.5f && + draw.bounds[1] >= bounds->getMinY() - 0.5f && draw.bounds[3] <= bounds->getMaxY() + 0.5f, + name + " actual glyph ink remains inside row and scissor bounds"); + Check(draw.bounds[1] >= frame.getMinY() - 1 && draw.bounds[3] <= frame.getMaxY() + 1, + name + " actual painted glyphs fit allocated label height"); + if (require_ellipsis) Check(ellipsis, name + " overflowing game identity has a complete static ellipsis"); + } + Check(found, name + " observed actual NanoVG text draw"); +} +void CheckActionRow(opennow::ui::ActionRow* row, const std::vector& draws, + const std::string& name, bool full_caption = false) +{ + const auto frame = row->getFrame(); + const float left = frame.getMinX() + YGNodeLayoutGetPadding(row->getYGNode(), YGEdgeLeft); + const float right = frame.getMaxX() - YGNodeLayoutGetPadding(row->getYGNode(), YGEdgeRight); + float previous_right = left; + brls::Rect scissor; + bool has_scissor = false; + auto* parent = row->hasParent() ? row->getParent() : nullptr; + while (parent) + { + if (auto* viewport = dynamic_cast(parent)) + { + scissor = viewport->getFrame(); + has_scissor = true; + break; + } + parent = parent->hasParent() ? parent->getParent() : nullptr; + } + for (auto* child : row->getChildren()) + { + auto* label = dynamic_cast(child); + if (!label || label->getVisibility() != brls::Visibility::VISIBLE || label->getFullText().empty()) continue; + const auto label_frame = label->getFrame(); + Check(label_frame.getMinX() >= left - 0.5f && label_frame.getMaxX() <= right + 0.5f, + name + " title/value label stays inside row content padding"); + Check(label_frame.getMinX() >= previous_right - 0.5f, name + " title and value do not overlap"); + previous_right = label_frame.getMaxX(); + const float required = GlyphWidth(label); + std::printf("CAPTION %s text=[%s] font=%d size=%.1f glyph_width=%.2f available=%.2f\n", + name.c_str(), label->getFullText().c_str(), label->getFont(), label->getFontSize(), required, label_frame.getWidth()); + if (full_caption) Check(required <= label_frame.getWidth() + 0.5f, name + " complete fixed caption fits measured content width"); + CheckPaintedLabel(label, draws, name, false, &frame, has_scissor ? &scissor : nullptr); + } +} +void CheckVisibleActionRows(brls::View* root, const std::string& name) +{ + const auto draws = CaptureText(); + Walk(root, [&](brls::View* view) { + auto* row = dynamic_cast(view); + if (!row) return; + const auto frame = row->getFrame(); + auto* parent = row->hasParent() ? row->getParent() : nullptr; + while (parent) + { + if (auto* viewport = dynamic_cast(parent)) + { + const auto bounds = viewport->getFrame(); + if (frame.getMinY() < bounds.getMinY() || frame.getMaxY() > bounds.getMaxY()) return; + } + parent = parent->hasParent() ? parent->getParent() : nullptr; + } + auto title = Text(row); + std::replace(title.begin(), title.end(), '\n', ' '); + CheckActionRow(row, draws, name + "/" + title); + }); +} +void CheckLibraryToolbar(brls::View* root) +{ + const auto draws = CaptureText(); + for (const char* id : {"library-search", "library-filter", "library-sort", "library-more", "library-previous", "library-next"}) + { + auto* row = dynamic_cast(Required(root, id)); + if (!row) throw std::runtime_error("Library toolbar is not a production ActionRow"); + CheckActionRow(row, draws, id, true); + } +} +void CheckStaticGameTitle(brls::View* owner, brls::Label* title, const std::string& name) +{ + Check(title != nullptr, name + " native title label exists"); + if (!title) throw std::runtime_error("Missing production game title label"); + brls::Application::giveFocus(owner); Pump(); DrainToasts(); + const bool overflow = GlyphWidth(title) > title->getWidth() + 1; + Check(overflow, name + " fixture genuinely exceeds native title width"); + CheckPaintedLabel(title, CaptureText(), name + "/focused", overflow); + auto read_title_pixels = [&] { + const auto frame = title->getFrame(); + const float scale = brls::Application::windowScale; + const int x = std::lround(frame.getMinX() * scale), width = std::lround(frame.getWidth() * scale); + const int y = brls::Application::windowHeight - std::lround(frame.getMaxY() * scale), height = std::lround(frame.getHeight() * scale); + std::vector pixels(width * height * 3); + glReadBuffer(GL_BACK); glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(x, y, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data()); + return pixels; + }; + const auto first_pixels = read_title_pixels(); + const std::string title_name = name.starts_with("library") ? "library" : "store"; + Screenshot(capture_path + "." + title_name + "-title-focused.ppm"); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2200); + while (std::chrono::steady_clock::now() < deadline) Pump(1); + CheckPaintedLabel(title, CaptureText(), name + "/after-scroll-timer", overflow); + Check(first_pixels == read_title_pixels(), name + " title framebuffer pixels remain unchanged after native scroll timer"); + Screenshot(capture_path + "." + title_name + "-title-after-scroll-timer.ppm"); +} +float CheckScrollDecorations(brls::ScrollingFrame* viewport, const std::string& name) +{ + float reserved_right = 0; + for (auto* child : viewport->getChildren()) + { + if (!child->isDetached() || child->getWidth() != 4 || child->getAlpha() <= 0.001f) continue; + reserved_right = std::max(reserved_right, viewport->getFrame().getMaxX() - child->getFrame().getMinX()); + } + Check(reserved_right == 0, name + " has no visible scrollbar over art, labels or focus border"); + return reserved_right; +} +void CheckFocusedScrollInsets(brls::View* focused, const std::string& name) +{ + auto* parent = focused->hasParent() ? focused->getParent() : nullptr; + while (parent && !dynamic_cast(parent)) parent = parent->hasParent() ? parent->getParent() : nullptr; + auto* viewport = dynamic_cast(parent); + Check(viewport != nullptr, name + " is inside a native scrolling viewport"); + if (!viewport) throw std::runtime_error("Missing native scrolling ancestor"); + const auto bounds = viewport->getFrame(), frame = focused->getFrame(); + const float reserved = CheckScrollDecorations(viewport, name); + Check(frame.getMinX() - 4 >= bounds.getMinX() - 0.5f && frame.getMaxX() + 4 <= bounds.getMaxX() - reserved + 0.5f, + name + " complete focus border fits horizontally inside scrolling viewport"); +} +void CheckSettingsActionRows(brls::View* root, const std::string& category) +{ + auto* viewport = dynamic_cast(Required(root, "settings/options")); + if (!viewport) throw std::runtime_error("Missing production settings scissor viewport"); + std::vector rows; + Walk(viewport, [&](brls::View* view) { if (auto* row = dynamic_cast(view)) rows.push_back(row); }); + Check(!rows.empty(), "Settings category contains production action rows: " + category); + auto* original_focus = brls::Application::getCurrentFocus(); + const auto bounds = viewport->getFrame(); + for (auto* row : rows) + { + brls::Application::giveFocus(row); Pump(16); + const auto frame = row->getFrame(); + auto title = Text(row); + std::replace(title.begin(), title.end(), '\n', ' '); + const auto name = "settings/" + category + "/" + title; + const float scrollbar_reservation = CheckScrollDecorations(viewport, name); + Check(brls::Application::getCurrentFocus() == row, name + " actual row owns focus"); + Check(frame.getMinX() - 4 >= bounds.getMinX() - 0.5f, name + " focus border has left scissor inset"); + Check(frame.getMaxX() + 4 <= bounds.getMaxX() - scrollbar_reservation + 0.5f, + name + " focus border clears actual right scissor and scrollbar reservation"); + Check(frame.getMinY() - 4 >= bounds.getMinY() - 0.5f && frame.getMaxY() + 4 <= bounds.getMaxY() + 0.5f, + name + " focused row and border fit vertical scissor viewport"); + CheckActionRow(row, CaptureText(), name); + } + if (original_focus) { brls::Application::giveFocus(original_focus); Pump(); } +} +void CheckTabLanguage(brls::View* root) +{ + Check(Text(Required(root, "shell-tabs")) == opennow::Tr("Store") + "\n" + + opennow::Tr("Library") + "\n" + opennow::Tr("Settings") + "\n", + "all three retained shell tab labels reflect current interface language"); +} +void SaveLanguageThroughSettings(brls::View* root, const std::string& language) +{ + auto* frame = dynamic_cast(root); + if (!frame) throw std::runtime_error("Language regression requires production shell"); + frame->focusTab(2); Pump(); + Activate(Required(root, "settings/category/app")); + Activate(Action(root, "App language")); + Check(dynamic_cast(CurrentRoot()) != nullptr, "actual App language opens native selector"); + const auto& options = opennow::InterfaceLanguageOptions(); + const auto current = std::find_if(options.begin(), options.end(), [](const auto& option) { + return option.code == opennow::GetInterfaceLanguage(); + }); + const auto target = std::find_if(options.begin(), options.end(), [&](const auto& option) { return option.code == language; }); + if (current == options.end() || target == options.end()) throw std::runtime_error("Unsupported language regression fixture"); + const auto difference = target - current; + for (int index = 0; index < std::abs(difference); ++index) + Key(difference > 0 ? brls::BUTTON_NAV_DOWN : brls::BUTTON_NAV_UP); + Key(brls::BUTTON_A); Key(brls::BUTTON_X); + Check(opennow::LoadStreamSettings().interface_language == language && opennow::GetInterfaceLanguage() == language, + "actual Settings selector and X Save persist and apply interface language"); + CheckTabLanguage(root); + frame->focusTab(1); Pump(); + for (const auto& [id, key] : std::array, 3> {{ + {"library-search", "Search"}, {"library-filter", "All"}, {"library-sort", "Last Added"}}}) + std::printf("LOCALE language=%s id=%s actual=[%s] expected=[%s]\n", + language.c_str(), id, Text(Required(root, id)).c_str(), opennow::Tr(key).c_str()); + std::printf("LOCALE language=%s heading_expected=[%s] heading_present=%d\n", language.c_str(), + opennow::Tr("My Library").c_str(), Text(root).find(opennow::Tr("My Library")) != std::string::npos); + Check(Text(root).find(opennow::Tr("My Library")) != std::string::npos && + Text(Required(root, "library-search")).find(opennow::Tr("Search")) != std::string::npos && + Text(Required(root, "library-filter")).find(opennow::Tr("All")) != std::string::npos && + Text(Required(root, "library-sort")).find(opennow::Tr("Last Added")) != std::string::npos, + "retained Library heading and controls refresh after actual language Save"); + Check(Text(Required(root, "library-preview-title")).find(ui_fixture::Library().front().title) != std::string::npos, + "retained Library preserves selected game identity across language Save"); +} void Screenshot(const std::string& path) { - const int width = brls::Application::windowWidth; - const int height = brls::Application::windowHeight; + const int width = brls::Application::windowWidth, height = brls::Application::windowHeight; std::vector pixels(width * height * 3); glReadBuffer(GL_BACK); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data()); const size_t bottom_right = static_cast(width - 1) * 3; - Check(pixels[bottom_right] != 0 || pixels[bottom_right + 1] != 0 || pixels[bottom_right + 2] != 0, - "native frame covers bottom-right framebuffer pixel"); + Check(pixels[bottom_right] != 0 || pixels[bottom_right + 1] != 0 || pixels[bottom_right + 2] != 0, "native frame covers bottom-right framebuffer pixel"); std::ofstream file(path, std::ios::binary); file << "P6\n" << width << ' ' << height << "\n255\n"; - for (int y = height - 1; y >= 0; --y) - file.write(reinterpret_cast(pixels.data() + y * width * 3), width * 3); + for (int y = height - 1; y >= 0; --y) file.write(reinterpret_cast(pixels.data() + y * width * 3), width * 3); + Check(file.good(), "native framebuffer capture written"); } + +void StoreChecks(brls::View* root, bool empty, bool guest) +{ + Until([] { return ui_fixture::Snapshot().catalog_requests + ui_fixture::Snapshot().public_requests > 0; }, "production Store fetch callback reached external fixture"); + std::vector cards; + Walk(root, [&](brls::View* view) { if (auto* card = dynamic_cast(view)) cards.push_back(card); }); + if (empty) { Check(cards.empty(), "empty real Store feed creates no game cards"); return; } + Check(cards.size() == 15, "production Store page retains fifteen games"); + CheckStaticGameTitle(cards.front(), dynamic_cast(cards.front()->getView("catalog-card-title")), "store/game-title"); + CheckFocusedScrollInsets(cards.front(), "store/first-column"); + for (size_t index = 0; index < cards.size(); ++index) + { + const auto rect = cards[index]->getFrame(); + Check(std::abs(rect.getWidth() - 220) < 0.01 && std::abs(rect.getHeight() - 210) < 0.01, "Store card has exact 220x210 logical geometry"); + if (index % 5 != 0) Check(std::abs(rect.getMinX() - cards[index - 1]->getFrame().getMinX() - 245) < 0.01, "Store card origins have exact 245 spacing"); + const auto row = cards[index]->getParent()->getFrame(); + Check(std::abs(row.getWidth() - 1200) < 0.01, "Store row spans 1200 logical pixels"); + Check(rect.getMinX() >= row.getMinX() && rect.getMaxX() <= row.getMaxX() + 0.01, "Store card remains within the 1200-wide row"); + } + brls::Application::giveFocus(cards[4]); Pump(); Key(brls::BUTTON_NAV_DOWN); + Check(brls::Application::getCurrentFocus() == cards[9], "real Store down navigation preserves fifth column"); + Key(brls::BUTTON_NAV_UP); + Check(brls::Application::getCurrentFocus() == cards[4], "real Store up navigation returns to fifth column"); + CheckFocusedScrollInsets(cards[4], "store/fifth-column"); + brls::Application::giveFocus(Required(root, "catalog-filter")); Key(brls::BUTTON_LT); + Check(Text(root).find("Epic\n") == std::string::npos, "Store Steam filter removes Epic cards"); + for (int index = 0; index < 5; ++index) Key(brls::BUTTON_LT); + Key(brls::BUTTON_RT); + Check(Text(Required(root, "catalog-sort")).find(opennow::Tr("Store")) != std::string::npos, "real Store sort cycles to Store"); + Check(dynamic_cast(Required(root, "catalog-row-0"))->getChildren().front() == root->getView("catalog-card-1001"), "Store sort actually reorders Epic before Steam instead of just changing its label"); + Key(brls::BUTTON_RT); + Check(dynamic_cast(Required(root, "catalog-row-0"))->getChildren().front() == root->getView("catalog-card-1001"), "Publisher sort actually places the Alpha publisher first"); + Key(brls::BUTTON_RT); + Search(root, "Fixture game 1"); + if (guest) Check(root->getView("catalog-card-1000") == nullptr && ui_fixture::Snapshot().catalog_requests == 0, "guest Store search filters actual public feed without authenticated requests"); + else Until([] { const auto calls = ui_fixture::Snapshot(); return !calls.search_queries.empty() && calls.search_queries.back() == "Fixture game 1"; }, "Y search passes actual query to catalog service"); + Search(root, ""); + if (!guest) Until([] { return ui_fixture::Snapshot().catalog_requests >= 3; }, "clearing catalog search reloads feed"); + Activate(Required(root, "catalog-next")); + if (guest) Check(root->getView("catalog-card-1015") != nullptr && ui_fixture::Snapshot().public_requests == 1, "guest Store next page uses already-loaded public data without another fetch"); + else Until([] { const auto calls = ui_fixture::Snapshot(); return std::find(calls.cursors.begin(), calls.cursors.end(), "fixture-page-2") != calls.cursors.end(); }, "next page consumes actual catalog cursor"); + Activate(Required(root, "catalog-previous")); + brls::Application::giveFocus(Required(root, "catalog-card-1000")); Pump(); + const size_t size = brls::Application::getActivitiesStack().size(); Key(brls::BUTTON_A); + Check(brls::Application::getActivitiesStack().size() == size + 1 && dynamic_cast(CurrentRoot()), "Store A opens actual production detail activity"); + Key(brls::BUTTON_B); + Check(brls::Application::getActivitiesStack().size() == size, "detail B returns to real Store"); +} +void LibraryChecks(brls::View* root, bool guest, bool empty) +{ + CheckLibraryToolbar(root); + if (guest || empty) + { + Check(Text(root).find(ui_fixture::Library().empty() ? "Fixture game" : ui_fixture::Library().front().title) == std::string::npos, "guest or empty library has no fabricated games"); + return; + } + Until([] { return ui_fixture::Snapshot().library_requests > 0; }, "real Library fetch callback reached fixture"); + auto* preview = Required(root, "library-preview-title"); + opennow::CachedImage* preview_image = nullptr; + Walk(preview->getParent(), [&](brls::View* view) { + if (auto* image = dynamic_cast(view)) preview_image = image; + }); + Check(preview_image != nullptr, "actual Library preview has a native cover image"); + auto check_preview_cover = [&](const std::string& url) { + const auto calls = ui_fixture::Snapshot(); + const auto request = std::find_if(calls.cover_requests.rbegin(), calls.cover_requests.rend(), + [&](const ui_fixture::CoverCall& call) { return call.image == preview_image; }); + Check(request != calls.cover_requests.rend() && request->url == url, + "actual Library preview cover identity matches selected game at image boundary"); + }; + auto* row = Required(root, "library-row-fixture-1000"); + brls::Label* game_title = nullptr; + Walk(row, [&](brls::View* view) { + if (auto* label = dynamic_cast(view); label && label->getFullText() == ui_fixture::Library().front().title) + game_title = label; + }); + CheckStaticGameTitle(row, game_title, "library/game-title"); + CheckFocusedScrollInsets(row, "library/selected-row"); + brls::Application::giveFocus(row); Pump(); + Check(Text(preview).find(ui_fixture::Library().front().title) != std::string::npos, "focused Library game identity updates actual preview title"); + check_preview_cover(ui_fixture::Library().front().image_url); + Key(brls::BUTTON_NAV_DOWN); + Check(brls::Application::getCurrentFocus() != row, "Library down moves to another selectable row"); + Check(Text(preview).find(ui_fixture::Library().front().title) == std::string::npos, "Library preview follows changed focus identity"); + const auto* selected_row = brls::Application::getCurrentFocus(); + const auto games = ui_fixture::Library(); + const auto selected = std::find_if(games.begin(), games.end(), [&](const opennow::GameInfo& game) { + return selected_row == root->getView("library-row-" + game.uuid); + }); + Check(selected != games.end(), "native focused Library row maps to actual fixture identity"); + if (selected != games.end()) check_preview_cover(selected->image_url); + Activate(Required(root, "library-sort")); + Check(Text(Required(root, "library-sort")).find(opennow::Tr("Last Added")) != std::string::npos, "Library sort callback cycles to actual Last Added feed order"); + Tap(Required(root, "library-filter")); + Check(Text(Required(root, "library-filter")).find("Steam") != std::string::npos, "native touch activates real Library store filter"); + for (int index = 0; index < 5; ++index) Key(brls::BUTTON_LT); + Search(root, "Fixture game 1"); + Check(root->getView("library-row-fixture-1000") == nullptr, "real Library search removes unmatched row"); + Search(root, ""); + const int fetches = ui_fixture::Snapshot().library_requests; + size_t count = 0; + Walk(Required(root, "library-list"), [&count](brls::View* view) { count += dynamic_cast(view) != nullptr; }); + Check(count <= 15 && count > 0, "actual Library page bounds number of native row views"); + Activate(Required(root, "library-next")); + Check(root->getView("library-row-fixture-1015") && !root->getView("library-row-fixture-1000"), "Library next exposes next actual feed slice without retaining prior rows"); + Activate(Required(root, "library-previous")); + Check(root->getView("library-row-fixture-1000") && ui_fixture::Snapshot().library_requests == fetches, "Library previous restores game 1000 without another network fetch"); + brls::Application::giveFocus(Required(root, "library-row-fixture-1000")); Pump(); + const auto size = brls::Application::getActivitiesStack().size(); Key(brls::BUTTON_A); + Check(dynamic_cast(CurrentRoot()) != nullptr, "Library A opens actual production detail"); + Key(brls::BUTTON_B); + Check(brls::Application::getActivitiesStack().size() == size, "Library detail B returns without quitting"); + Key(brls::BUTTON_LB); + Check(root->getView("catalog") != nullptr, "real L action switches to Store"); + Key(brls::BUTTON_RB); + Check(root->getView("library") && root->getView("library-row-fixture-1000"), "real R action returns to live Library with page identity"); + brls::Application::giveFocus(Required(root, "library-row-fixture-1000")); Pump(); + const auto original_language = opennow::GetInterfaceLanguage(); + const auto before_language = ui_fixture::Snapshot(); + SaveLanguageThroughSettings(root, original_language == "ru" ? "en" : "ru"); + SaveLanguageThroughSettings(root, original_language); + const auto after_language = ui_fixture::Snapshot(); + Check(after_language.library_requests == before_language.library_requests && + after_language.catalog_requests == before_language.catalog_requests && + after_language.public_requests == before_language.public_requests && + after_language.region_requests == before_language.region_requests, + "actual language Save and retained-tab refresh cause no new feed or region requests"); + const int before_caption_cycles = ui_fixture::Snapshot().library_requests; + for (int sort = 0; sort < 4; ++sort) + { + for (int filter = 0; filter < 6; ++filter) + { + CheckLibraryToolbar(root); + Key(brls::BUTTON_LT); + } + Key(brls::BUTTON_RT); + } + Check(ui_fixture::Snapshot().library_requests == before_caption_cycles, "all native toolbar caption states remain local without feed requests"); + CheckLibraryToolbar(root); + brls::Application::giveFocus(Required(root, "library-row-fixture-1000")); Pump(); +} +void SettingsChecks(brls::View* root, const std::string& capture) +{ + auto capture_category = [&](const std::string& category) { + CheckSettingsActionRows(root, category); + CheckChildren(dynamic_cast(root), "settings-" + category); + CheckFonts(root); DrainToasts(); Screenshot(capture + ".settings-" + category + ".ppm"); + }; + capture_category("account"); + Activate(Required(root, "settings/category/stream")); + Check(root->getView("settings/option/zortos-community-proxy") == nullptr, + "retired community proxy is absent from the Stream settings page"); + auto* bitrate = Action(root, "Bitrate"); Activate(bitrate); Key(brls::BUTTON_X); + Check(opennow::LoadStreamSettings().bitrate_kbps == 16000, "real A bitrate cycle and X Save persist 16 Mbps"); + Activate(bitrate); Key(brls::BUTTON_Y); Key(brls::BUTTON_X); + Check(opennow::LoadStreamSettings().bitrate_kbps == 16000, "real Y Revert prevents unsaved bitrate persistence"); + capture_category("stream"); + Activate(Required(root, "settings/category/app")); + auto* reminder = Action(root, "Queue notify at"); Activate(reminder); Key(brls::BUTTON_X); + Check(opennow::LoadStreamSettings().queue_notify_threshold == 20, "queue reminder alone marks settings dirty and is saved by real X callback"); + Activate(reminder); Key(brls::BUTTON_Y); Key(brls::BUTTON_X); + Check(opennow::LoadStreamSettings().queue_notify_threshold == 20, "reminder Y Revert retains saved value"); + capture_category("app"); + Activate(Required(root, "settings/category/preferences")); capture_category("preferences"); + for (const char* category : {"preferences", "account", "stream"}) Activate(Required(root, std::string("settings/category/") + category)); + brls::Application::giveFocus(Action(root, "Bitrate")); Pump(); + Check(!Text(Required(root, "settings/help")).empty(), "actual focused setting help is populated"); + Check(ui_fixture::Snapshot().region_requests == 0 && ui_fixture::Snapshot().latency_measurements == 0, "category construction and focused help do not trigger implicit region or latency requests"); +} +void DetailChecks(opennow::GameDetailView* root) +{ + CheckVisibleActionRows(root, "detail/actions"); + auto* description = dynamic_cast(Required(root, "detail/description")); + Check(description && description->getChildren().front()->getHeight() > description->getHeight(), "actual production description overflows bounded scroll viewport"); + brls::Application::giveFocus(Required(root, "detail/play")); Pump(); Key(brls::BUTTON_NAV_UP); + Check(brls::Application::getCurrentFocus() == description, "detail Up routes from Play to real description scroller"); + SDL_Event scroll {}; + scroll.type = SDL_KEYDOWN; scroll.key.keysym.scancode = SDL_SCANCODE_DOWN; scroll.key.keysym.sym = SDLK_DOWN; + SDL_PushEvent(&scroll); Pump(16); scroll.type = SDL_KEYUP; SDL_PushEvent(&scroll); Pump(); + Check(description->getContentOffsetY() > 0, "held native SDL Down scrolls actual overflow description"); + Activate(Action(root, "Play on GeForce NOW")); + Check(ui_fixture::Snapshot().launches.size() == 1, "actual detail Play callback reaches launch boundary once"); + const auto call = ui_fixture::Snapshot().launches.front(); + Check(call.app_id == "1000" && call.store == "Steam" && call.user_id == "native-fixture-user" && call.title == ui_fixture::Library().front().title, "actual Play passes selected launch ID, Steam, current account identity and internal title"); + Until([] { return !ui_fixture::Snapshot().handed_off_sessions.empty(); }, "real queue worker reaches recording stream handoff"); + Check(ui_fixture::Snapshot().played_ids == std::vector{"fixture-1000"}, "real queue worker records selected history identity"); + Check(ui_fixture::Snapshot().start_settings == ui_fixture::Snapshot().handoff_settings, "actual Play forwards complete request settings to stream handoff unchanged"); + Activate(Action(root, "Store")); Key(brls::BUTTON_B); + Activate(Action(root, "Create Switch shortcut")); + Until([] { return !ui_fixture::Snapshot().shortcuts.empty(); }, "actual shortcut worker reaches external shortcut boundary"); + const auto request = ui_fixture::Snapshot().shortcuts.front(); + Check(request.launch_app_id == "1000" && request.store == "Steam" && request.game_id == "fixture-1000", "actual shortcut request preserves launch/store/game IDs"); + if (CurrentRoot() != root) Key(brls::BUTTON_B); + auto data = opennow::MakeLibraryGameDetail(ui_fixture::Library().front()); + data.game_id = "fixture-multi"; + data.variants.push_back({"2000", "Epic", "OWNED", {}, "AVAILABLE", true}); + ui_fixture::ClearLauncherPreference(); + auto* multi = new opennow::GameDetailView(opennow::GfnClient(), data); + brls::Application::pushActivity(new brls::Activity(multi)); Pump(); + const size_t starts = ui_fixture::Snapshot().launches.size(); + Activate(Required(multi, "detail/play")); + Check(dynamic_cast(CurrentRoot()) != nullptr, "multi-variant Play opens actual native store selector"); + Key(brls::BUTTON_NAV_DOWN); Key(brls::BUTTON_A); + Until([starts] { return ui_fixture::Snapshot().launches.size() > starts; }, "native selector dismissal launches selected variant"); + Check(ui_fixture::Snapshot().launches.back().app_id == "2000" && ui_fixture::Snapshot().launches.back().store == "Epic", "actual selector launches Epic variant, not default Steam variant"); + Check(ui_fixture::Snapshot().saved_variants.back() == "2000", "actual selector persists selected launcher preference"); + Until([starts] { return ui_fixture::Snapshot().handed_off_sessions.size() > starts; }, "selected variant completes real queue handoff"); + Key(brls::BUTTON_B); + ui_fixture::ClearLauncherPreference(); + auto* guarded = new opennow::GameDetailView(opennow::GfnClient(), data); + brls::Application::pushActivity(new brls::Activity(guarded)); Pump(); + Activate(Required(guarded, "detail/play")); Key(brls::BUTTON_NAV_DOWN); + const size_t guarded_starts = ui_fixture::Snapshot().launches.size(); + const auto auth = *opennow::AppState::Instance().session(); + brls::Application::onControllerButtonPressed(brls::BUTTON_A, false); + opennow::AppState::Instance().ActivateSession(auth); Pump(48); + Check(ui_fixture::Snapshot().launches.size() == guarded_starts, "account change between selection and deferred dismiss prevents stale-account launch"); + Key(brls::BUTTON_B); + description->setContentOffsetY(0, false); Pump(); } -int main(int argc, char** argv) +void QueueChecks(const opennow::AuthSession& session, const std::string& capture) +{ + opennow::QueueDisplayState presentation {"Queue presentation fixture", "Checking your NVIDIA account", "Waiting for authorization", 123, 0, false}; + auto* presentation_view = new opennow::QueueView(presentation, opennow::LoadStreamSettings(), [] {}, [] {}); + brls::Application::pushActivity(new brls::Activity(presentation_view)); Pump(); + for (int stage : {0, 1, 2, 3}) + { + presentation.stage = stage; + presentation.status = stage == 0 ? "Checking your NVIDIA account" : stage == 1 ? "Waiting in queue..." : stage == 2 ? "Preparing your cloud rig" : "Starting the video stream"; + presentation_view->Update(presentation); Pump(); + const auto expected = stage == 1 ? brls::Visibility::VISIBLE : brls::Visibility::GONE; + Check(Required(presentation_view, "queue/position")->getParent()->getVisibility() == expected, + "queue number cards only occupy space during the actual queue phase"); + Check(Required(presentation_view, "queue/position-heading")->getVisibility() == expected, + "position-in-queue heading is only visible with the actual number cards"); + CheckChildren(presentation_view, "queue-presentation-" + std::to_string(stage)); + Screenshot(capture + ".phase-" + std::to_string(stage) + ".ppm"); + } + presentation.stage = 1; presentation.position = -1; + presentation_view->Update(presentation); Pump(); + Check(Required(presentation_view, "queue/position")->getParent()->getVisibility() == brls::Visibility::GONE, + "unknown queue position has no placeholder number card"); + presentation.position = 123; presentation.failed = true; + presentation_view->Update(presentation); Pump(); + Check(Required(presentation_view, "queue/position")->getParent()->getVisibility() == brls::Visibility::GONE, + "failed session hides stale queue number cards"); + brls::Application::popActivity(brls::TransitionAnimation::NONE); Pump(); + ui_fixture::SetSessionMode(ui_fixture::SessionMode::Waiting); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1000", "Native queue fixture", "Steam", "Native queue fixture", "fixture-1000", "fixture://cover/1000"); + Until([] { return opennow::GetCurrentQueuePosition() == 123; }, "real queue worker publishes actual three-digit position"); + Check(Text(Required(CurrentRoot(), "queue/position")).find("123") != std::string::npos, "native queue displays all three position digits"); + CheckChildren(dynamic_cast(CurrentRoot()), "queue"); CheckFonts(CurrentRoot()); + CheckVisibleActionRows(CurrentRoot(), "queue/actions"); Screenshot(capture + ".queue.ppm"); + Tap(Required(CurrentRoot(), "queue/minimize")); + Check(opennow::IsQueueMinimized(), "actual Minimize dismisses UI without canceling worker"); + const auto original = opennow::LoadStreamSettings(); + auto changed = original; + changed.bitrate_kbps = 25000; changed.queue_notify_threshold = 5; changed.region = "https://changed-region.fixture.invalid/"; + Check(opennow::SaveStreamSettings(changed), "fixture saves new settings while actual queue is minimized"); + const size_t starts_before_second = ui_fixture::Snapshot().launches.size(); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "9999", "Blocked second launch", "Steam"); Pump(); + Check(ui_fixture::Snapshot().launches.size() == starts_before_second, "second launch is blocked while actual queue exists"); + Check(Text(Required(CurrentRoot(), "queue/stream-summary")).find("12 Mbps") != std::string::npos && + Text(Required(CurrentRoot(), "queue/region")).find(opennow::ui::ConfiguredLocation(original.region)) != std::string::npos && + Text(Required(CurrentRoot(), "queue/reminder")).find("10") != std::string::npos, "restored actual queue retains original configured bitrate, region and reminder"); + Activate(Required(CurrentRoot(), "queue/minimize")); + const int polls = ui_fixture::Snapshot().polls; + Until([polls] { return ui_fixture::Snapshot().polls >= polls + 2; }, "real minimized queue keeps polling without another worker", 12); + const auto notifications = ui_fixture::Snapshot().notifications; + const auto reminder = opennow::Tr("Queue almost ready. Tap the Queue chip to return."); + Check(std::count(notifications.begin(), notifications.end(), reminder) == 1, "actual minimized queue reminds once using original threshold despite changed saved settings"); + opennow::RestoreMinimizedQueueDialog(); Pump(); + Check(!opennow::IsQueueMinimized() && CurrentRoot()->getView("queue/position"), "actual restore reattaches native queue activity"); + Activate(Required(CurrentRoot(), "queue/cancel")); + Until([] { return !ui_fixture::Snapshot().stopped_sessions.empty(); }, "actual queue cancellation stops adopted remote session"); + Check(opennow::GetCurrentQueuePosition() == -1 && !opennow::IsQueueMinimized(), "cancel clears active queue ownership"); + ui_fixture::SetSessionMode(ui_fixture::SessionMode::BlockStart); + const size_t before = ui_fixture::Snapshot().launches.size(), stops = ui_fixture::Snapshot().stopped_sessions.size(); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1001", "Cancel-before-adopt fixture", "Epic"); + Until([before] { return ui_fixture::Snapshot().launches.size() > before; }, "actual start worker reaches controlled external gate"); + Activate(Required(CurrentRoot(), "queue/cancel")); ui_fixture::ReleaseStart(); + Until([stops] { return ui_fixture::Snapshot().stopped_sessions.size() > stops; }, "session returned after cancel is stopped instead of adopted"); + Check(ui_fixture::Snapshot().handed_off_sessions.empty(), "canceled queue never hands off a stream"); + opennow::SaveStreamSettings(original); + for (const auto mode : {ui_fixture::SessionMode::Unknown, ui_fixture::SessionMode::Patching, ui_fixture::SessionMode::Confirmation, ui_fixture::SessionMode::Failure}) + { + ui_fixture::SetSessionMode(mode); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1000", "Actual phase fixture", "Steam"); Pump(48); + if (mode == ui_fixture::SessionMode::Unknown) + Check(Text(Required(CurrentRoot(), "queue/position")).find(opennow::Tr("Unknown")) != std::string::npos, "real queue with unavailable position stays unknown"); + else if (mode == ui_fixture::SessionMode::Patching) + Check(Text(Required(CurrentRoot(), "queue/status")).find(opennow::Tr("Updating the game")) != std::string::npos, "actual patching state takes priority over positive queue position"); + else if (mode == ui_fixture::SessionMode::Confirmation) + Check(Text(CurrentRoot()).find(opennow::Tr("Waiting for NVIDIA session ads or confirmation...")) != std::string::npos, "actual confirmation state takes priority over positive queue position"); + else { CheckChildren(dynamic_cast(CurrentRoot()), "queue-failure"); Check(opennow::GetCurrentQueuePosition() == -1, "actual failed queue relinquishes running ownership"); } + Check(Required(CurrentRoot(), "queue/position")->getParent()->getVisibility() == brls::Visibility::GONE, + "unknown, patching, confirmation and failure states never render queue number cards"); + Activate(Required(CurrentRoot(), "queue/cancel")); + } + ui_fixture::SetSessionMode(ui_fixture::SessionMode::BlockStart); + const size_t epoch_starts = ui_fixture::Snapshot().launches.size(), epoch_stops = ui_fixture::Snapshot().stopped_sessions.size(); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1002", "Account guard fixture", "Steam"); + Until([epoch_starts] { return ui_fixture::Snapshot().launches.size() > epoch_starts; }, "account-guarded worker reaches controlled start boundary"); + auto replacement = session; replacement.user.user_id = "replacement-native-fixture-user"; + opennow::AppState::Instance().ActivateSession(replacement); ui_fixture::ReleaseStart(); + Until([epoch_stops] { return ui_fixture::Snapshot().stopped_sessions.size() > epoch_stops; }, "actual account-generation guard cancels and cleans up prior account session"); + Check(ui_fixture::Snapshot().handed_off_sessions.empty(), "prior-account queue never hands off a stream"); + opennow::AppState::Instance().ActivateSession(session); opennow::SaveStreamSettings(original); + ui_fixture::SetSessionMode(ui_fixture::SessionMode::BlockStart); + const size_t frozen_starts = ui_fixture::Snapshot().launches.size(), frozen_handoffs = ui_fixture::Snapshot().handed_off_sessions.size(); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1003", "Frozen settings handoff fixture", "Steam"); + Until([frozen_starts] { return ui_fixture::Snapshot().launches.size() > frozen_starts; }, "gated actual request captures explicit stream settings"); + Activate(Required(CurrentRoot(), "queue/minimize")); + Check(opennow::SaveStreamSettings(changed), "saved settings change while real start request is gated and minimized"); + opennow::RestoreMinimizedQueueDialog(); Pump(); + Check(Text(Required(CurrentRoot(), "queue/stream-summary")).find("12 Mbps") != std::string::npos, "restored gated queue still displays original configuration"); + Screenshot(capture + ".restored.ppm"); ui_fixture::ReleaseStart(); + Until([frozen_handoffs] { return ui_fixture::Snapshot().handed_off_sessions.size() > frozen_handoffs; }, "gated real worker completes actual stream handoff"); + Check(ui_fixture::Snapshot().start_settings.back() == original && ui_fixture::Snapshot().handoff_settings.back() == original, "StartSession and PresentCloudStream receive same entire frozen StreamSettings after saved-settings change"); + Check(opennow::LoadStreamSettings() == changed, "handoff does not roll back independently saved next-stream settings"); + ui_fixture::SetSessionMode(ui_fixture::SessionMode::BlockStart); + const size_t covered_starts = ui_fixture::Snapshot().launches.size(), covered_handoffs = ui_fixture::Snapshot().handed_off_sessions.size(); + const size_t original_stack = brls::Application::getActivitiesStack().size(); + opennow::LaunchSessionDialog(opennow::GfnClient(), session, "1004", "Covered queue handoff fixture", "Steam"); + Until([covered_starts] { return ui_fixture::Snapshot().launches.size() > covered_starts; }, "covered queue worker reaches external gate"); + auto* dialog = new brls::Dialog("Native dialog covering the production queue"); + dialog->addButton("Close", [] {}); dialog->setCancelable(true); dialog->open(); Pump(); ui_fixture::ReleaseStart(); + Until([covered_handoffs] { return ui_fixture::Snapshot().handed_off_sessions.size() > covered_handoffs; }, "covered real queue hands off exactly when ready"); + Check(CurrentRoot() == dialog && brls::Application::getActivitiesStack().size() == original_stack + 2, "covered queue readiness does not pop unrelated top dialog"); + Check(ui_fixture::Snapshot().handed_off_sessions.size() == covered_handoffs + 1, "covered queue invokes actual handoff callback exactly once"); + Key(brls::BUTTON_B); + Until([original_stack] { return brls::Application::getActivitiesStack().size() == original_stack; }, "closing cover dialog removes stale queue on resume"); +} +void OverlayChecks() { - opennow::SetInterfaceLanguage(argc > 1 ? argv[1] : "en"); - brls::Logger::setLogLevel(brls::LogLevel::LOG_ERROR); - if (!brls::Application::init()) - return 2; - brls::Application::createWindow("OpenNOW native UI layout test"); - brls::Application::getPlatform()->setThemeVariant(brls::ThemeVariant::DARK); - brls::Application::setGlobalQuit(false); - if (argc > 4 && std::string(argv[4]) == "1920") - { - SDL_SetWindowSize(SDL_GL_GetCurrentWindow(), 1920, 1080); - int width = 0; - int height = 0; - SDL_GL_GetDrawableSize(SDL_GL_GetCurrentWindow(), &width, &height); - glViewport(0, 0, width, height); - brls::Application::setWindowSize(1920, 1080); - } - opennow::AuthSession session; - session.user.display_name = argc > 2 ? argv[2] : "Player"; - session.user.membership_tier = "Ultimate"; - session.user.membership_tier_verified = true; - session.subscription.available = true; - session.subscription.remaining_hours = 100.5; - session.subscription.has_storage = true; - session.subscription.storage_size_gb = 1000; - if (session.user.display_name != "guest") - opennow::AppState::Instance().SetSession(session); + opennow::StreamOverlaySample sample; + const auto unknown = opennow::FormatStreamOverlay(sample); + Check(unknown.metrics[2] == opennow::Tr("Unknown") && unknown.metrics[3] == opennow::Tr("Unknown"), "real overlay formatter keeps unavailable RTT and zero-packet loss unknown"); + sample.game_title = "A long live game title Жї 玩家"; sample.provider = "GeForce NOW"; + sample.presented_fps = 60; sample.incoming_fps = 60; sample.decoded_fps = 59; sample.bitrate_mbps = 15.8; + sample.rtt_ms = 18; sample.width = 1280; sample.height = 720; sample.decode_us_p95 = 6100; sample.render_us_p95 = 2400; + sample.queue_size = 1; sample.queue_high_water = 4; sample.packets_received = 9998; sample.sequence_gaps = 2; + sample.codec = "H.264"; sample.location = "Auto"; sample.network = "Wi-Fi 5 GHz"; sample.peer_connected = true; + auto* view = new opennow::StreamOverlayView(); view->Update(sample); view->UpdateElapsed(2538); + std::printf("OVERLAY loss=%s decode=%s render=%s queue=%s elapsed=%s\n", view->Display().metrics[3].c_str(), view->Display().details[2].c_str(), view->Display().details[3].c_str(), view->Display().details[4].c_str(), view->Display().elapsed.c_str()); + Check(view->Display().metrics[3] == "0.02", "actual overlay loss estimate uses received plus sequence gaps denominator"); + Check(view->Display().details[2] == "6.1 ms" && view->Display().details[3] == "2.4 ms", "actual overlay preserves measured p95 fractions"); + Check(view->Display().details[4] == "1 / 4" && view->Display().elapsed == "42:18", "actual overlay formats queue high water and elapsed time"); + view->setDimensions(1280, 720); brls::Application::pushActivity(new brls::Activity(view)); Pump(); +} +} - auto* frame = new opennow::TopBarFrame(); - brls::Box* header = nullptr; - brls::Box* row = nullptr; - std::vector actions; - frame->addTab(opennow::Tr("Store"), [] { return new brls::Box(); }); - frame->addTab(opennow::Tr("Library"), [&] { - auto* page = new brls::Box(brls::Axis::COLUMN); - page->setPadding(18, 32, 18, 32); - for (const auto* text : {"Y Search", "ZL All stores", "ZR Last Played", "X More / Refresh"}) - actions.push_back(opennow::ui::MakeGameBrowserActionButton(text)); - header = opennow::ui::MakeGameBrowserHeader("My Library", actions); - page->addView(header); - row = new brls::Box(brls::Axis::ROW); - for (int i = 0; i < 5; ++i) +int main(int argc, char** argv) +{ + try + { + const std::string language = argc > 1 ? argv[1] : "en", account = argc > 2 ? argv[2] : "Player"; + const std::string capture = argc > 3 ? argv[3] : "native.ppm", scenario = argc > 5 ? argv[5] : "library"; + capture_path = capture; + const int physical_width = argc > 4 ? std::stoi(argv[4]) : 1280; + const bool guest = account == "guest", empty = scenario == "empty" || scenario == "empty-store"; + ui_fixture::Reset(capture + ".storage", empty); + opennow::StreamSettings initial; initial.interface_language = language; + Check(opennow::SaveStreamSettings(initial), "real settings fixture initializes isolated scratch persistence"); + opennow::SetInterfaceLanguage(language); brls::Logger::setLogLevel(brls::LogLevel::LOG_ERROR); + if (!brls::Application::init()) return 2; + brls::Application::createWindow("OpenNOW production native UI tests"); opennow::ui::InitializeThemeAndFonts(); + brls::Application::setGlobalQuit(false); + if (physical_width == 1920) { - auto* card = new opennow::GameCardView({"A very long game title for clipping verification", "Steam", "Recently played", ""}, [] {}); - card->setMarginRight(i < 4 ? 24 : 0); - row->addView(card); + SDL_SetWindowSize(SDL_GL_GetCurrentWindow(), 1920, 1080); + int width, height; SDL_GL_GetDrawableSize(SDL_GL_GetCurrentWindow(), &width, &height); + glViewport(0, 0, width, height); brls::Application::setWindowSize(1920, 1080); } - page->addView(row); - return page; - }); - frame->addTab(opennow::Tr("Settings"), [] { return new brls::Box(); }); - frame->focusTab(1); - brls::Application::pushActivity(new brls::Activity(frame)); - for (int i = 0; i < 20; ++i) - if (!brls::Application::mainLoop()) - return 2; - int drawable_width = 0; - int drawable_height = 0; - SDL_GL_GetDrawableSize(SDL_GL_GetCurrentWindow(), &drawable_width, &drawable_height); - const int expected_width = argc > 4 && std::string(argv[4]) == "1920" ? 1920 : 1280; - Check(drawable_width == expected_width && drawable_height == expected_width * 9 / 16, - "native drawable reaches requested physical resolution"); - int viewport[4] {}; - glGetIntegerv(GL_VIEWPORT, viewport); - Check(viewport[2] == drawable_width && viewport[3] == drawable_height, - "OpenGL viewport covers native drawable"); - Check(brls::Application::windowWidth == static_cast(drawable_width) && - brls::Application::windowHeight == static_cast(drawable_height), - "Borealis physical dimensions match native drawable"); - Check(brls::Application::contentWidth == 1280 && brls::Application::contentHeight == 720, - "1280x720 logical layout"); - CheckChildren(static_cast(frame->getChildren()[0]), "topbar"); - auto* tabs = static_cast(static_cast(frame->getChildren()[0])->getChildren()[1]); - for (auto* tab : tabs->getChildren()) - { - auto* label_box = static_cast(static_cast(tab)->getChildren()[0]); - Check(label_box->getChildren()[0]->getFrame().getHeight() <= 20, "tab label stays on one line"); - } - CheckChildren(header, "browser_header"); - CheckChildren(row, "grid"); - for (auto* card : row->getChildren()) - Check(card->getFrame().getWidth() == 224, "grid card retains 224 logical pixels"); - auto& cards = row->getChildren(); - opennow::WireVerticalGridNavigation({actions, cards}); - Check(cards.back()->getCustomNavigationRoutePtr(brls::FocusDirection::UP) == actions.back(), - "last grid column clamps to last toolbar action"); - Check(cards.back()->getCustomNavigationRoutePtr(brls::FocusDirection::DOWN) == cards.back(), - "bottom grid edge stays on live card"); - if (argc > 5 && std::string(argv[5]) == "detail") - { - opennow::GameDetailData data; - data.title = "A game title"; - data.subtitle = "GeForce NOW library"; - data.stores = "Steam"; - data.description = "A description for the native layout fixture."; - data.owned = true; - auto* detail = new opennow::GameDetailView(opennow::GfnClient(), data); - brls::Application::pushActivity(new brls::Activity(detail)); - for (int i = 0; i < 20; ++i) - brls::Application::mainLoop(); - CheckChildren(detail, "detail"); - } - if (argc > 3) - Screenshot(argv[3]); - brls::Application::quit(); - while (brls::Application::mainLoop()) {} + opennow::AuthSession session; + session.user.user_id = "native-fixture-user"; session.user.display_name = account; + session.user.membership_tier = "Ultimate"; session.user.membership_tier_verified = true; + session.subscription.available = true; session.subscription.remaining_hours = 100.5; + session.subscription.has_storage = true; session.subscription.storage_size_gb = 1000; + auto& state = opennow::AppState::Instance(); + if (!guest) state.SetSession(session); else state.MarkSessionLoaded(); + state.SetLibraryGames(ui_fixture::Library()); + auto* frame = new opennow::TopBarFrame(); + frame->addTab("Store", [] { return new opennow::CatalogTab(); }); + frame->addTab("Library", [] { return new opennow::LibraryTab(); }); + frame->addTab("Settings", [] { return new opennow::SettingsTab(); }); + frame->focusTab(scenario == "store" || scenario == "empty-store" ? 0 : scenario == "settings" ? 2 : 1); + brls::Application::pushActivity(new brls::Activity(frame)); Pump(); + int width, height, viewport[4] {}; SDL_GL_GetDrawableSize(SDL_GL_GetCurrentWindow(), &width, &height); glGetIntegerv(GL_VIEWPORT, viewport); + Check(width == physical_width && height == physical_width * 9 / 16, "native drawable reaches requested resolution"); + Check(viewport[2] == width && viewport[3] == height, "OpenGL viewport covers drawable"); + Check(brls::Application::windowWidth == static_cast(width) && brls::Application::windowHeight == static_cast(height), "physical dimensions match drawable"); + Check(brls::Application::contentWidth == 1280 && brls::Application::contentHeight == 720, "layout remains logical 1280x720"); + CheckChildren(frame, "production"); + const auto header = Required(frame, "shell-header")->getFrame(), footer = Required(frame, "shell-footer")->getFrame(); + Check(header.getHeight() == 76 && footer.getHeight() == 60 && footer.getMinY() == 660, "production shell retains 76-pixel header and single 60-pixel footer at y660"); + Walk(Required(frame, "shell-tabs"), [](brls::View* view) { + if (auto* label = dynamic_cast(view)) Check(label->isSingleLine() && label->getHeight() <= 20, "production tab label stays on one line"); + }); + CheckFonts(frame); + CheckVisibleActionRows(frame, "initial-actions"); + opennow::StreamSettings fractional; fractional.bitrate_kbps = 12345; + Check(opennow::ui::StreamSummary(fractional).find("12.345") != std::string::npos, "configured stream summary preserves 12345 kbps"); + fractional.bitrate_kbps = 12500; + Check(opennow::ui::StreamSummary(fractional).find("12.5") != std::string::npos, "configured stream summary preserves 12500 kbps"); + fractional.bitrate_kbps = 12000; + Check(opennow::ui::StreamSummary(fractional).find("12 Mbps") != std::string::npos, "configured stream summary preserves integer 12000 kbps"); + if (scenario == "store" || scenario == "empty-store") StoreChecks(frame, empty, guest); + else if (scenario == "settings") SettingsChecks(frame, capture); + else if (scenario == "queue") { DrainToasts(); QueueChecks(session, capture); } + else if (scenario == "overlay") OverlayChecks(); + else if (scenario == "detail") + { + auto data = opennow::MakeLibraryGameDetail(ui_fixture::Library().front()); + auto* detail = new opennow::GameDetailView(opennow::GfnClient(), std::move(data)); + brls::Application::pushActivity(new brls::Activity(detail)); Pump(); + CheckChildren(detail, "detail"); CheckFonts(detail); DetailChecks(detail); + } + else LibraryChecks(frame, guest, empty); + DrainToasts(); Screenshot(capture); + Check(dynamic_cast(CurrentRoot()) == nullptr && dynamic_cast(CurrentRoot()) == nullptr, "production callbacks leave no unexpected modal activity"); + brls::Application::quit(); while (brls::Application::mainLoop()) {} + } + catch (const std::exception& error) + { + Check(false, std::string("native harness exception: ") + error.what()); + if (argc > 3 && CurrentRoot()) Screenshot(std::string(argv[3]) + ".failure.ppm"); + } + std::printf("RESULT failures=%d\n", failures); return failures ? 1 : 0; } diff --git a/tests/ui_layout/ui_layout_fixtures.hpp b/tests/ui_layout/ui_layout_fixtures.hpp new file mode 100644 index 00000000..066a373c --- /dev/null +++ b/tests/ui_layout/ui_layout_fixtures.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "home_shortcut.hpp" +#include "models.hpp" +#include "stream_settings.hpp" + +#include +#include +#include + +namespace opennow { class CachedImage; } + +namespace ui_fixture +{ +struct LaunchCall { std::string user_id, app_id, title, store; }; +struct CoverCall { const opennow::CachedImage* image; std::string url; }; +struct TextDraw +{ + std::string text; + std::array bounds; + std::array transform; + float x, y, raw_x, raw_y; + bool scrolling_path; +}; +struct Calls +{ + int library_requests = 0; + int catalog_requests = 0; + int public_requests = 0; + int polls = 0; + int region_requests = 0; + int latency_measurements = 0; + std::vector search_queries, cursors; + std::vector launches; + std::vector shortcuts; + std::vector saved_variants, cover_urls, stopped_sessions; + std::vector cover_requests; + std::vector handed_off_sessions, played_ids, notifications; + std::vector start_settings, handoff_settings; +}; +enum class SessionMode { Ready, Waiting, BlockStart, Unknown, Patching, Confirmation, Failure }; +void Reset(std::string storage_directory, bool empty); +Calls Snapshot(); +std::vector Library(); +std::vector Catalog(); +void SetSessionMode(SessionMode mode); +void ReleaseStart(); +void ClearLauncherPreference(); +void RecordNotification(const std::string& text); +void BeginTextCapture(); +std::vector EndTextCapture(); +} diff --git a/tests/ui_layout/ui_layout_stubs.cpp b/tests/ui_layout/ui_layout_stubs.cpp index 650f6637..65d7397b 100644 --- a/tests/ui_layout/ui_layout_stubs.cpp +++ b/tests/ui_layout/ui_layout_stubs.cpp @@ -1,44 +1,264 @@ +#include "ui_layout_fixtures.hpp" +#include "app_paths.hpp" #include "avatar_utils.hpp" #include "cover_image_cache.hpp" -#include "home_shortcut.hpp" +#include "gfn_client.hpp" +#include "cloud_launch_internal.hpp" +#include "network_utils.hpp" #include "nte_credentials.hpp" +#include "play_history.hpp" #include "ui_helpers.hpp" -namespace opennow +#include +#include +#include +#include +#include +#include + +namespace +{ +std::mutex fixture_mutex; +ui_fixture::Calls calls; +std::vector library; +std::vector catalog; +std::string storage, saved_variant; +ui_fixture::SessionMode session_mode = ui_fixture::SessionMode::Ready; +std::condition_variable start_gate; +bool start_released = false; +bool capture_text = false; +std::vector text_draws; + +void RecordTextDraw(NVGcontext* vg, float x, float y, const char* text, const char* end, bool scrolling_path) +{ + if (!capture_text || !text || text == end || !*text) return; + float bounds[4] {}, transform[6] {}; + nvgTextBounds(vg, x, y, text, end, bounds); + nvgCurrentTransform(vg, transform); + ui_fixture::TextDraw draw; + draw.text = end ? std::string(text, end) : std::string(text); + std::copy(std::begin(transform), std::end(transform), draw.transform.begin()); + draw.raw_x = x; + draw.raw_y = y; + const float scale = brls::Application::windowScale; + nvgTransformPoint(&draw.bounds[0], &draw.bounds[1], transform, bounds[0], bounds[1]); + nvgTransformPoint(&draw.bounds[2], &draw.bounds[3], transform, bounds[2], bounds[3]); + nvgTransformPoint(&draw.x, &draw.y, transform, x, y); + for (auto& value : draw.bounds) value /= scale; + draw.x /= scale; + draw.y /= scale; + draw.scrolling_path = scrolling_path; + text_draws.push_back(std::move(draw)); +} +} + +namespace ui_fixture +{ +void Reset(std::string directory, bool empty) { + std::lock_guard lock(fixture_mutex); + calls = {}; + storage = std::move(directory); + std::filesystem::create_directories(storage); + library.clear(); + catalog.clear(); + saved_variant.clear(); + session_mode = SessionMode::Ready; + start_released = false; + if (empty) return; + for (int index = 0; index < 21; ++index) + { + opennow::GameInfo game; + game.id = std::to_string(1000 + index); + game.uuid = "fixture-" + game.id; + game.launch_app_id = game.id; + game.title = index == 0 ? "A very long game title for native clipping verification Жї 玩家" : + std::string(1, static_cast('B' + index)) + " Fixture game " + std::to_string(index); + game.description = "A native fixture description with long wrapping text. " + "This is test data at the external feed boundary, not a runtime catalog entry. " + "Описание игры. 游戏描述。"; + const std::string paragraph = game.description; + for (int line = 0; line < 12; ++line) game.description += "\n" + paragraph; + game.publisher = index % 2 == 0 ? "Zulu publisher" : "Alpha publisher"; + game.available_stores = {index % 2 == 0 ? "Steam" : "Epic"}; + game.membership_tier_label = "Free"; + game.image_url = "fixture://cover/" + game.id; + game.is_in_library = true; + game.last_played = index == 0 ? "2026-10-02T12:00:00Z" : + index == 1 ? "2026-09-29T12:00:00Z" : index == 2 ? "2026-09-01T12:00:00Z" : ""; + game.variants = {{game.id, game.available_stores.front(), "OWNED", game.last_played, "AVAILABLE", true}}; + library.push_back(game); + opennow::PublicGame item; + item.id = game.id; + item.uuid = game.uuid; + item.launch_app_id = game.id; + item.title = game.title; + item.publisher = game.publisher; + item.store = game.available_stores.front(); + item.image_url = game.image_url; + item.is_in_library = index % 3 == 0; + item.variants = game.variants; + catalog.push_back(std::move(item)); + } +} +Calls Snapshot() { std::lock_guard lock(fixture_mutex); return calls; } +std::vector Library() { std::lock_guard lock(fixture_mutex); return library; } +std::vector Catalog() { std::lock_guard lock(fixture_mutex); return catalog; } +void SetSessionMode(SessionMode mode) { std::lock_guard lock(fixture_mutex); session_mode = mode; start_released = false; } +void ReleaseStart() { { std::lock_guard lock(fixture_mutex); start_released = true; } start_gate.notify_all(); } +void ClearLauncherPreference() { std::lock_guard lock(fixture_mutex); saved_variant.clear(); } +void RecordNotification(const std::string& text) { std::lock_guard lock(fixture_mutex); calls.notifications.push_back(text); } +void BeginTextCapture() { text_draws.clear(); capture_text = true; } +std::vector EndTextCapture() { capture_text = false; return std::move(text_draws); } +} +extern "C" float __real_nvgText(NVGcontext*, float, float, const char*, const char*); +extern "C" float __wrap_nvgText(NVGcontext* vg, float x, float y, const char* text, const char* end) +{ + RecordTextDraw(vg, x, y, text, end, true); + return __real_nvgText(vg, x, y, text, end); +} +extern "C" float __real_nvgTextWithCursor(NVGcontext*, float, float, const char*, const char*, int); +extern "C" float __wrap_nvgTextWithCursor(NVGcontext* vg, float x, float y, const char* text, const char* end, int cursor) +{ + RecordTextDraw(vg, x, y, text, end, false); + return __real_nvgTextWithCursor(vg, x, y, text, end, cursor); +} + +extern "C" void RealNotify(const std::string& text) + asm("__real__ZN4brls11Application6notifyERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE"); +extern "C" void ObserveNotify(const std::string& text) + asm("__wrap__ZN4brls11Application6notifyERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE"); +extern "C" void ObserveNotify(const std::string& text) +{ + ui_fixture::RecordNotification(text); + RealNotify(text); +} + +namespace opennow +{ +const std::string& AppHomePath() { return storage; } +const std::string& LegacyAppHomePath() { return storage; } +void PrepareAppStorage() { std::filesystem::create_directories(storage); } CachedImage::~CachedImage() = default; void CachedImage::draw(NVGcontext* vg, float x, float y, float width, float height, brls::Style style, brls::FrameContext* ctx) +{ brls::Image::draw(vg, x, y, width, height, style, ctx); } +void SetCachedCoverImage(CachedImage* image, const std::string& url) { - brls::Image::draw(vg, x, y, width, height, style, ctx); -} -void SetCachedCoverImage(CachedImage* image, const std::string&) -{ + { + std::lock_guard lock(fixture_mutex); + calls.cover_urls.push_back(url); + calls.cover_requests.push_back({image, url}); + } image->setImageFromRes("img/opennow-logo-mark.png"); } void SetCachedAvatarImage(CachedImage*, const std::string&) {} -std::string ResolveAvatarUrl(const AuthUser&) { return "fixture-avatar"; } -int GetCurrentQueuePosition() { return 123; } -bool IsQueueMinimized() { return true; } -void RestoreMinimizedQueueDialog() {} +void CachedImage::SetUrl(const std::string& url, bool) { SetCachedCoverImage(this, url); } +CoverImageCacheStats InspectCoverImageCache() { return {4096, 2}; } +std::size_t ClearCoverImageCache() { return 2; } +void ShutdownCoverImageWorker() {} +std::string ResolveAvatarUrl(const AuthUser&) { return {}; } GfnClient::GfnClient() = default; -std::string GfnClient::LoadLauncherPreference(const std::string&, const std::string&) const { return {}; } -void GfnClient::SaveLauncherPreference(const std::string&, const std::string&, const std::string&) const {} -void ShowError(const std::string&, const std::string&) {} -void LaunchSessionDialog(const GfnClient&, const AuthSession&, const std::string&, const std::string&, - const std::string&, const std::string&, const std::string&, const std::string&) {} -bool IsNevernessToEverness(const std::string&) { return false; } +std::vector GfnClient::FetchLibraryGames(AuthSession&) const +{ std::lock_guard lock(fixture_mutex); ++calls.library_requests; return library; } +CatalogPage GfnClient::FetchCatalogPage(AuthSession&, const std::string& query, const std::string& cursor) const +{ + std::lock_guard lock(fixture_mutex); + ++calls.catalog_requests; + calls.search_queries.push_back(query); + calls.cursors.push_back(cursor); + std::vector matches; + for (const auto& game : catalog) + if (query.empty() || game.title.find(query) != std::string::npos) matches.push_back(game); + const size_t start = cursor.empty() ? 0 : 15; + const size_t end = std::min(matches.size(), start + 15); + CatalogPage page; + if (start < matches.size()) page.games.assign(matches.begin() + start, matches.begin() + end); + page.total_count = matches.size(); + if (end < matches.size()) page.next_cursor = "fixture-page-2"; + return page; +} +std::vector GfnClient::FetchPublicGames() const +{ + std::lock_guard lock(fixture_mutex); + ++calls.public_requests; + auto public_games = catalog; + for (auto& game : public_games) game.is_in_library = false; + return public_games; +} +std::vector GfnClient::FetchLoginProviders() const { return {}; } +std::vector GfnClient::FetchStreamRegions(AuthSession&) const +{ std::lock_guard lock(fixture_mutex); ++calls.region_requests; return {{"Fixture region", "https://region.fixture.invalid/", -1}}; } +std::vector GfnClient::MeasureStreamRegionLatencies(std::vector regions) const +{ + std::lock_guard lock(fixture_mutex); + ++calls.latency_measurements; + for (auto& region : regions) region.ping_ms = 18; + return regions; +} +AuthSession GfnClient::LoginWithQrCode(const LoginProvider&, const std::function&, + const std::function&) const +{ throw std::runtime_error("Native fixture does not authorize an NVIDIA account"); } +AuthSession GfnClient::EnsureFreshSession(const AuthSession& session) const { return session; } +AuthSession GfnClient::EnsureFreshSavedSession(const AuthSession& session) const { return session; } +AuthSession GfnClient::RecoverSavedSession(const AuthSession& session, bool) const { return session; } +bool GfnClient::LoadSavedSession(AuthSession&) const { return false; } +std::vector GfnClient::LoadSavedSessions() const { return {}; } +void GfnClient::ClearSavedSession() const {} +void GfnClient::SaveSession(const AuthSession&) const {} +bool GfnClient::SetActiveSavedSession(const std::string&) const { return true; } +std::string GfnClient::LoadLauncherPreference(const std::string&, const std::string&) const +{ std::lock_guard lock(fixture_mutex); return saved_variant; } +void GfnClient::SaveLauncherPreference(const std::string&, const std::string&, const std::string& variant) const +{ std::lock_guard lock(fixture_mutex); saved_variant = variant; calls.saved_variants.push_back(variant); } +SessionInfo GfnClient::StartSession(AuthSession& auth, const std::string& id, const StreamSettings& request_settings, + const std::string& store, const std::string& title) const +{ + std::unique_lock lock(fixture_mutex); + calls.launches.push_back({auth.user.user_id, id, title, store}); + calls.start_settings.push_back(request_settings); + if (session_mode == ui_fixture::SessionMode::BlockStart && + !start_gate.wait_for(lock, std::chrono::seconds(8), [] { return start_released; })) + throw std::runtime_error("Fixture start gate timed out"); + if (session_mode == ui_fixture::SessionMode::Failure) + throw std::runtime_error("Fixture cloud request failed. " + std::string(256, 'x')); + SessionInfo info; + info.session_id = "fixture-session-" + std::to_string(calls.launches.size()); + info.status = session_mode == ui_fixture::SessionMode::Ready || session_mode == ui_fixture::SessionMode::BlockStart ? 2 : + session_mode == ui_fixture::SessionMode::Confirmation ? 6 : session_mode == ui_fixture::SessionMode::Patching ? 1 : 0; + info.queue_position = session_mode == ui_fixture::SessionMode::Unknown || info.status == 2 ? -1 : 123; + info.app_patching = session_mode == ui_fixture::SessionMode::Patching; + info.signaling_url = "fixture://signaling"; + return info; +} +SessionInfo GfnClient::PollSession(AuthSession&, const std::string& id) const +{ std::lock_guard lock(fixture_mutex); ++calls.polls; SessionInfo info; info.session_id = id; info.status = 0; info.queue_position = 7; return info; } +void GfnClient::StopSession(AuthSession&, const std::string& id) const +{ std::lock_guard lock(fixture_mutex); calls.stopped_sessions.push_back(id); } +void GfnClient::CleanupStaleCloudSession(AuthSession&) const {} +void PresentCloudStream(const SessionInfo& info, const GfnClient&, const AuthSession&, const std::string&, const StreamSettings& settings) +{ std::lock_guard lock(fixture_mutex); calls.handed_off_sessions.push_back(info.session_id); calls.handoff_settings.push_back(settings); } +std::string CurrentUtcIsoTimestamp() { return "2026-10-02T12:00:00Z"; } +bool RecordGamePlayed(const std::string& id, const std::string&, const std::string&) +{ std::lock_guard lock(fixture_mutex); calls.played_ids.push_back(id); return true; } +bool IsNevernessToEverness(const std::string& title) { return title.find("Neverness") != std::string::npos; } bool NteCredentials::valid() const { return false; } NteCredentials LoadNteCredentials() { return {}; } bool SaveNteCredentials(const NteCredentials&) { return false; } bool ClearNteCredentials() { return true; } std::string NteCredentialsPath() { return {}; } - +NetworkConnectionInfo NetworkUtils::GetConnectionInfo() +{ return {true, NetworkConnectionType::Wifi, network::WifiBand::Ghz5, 3}; } namespace shortcut { -CreateResult CreateGameShortcut(LaunchRequest) { return {}; } -bool StartForwarderInstaller(const std::string&, const std::string&, std::string&) { return false; } +const std::string& ExecutablePath() { static const std::string path = "fixture://SwitchNOW.nro"; return path; } +CreateResult CreateGameShortcut(LaunchRequest request) +{ + std::lock_guard lock(fixture_mutex); + calls.shortcuts.push_back(request); + return {true, false, request.title, "fixture://shortcut.nro", "fixture://manifest", {}}; +} +bool StartForwarderInstaller(const std::string&, const std::string&, std::string&) { return true; } } - } diff --git a/tests/usrsctp_crc32c_test.c b/tests/usrsctp_crc32c_test.c new file mode 100644 index 00000000..8a61e644 --- /dev/null +++ b/tests/usrsctp_crc32c_test.c @@ -0,0 +1,26 @@ +#include +#include +#include + +uint32_t calculate_crc32c(uint32_t crc32c, const unsigned char* buffer, unsigned int length); +uint32_t sctp_finalize_crc32c(uint32_t crc32c); + +static void assert_wire_checksum(const unsigned char* data, unsigned int length, uint32_t expected) { + const uint32_t stored = sctp_finalize_crc32c(calculate_crc32c(0xffffffff, data, length)); + unsigned char wire[4]; + memcpy(wire, &stored, sizeof(wire)); + assert(wire[0] == (expected & 0xff)); + assert(wire[1] == ((expected >> 8) & 0xff)); + assert(wire[2] == ((expected >> 16) & 0xff)); + assert(wire[3] == (expected >> 24)); +} + +int main(void) { + const unsigned char check[] = "123456789"; + unsigned char init[44] = {0x13, 0x88, 0x13, 0x88, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0x20}; + for (unsigned i = 16; i < sizeof(init); ++i) + init[i] = (unsigned char)(i - 16); + assert_wire_checksum(check, 9, 0xe3069283u); + assert_wire_checksum(init, sizeof(init), 0x3e2be4f4u); + return 0; +} diff --git a/tests/usrsctp_no_byte_order.h b/tests/usrsctp_no_byte_order.h new file mode 100644 index 00000000..2bf6116d --- /dev/null +++ b/tests/usrsctp_no_byte_order.h @@ -0,0 +1,5 @@ +#define _GNU_SOURCE +#include +#include +#undef BYTE_ORDER +#undef BIG_ENDIAN diff --git a/tests/webrtc_decode_output_accounting_test.cpp b/tests/webrtc_decode_output_accounting_test.cpp new file mode 100644 index 00000000..181b78ff --- /dev/null +++ b/tests/webrtc_decode_output_accounting_test.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include "webrtc_session.hpp" +#undef private +#include "webrtc/internal.hpp" +#include "webrtc/startup_timeout_policy.hpp" +#include "stream/ffmpeg/AVFrameHolder.hpp" + +#include +#include + +namespace { +std::deque send_results; +std::deque receive_results; +WebRtcSession* active_session = nullptr; +int64_t next_pts = 90000; +} + +extern "C" { +int peer_init() { return 0; } +void peer_deinit() {} + +int __wrap_avcodec_send_packet(AVCodecContext*, const AVPacket*) +{ + assert(!send_results.empty()); + const int result = send_results.front(); + send_results.pop_front(); + if (send_results.empty() && receive_results.empty()) + active_session->decoder_running_.store(false); + return result; +} + +int __wrap_avcodec_receive_frame(AVCodecContext*, AVFrame* frame) +{ + assert(!receive_results.empty()); + const int result = receive_results.front(); + receive_results.pop_front(); + if (send_results.empty() && receive_results.empty()) + active_session->decoder_running_.store(false); + if (result < 0) + return result; + frame->format = AV_PIX_FMT_YUV420P; + frame->width = 16; + frame->height = 16; + frame->pts = next_pts; + next_pts += 1500; + assert(av_frame_get_buffer(frame, 0) == 0); + return 0; +} +} + +namespace opennow::webrtc::internal { +void AppendStreamLog(const std::string&) {} +} + +WebRtcSession::WebRtcSession(const std::string&, const std::string&, const std::string&, + const std::string&, int, const std::vector&, + const opennow::StreamSettings&) {} +WebRtcSession::~WebRtcSession() +{ + if (decoder_) + decoder_->cleanup(); +} +SignalingClient::~SignalingClient() = default; +WebSocketClient::~WebSocketClient() = default; +struct AudioPipeline::Impl {}; +AudioPipeline::~AudioPipeline() = default; + +static void run_worker(WebRtcSession& session, std::deque sends, + std::deque receives, bool queue_following_frame) +{ + send_results = std::move(sends); + receive_results = std::move(receives); + active_session = &session; + session.decoder_running_.store(true); + const uint8_t idr[] = {0, 0, 0, 1, 0x65}; + session.enqueue_decode_unit(idr, sizeof(idr), 90000); + if (queue_following_frame) + session.enqueue_decode_unit(idr, sizeof(idr), 91500); + session.decoder_loop(); + assert(send_results.empty() && receive_results.empty()); +} + +static void check_partial_output(std::deque sends, std::deque receives) +{ + WebRtcSession session("", "", "", "", 0, {}, {}); + session.decoder_ = std::make_unique("Adaptive"); + assert(session.decoder_->setup(VIDEO_FORMAT_H264, 16, 16, 60, nullptr, + VIDEO_DECODER_FORCE_SOFTWARE) == 0); + run_worker(session, std::move(sends), std::move(receives), true); + bool published = false; + AVFrameHolder::instance().get([&](AVFrame* frame, uint64_t generation, bool reused) { + assert(frame->buf[0] && generation == 1 && !reused); + published = true; + }); + assert(published); + assert(session.frames_decoded_.load() == 1); + assert(opennow::webrtc::DetectStartupTimeout( + true, session.frames_decoded_.load() > 0, std::chrono::seconds(15), + std::chrono::seconds(15)) == opennow::webrtc::StartupTimeout::None); + assert(session.last_decoded_frame_at_us_.load() > 0); + assert(session.decode_errors_.load() == 1); + assert(session.decoder_recovery_.waiting_for_idr()); + assert(session.decoder_recovery_.take_keyframe_request()); + assert(session.decoder_queue_.empty() && session.decoder_queue_drops_.load() == 1); + + run_worker(session, {0}, {AVERROR(EAGAIN)}, false); + assert(session.frames_decoded_.load() == 1); + assert(session.decode_errors_.load() == 1); + assert(!session.decoder_recovery_.waiting_for_idr()); + + run_worker(session, {AVERROR(EAGAIN), 0}, {0, AVERROR(EAGAIN), 0, AVERROR(EAGAIN)}, false); + assert(session.frames_decoded_.load() == 3); + assert(session.decode_errors_.load() == 1); + assert(!session.decoder_recovery_.waiting_for_idr()); +} + +int main() +{ + check_partial_output({0}, {0, AVERROR_INVALIDDATA}); + check_partial_output({AVERROR(EAGAIN)}, {0, AVERROR_INVALIDDATA}); + check_partial_output({AVERROR(EAGAIN), AVERROR_INVALIDDATA}, {0, AVERROR(EAGAIN)}); + std::puts("Decode output accounting and recovery: PASS"); +} diff --git a/tests/webrtc_input_handshake_test.cpp b/tests/webrtc_input_handshake_test.cpp index 5a0ee07a..bca42192 100644 --- a/tests/webrtc_input_handshake_test.cpp +++ b/tests/webrtc_input_handshake_test.cpp @@ -67,7 +67,8 @@ uint64_t NowUs() { return 0x0102030405060708ULL; } } WebRtcSession::WebRtcSession(const std::string&, const std::string&, const std::string&, - const std::string&, int, const std::vector&) {} + const std::string&, int, const std::vector&, + const opennow::StreamSettings&) {} WebRtcSession::~WebRtcSession() = default; SignalingClient::~SignalingClient() = default; WebSocketClient::~WebSocketClient() = default; @@ -86,7 +87,7 @@ static void RunScenario(const std::string& scenario) { open_attempts = 0; input_logs.clear(); channel_label[0] = 'i'; - WebRtcSession session("", "", "", "", 0, {}); + WebRtcSession session("", "", "", "", 0, {}, {}); session.pc_ = reinterpret_cast(&session); session.on_datachannel_open(); session.datachannel_open_requested_ = true;