diff --git a/Cargo.toml b/Cargo.toml index 86508b0..4c6501e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opennow-vita" -version = "0.3.1" +version = "0.3.2" edition = "2024" license = "MPL-2.0" description = "Cliente homebrew no oficial de GeForce NOW para PlayStation Vita" @@ -45,6 +45,8 @@ rtc-shared = { git = "https://github.com/Day-OS/rtc.git", branch = "vita" } title_id = "OPENNOWV0" title_name = "OpenNOW Vita" assets = "static" +# Vita APP_VER is XX.YY (not Cargo semver). Keep in sync with [package].version (0.3.1 → 00.31). +vita_mksfoex_flags = ["-s", "APP_VER=00.31"] [profile.release] # Nothing tuned the release profile before. On armv7 these are typically worth 5-15% for build diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3c81050..a250203 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -56,3 +56,9 @@ This project uses green-vita's fork of the `rtc`/`rtc-media`/`ring` crates, patc for `armv7-sony-vita-newlibeabihf` (https://github.com/Day-OS/rtc, `vita` branch; https://github.com/vita-rust/ring, `v0.17.14-vita` branch), for the WebRTC peer connection (ICE/DTLS/SRTP) and H.264 RTP depacketization. + +## Noto Sans CJK JP (SIL Open Font License 1.1) + +`assets/fonts/NotoSansJP-Subset.otf` is a subset of Google's Noto Sans CJK JP (kana + +Jōyō/Jinmeiyō kanji) used as an egui fallback so Japanese catalog titles render instead of +tofu boxes. Full license text: `assets/fonts/LICENSE-Noto-CJK.txt`. diff --git a/assets/back.png b/assets/back.png new file mode 100644 index 0000000..ca536a4 Binary files /dev/null and b/assets/back.png differ diff --git a/assets/fonts/LICENSE-Noto-CJK.txt b/assets/fonts/LICENSE-Noto-CJK.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/assets/fonts/LICENSE-Noto-CJK.txt @@ -0,0 +1,92 @@ +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/assets/fonts/NotoSansJP-Subset.otf b/assets/fonts/NotoSansJP-Subset.otf new file mode 100644 index 0000000..4446ff2 Binary files /dev/null and b/assets/fonts/NotoSansJP-Subset.otf differ diff --git a/assets/front.png b/assets/front.png new file mode 100644 index 0000000..f97e3ee Binary files /dev/null and b/assets/front.png differ diff --git a/src/app/fonts.rs b/src/app/fonts.rs new file mode 100644 index 0000000..08f476b --- /dev/null +++ b/src/app/fonts.rs @@ -0,0 +1,23 @@ + +use egui::{FontData, FontDefinitions, FontFamily}; +use std::sync::Arc; + +const JP_FONT: &[u8] = include_bytes!("../../assets/fonts/NotoSansJP-Subset.otf"); + +pub(crate) fn configure(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + fonts.font_data.insert( + "noto-jp".to_owned(), + Arc::new(FontData::from_static(JP_FONT)), + ); + + for family in [FontFamily::Proportional, FontFamily::Monospace] { + fonts + .families + .get_mut(&family) + .expect("default font family") + .push("noto-jp".to_owned()); + } + + ctx.set_fonts(fonts); +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 17b1105..a72df91 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,3 +1,5 @@ +pub mod fonts; +pub mod settings_menu; pub mod ui; use crate::gfn::auth::{self, AuthTokens, DeviceCodeChallenge, DevicePollOutcome, GfnUser}; @@ -11,7 +13,7 @@ use crate::locale::Locale; use anyhow::Result; use reqwest::Client; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio::task::JoinHandle; /// The outcome of trying to renew the saved GFN login. @@ -105,7 +107,6 @@ impl CatalogSort { } } -// my games vs whole gfn catalog, default is my games obviously #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CatalogFilter { #[default] @@ -116,7 +117,6 @@ pub enum CatalogFilter { impl CatalogFilter { pub const ALL: [CatalogFilter; 2] = [Self::MyGames, Self::AllGames]; - // label for the dropdown pub fn label_key(self) -> &'static str { match self { Self::MyGames => "catalog-filter-my-games", @@ -291,7 +291,9 @@ fn sorted_indices(games: &[GameSummary], query: &str, sort: CatalogSort) -> Vec< fn tr(locale: Locale, id: &'static str, key: &'static str, value: impl ToString) -> String { let mut args = fluent_bundle::FluentArgs::new(); args.set(key, crate::i18n::arg_string(value.to_string())); - crate::i18n::I18n::new(locale).text_with(id, args) + crate::i18n::I18n::new(locale) + .text_with(id, args) + .to_string() } // digs thru the error chain for a gfn code, the outer error is usually just anyhow context by now @@ -385,6 +387,7 @@ pub enum AppState { session: SessionInfo, handle: SignalingHandle, peer: crate::gfn::peer::PeerEngine, + session_start: std::time::Instant, }, Error { message: String, @@ -436,6 +439,10 @@ pub struct App { pub(crate) toolbar_expanded: bool, /// Whether the in-stream controls quick modal (L2/R2 & L3/R3 settings) is showing. pub(crate) show_controls_modal: bool, + pub(crate) keyboard_open: bool, + pub(crate) key_shift: bool, + pub(crate) key_ctrl: bool, + pub(crate) key_alt: bool, /// Whether front-touch trackpad input drives host mouse movement. pub(crate) mouse_trackpad_enabled: bool, /// UI display language, changed via the gear icon next to the avatar in the catalog screen. @@ -467,6 +474,27 @@ pub struct App { /// the search started: clearing "holl" would shrink the list back down to a single fresh page /// and slowly regrow it, discarding scroll position along the way. browse_snapshot: Option, + pub(crate) regions: Vec, + regions_job: Option>>, + pub(crate) regions_measuring: bool, + pub(crate) regions_error: Option, + pub(crate) settings_open: bool, + pub(crate) settings_tab: settings_menu::SettingsTab, + pub(crate) settings_focus: usize, + pub(crate) settings_expanded: Option, + pub(crate) settings_option_focus: usize, + pub(crate) server_picker_open: bool, + pub(crate) server_picker_focus: usize, + pub(crate) queue_stats: crate::gfn::queue_stats::QueueMap, + queue_job: Option>, + regions_measured_for_picker: bool, + pub(crate) battery: Option, + battery_checked_at: Option, + bitrate_ceiling_mbps: u32, + bitrate_keyframes_seen: u64, + bitrate_checked_at: Option, + last_tick_wall_clock: Option, + pub(crate) membership_tier: Option, } struct BrowseSnapshot { @@ -477,11 +505,15 @@ struct BrowseSnapshot { } impl App { + pub(crate) fn ui_owns_touch(&self) -> bool { + self.confirm_exit || self.show_controls_hint || self.show_controls_modal + } + /// Returns the current Bearer token if the user is logged in. /// Localized text in the user's chosen UI language. The error and status strings these build /// were hardcoded Spanish, so an English UI still reported failures in Spanish. fn tr(&self, id: &'static str) -> String { - crate::i18n::I18n::new(self.locale).text(id) + crate::i18n::I18n::new(self.locale).text(id).to_string() } fn tr1(&self, id: &'static str, key: &'static str, value: impl ToString) -> String { @@ -497,7 +529,9 @@ impl App { let mut args = fluent_bundle::FluentArgs::new(); args.set(first.0, crate::i18n::arg_string(first.1.to_string())); args.set(second.0, crate::i18n::arg_string(second.1.to_string())); - crate::i18n::I18n::new(self.locale).text_with(id, args) + crate::i18n::I18n::new(self.locale) + .text_with(id, args) + .to_string() } pub fn bearer_token(&self) -> Option<&str> { @@ -518,6 +552,7 @@ impl App { let favorite_games = crate::gfn::favorites::load(); let http_client = auth::client(); let tokens = auth::load_tokens(); + let membership_tier = tokens.as_ref().and_then(|t| t.membership_tier.clone()); let vpc_id_cache = catalog::VpcIdCache::default(); let catalog_filter = CatalogFilter::from_text(&crate::gfn::stream_prefs::saved_catalog_filter()); @@ -554,6 +589,10 @@ impl App { confirm_exit: false, toolbar_expanded: false, show_controls_modal: false, + keyboard_open: false, + key_shift: false, + key_ctrl: false, + key_alt: false, mouse_trackpad_enabled: true, locale: Locale::default(), catalog_sort: CatalogSort::from_text(&crate::gfn::stream_prefs::saved_catalog_sort()), @@ -564,9 +603,38 @@ impl App { launching_session: Arc::new(std::sync::Mutex::new(None)), launch_was_queued: false, link_meter: None, + regions: Vec::new(), + regions_job: None, + regions_measuring: false, + regions_error: None, + settings_open: false, + settings_tab: settings_menu::SettingsTab::Stream, + settings_focus: 0, + settings_expanded: None, + settings_option_focus: 0, + server_picker_open: false, + server_picker_focus: 0, + queue_stats: Default::default(), + queue_job: None, + regions_measured_for_picker: false, + battery: None, + battery_checked_at: None, + bitrate_ceiling_mbps: 0, + bitrate_keyframes_seen: 0, + bitrate_checked_at: None, + last_tick_wall_clock: None, + membership_tier, }) } + pub(crate) fn is_loading_queue_stats(&self) -> bool { + self.queue_job.is_some() + } + + pub(crate) fn is_loading_regions(&self) -> bool { + self.regions_job.is_some() + } + pub async fn handle_command(&mut self, command: AppCommand) -> Result<()> { let bearer_token = self.bearer_token().map(|s| s.to_owned()); let http_client = self.http_client.clone(); @@ -618,6 +686,121 @@ impl App { } current_state } + AppCommand::SetRegion(base_url) => { + crate::gfn::stream_prefs::set_region(&base_url); + let pinned = crate::gfn::stream_prefs::region(); + crate::log_info!( + "Region set to {}", + if pinned.is_empty() { "automatic" } else { &pinned } + ); + self.status_note = Some(if pinned.is_empty() { + self.tr("settings-region-note-auto") + } else { + let name = self + .regions + .iter() + .find(|region| region.url == pinned) + .map(|region| region.name.clone()) + .unwrap_or(pinned); + self.tr1("settings-region-note-pinned", "region", name) + }); + current_state + } + AppCommand::LoadRegions => { + if self.regions_job.is_none() && self.regions.is_empty() { + self.start_region_fetch(); + } + current_state + } + AppCommand::TestRegionLatency => { + if self.regions_job.is_none() && !self.regions.is_empty() { + let regions = self.regions.clone(); + self.regions_measuring = true; + self.regions_job = Some(PollJob::Pending(tokio::spawn(async move { + Ok(crate::gfn::regions::measure_all(regions).await) + }))); + } + current_state + } + AppCommand::CloseServerPicker => { + self.server_picker_open = false; + current_state + } + AppCommand::FocusServerPicker(row) => { + self.server_picker_focus = row; + current_state + } + AppCommand::LaunchOnServer(zone_base_url) => { + self.server_picker_open = false; + self.start_launch(current_state, zone_base_url, bearer_token, http_client) + } + AppCommand::LoadQueueStats => { + if self.queue_job.is_none() { + self.start_queue_fetch(); + } + current_state + } + AppCommand::ToggleGameProfile => { + let enabled = crate::gfn::stream_prefs::active_game_has_profile(); + crate::gfn::stream_prefs::set_active_game_profile(!enabled); + current_state + } + AppCommand::ToggleTriggerSwap => { + let enabled = crate::gfn::stream_prefs::trigger_swap_enabled(); + crate::gfn::stream_prefs::set_trigger_swap_enabled(!enabled); + current_state + } + AppCommand::SetGameLanguage(language) => { + crate::gfn::stream_prefs::set_game_language(language); + current_state + } + AppCommand::OpenSettings => { + self.settings_open = true; + self.settings_tab = settings_menu::SettingsTab::Stream; + self.settings_focus = 0; + self.settings_expanded = None; + if self.regions_job.is_none() && self.regions.is_empty() { + self.start_region_fetch(); + } + current_state + } + AppCommand::CloseSettings => { + self.settings_open = false; + self.settings_expanded = None; + current_state + } + AppCommand::SetSettingsTab(tab) => { + self.settings_tab = tab; + self.settings_focus = 0; + self.settings_expanded = None; + current_state + } + AppCommand::ExpandSettingsRow(row) => { + if let Some(row) = row { + self.settings_focus = row; + } + self.settings_expanded = row; + self.settings_option_focus = row + .map(|row| { + settings_menu::current_option_index( + self.settings_tab, + row, + &self.regions, + self.locale, + ) + }) + .unwrap_or(0); + current_state + } + AppCommand::ChooseSettingsOption(row, option) => { + self.settings_focus = row; + self.settings_expanded = None; + if let Some(cmd) = settings_menu::command_for(self.settings_tab, row, option, &self.regions) { + self.state = current_state; + return Box::pin(self.handle_command(cmd)).await; + } + current_state + } AppCommand::DismissControlsHint => { crate::gfn::stream_prefs::mark_controls_hint_seen(); self.show_controls_hint = false; @@ -677,10 +860,9 @@ impl App { current_state } AppCommand::ToggleKeyboard => { - if crate::ime::is_open() { - crate::ime::close(); - } else { - crate::ime::open(); + self.keyboard_open = !self.keyboard_open; + if !self.keyboard_open { + self.release_keyboard_modifiers(¤t_state); } current_state } @@ -688,6 +870,40 @@ impl App { if let AppState::Streaming { peer, .. } = ¤t_state { peer.tap_key(key); } + self.key_shift = false; + current_state + } + AppCommand::SendChord { ctrl, alt, key } => { + if let AppState::Streaming { peer, .. } = ¤t_state { + if ctrl { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_CTRL, true); + } + if alt { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_ALT, true); + } + peer.tap_key(key); + if alt { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_ALT, false); + } + if ctrl { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_CTRL, false); + } + } + self.key_shift = false; + self.key_ctrl = false; + self.key_alt = false; + current_state + } + AppCommand::ToggleKeyShift => { + self.key_shift = !self.key_shift; + current_state + } + AppCommand::ToggleKeyCtrl => { + self.key_ctrl = !self.key_ctrl; + current_state + } + AppCommand::ToggleKeyAlt => { + self.key_alt = !self.key_alt; current_state } AppCommand::SetAudioBoost(boost) => { @@ -702,6 +918,16 @@ impl App { crate::gfn::stream_prefs::set_fps(fps); current_state } + AppCommand::SetColorDepth(depth) => { + crate::gfn::stream_prefs::set_color_depth(depth); + current_state + } + AppCommand::ToggleSessionTimer => { + crate::gfn::stream_prefs::set_session_timer_enabled( + !crate::gfn::stream_prefs::session_timer_enabled(), + ); + current_state + } AppCommand::SelectGame(index) => { let mut state = current_state; if let AppState::Catalog { @@ -965,6 +1191,511 @@ impl App { Ok(new_state) } + fn publish_active_game(&self) { + let app_id = match &self.state { + AppState::Streaming { session, .. } => Some(session.app_id.clone()), + AppState::Catalog { + games, + selected, + filtered_indices, + .. + } + | AppState::CreatingSession { + games, + selected, + filtered_indices, + .. + } + | AppState::SessionReady { + games, + selected, + filtered_indices, + .. + } + | AppState::Signaling { + games, + selected, + filtered_indices, + .. + } => ui::selected_game(games, filtered_indices, *selected) + .map(|game| game.app_id.clone()), + _ => None, + }; + crate::gfn::stream_prefs::set_active_game(app_id.as_deref()); + } + + const SUSPEND_GAP: Duration = Duration::from_secs(5); + + fn handle_suspend(&mut self, current_state: AppState) -> AppState { + let now = std::time::SystemTime::now(); + let resumed_from_sleep = self + .last_tick_wall_clock + .and_then(|previous| now.duration_since(previous).ok()) + .is_some_and(|gap| gap >= Self::SUSPEND_GAP); + self.last_tick_wall_clock = Some(now); + + let in_session = matches!( + current_state, + AppState::CreatingSession { .. } + | AppState::SessionReady { .. } + | AppState::Signaling { .. } + | AppState::Streaming { .. } + ); + if !in_session { + return current_state; + } + + let suspending = crate::power::suspend_required(); + if !suspending && !resumed_from_sleep { + return current_state; + } + + crate::log_warn!( + "Ending the session for a console suspend (pending: {suspending}, resumed: {resumed_from_sleep})" + ); + self.status_note = Some(self.tr("status-session-suspended")); + match self.exit_session(current_state) { + Ok(state) => state, + Err(error) => { + crate::log_warn!("Suspend teardown could not stop the session: {error:#}"); + AppState::Login + } + } + } + + const BITRATE_ADAPT_INTERVAL: Duration = Duration::from_secs(12); + const BITRATE_STRESS_REQUESTS: u64 = 3; + + fn adapt_stream_bitrate(&mut self) { + let AppState::Streaming { peer, .. } = &self.state else { + self.bitrate_ceiling_mbps = 0; + self.bitrate_keyframes_seen = 0; + self.bitrate_checked_at = None; + return; + }; + + if self.bitrate_ceiling_mbps == 0 { + self.bitrate_ceiling_mbps = crate::gfn::link_estimate::ceiling_mbps(); + } + let now = Instant::now(); + let due = self + .bitrate_checked_at + .is_none_or(|at| now.duration_since(at) >= Self::BITRATE_ADAPT_INTERVAL); + if !due { + return; + } + let requests = peer.keyframe_requests(); + let previous_checked = self.bitrate_checked_at.replace(now); + let in_window = requests.saturating_sub(self.bitrate_keyframes_seen); + self.bitrate_keyframes_seen = requests; + + if previous_checked.is_none() || in_window < Self::BITRATE_STRESS_REQUESTS { + return; + } + + let current = self.bitrate_ceiling_mbps; + let lowered = (current * 3 / 4).max(crate::gfn::link_estimate::MIN_CEILING_MBPS); + if lowered >= current { + return; + } + self.bitrate_ceiling_mbps = lowered; + peer.set_max_bitrate(lowered * 1000); + crate::log_warn!( + "Link stressed ({in_window} keyframe request(s) in {}s), lowering the ceiling {current} -> {lowered} Mbps", + Self::BITRATE_ADAPT_INTERVAL.as_secs() + ); + self.status_note = Some(self.tr1("status-bitrate-lowered", "mbps", lowered)); + } + + const BATTERY_POLL_INTERVAL: Duration = Duration::from_secs(20); + + fn check_battery(&mut self, current_state: AppState) -> AppState { + if self + .battery_checked_at + .is_some_and(|at| at.elapsed() < Self::BATTERY_POLL_INTERVAL) + { + return current_state; + } + self.battery_checked_at = Some(Instant::now()); + let Some(status) = crate::power::battery_status() else { + return current_state; + }; + self.battery = Some(status); + + let streaming = matches!( + current_state, + AppState::CreatingSession { .. } + | AppState::SessionReady { .. } + | AppState::Signaling { .. } + | AppState::Streaming { .. } + ); + if !streaming { + return current_state; + } + + if status.is_critical() { + crate::log_warn!( + "Battery critical ({}%), stopping the session while it can still be released", + status.percent + ); + self.status_note = Some(self.tr("status-battery-critical")); + return match self.exit_session(current_state) { + Ok(state) => state, + Err(error) => { + crate::log_warn!("Battery shutdown could not stop the session: {error:#}"); + AppState::Login + } + }; + } + if status.should_warn() { + self.status_note = Some(self.tr1("status-battery-low", "percent", status.percent)); + } + current_state + } + + fn open_server_picker(&mut self) { + self.server_picker_open = true; + self.regions_measured_for_picker = false; + let pinned = crate::gfn::stream_prefs::region(); + self.server_picker_focus = if pinned.is_empty() { + 0 + } else { + self.regions + .iter() + .position(|region| region.url == pinned) + .map_or(0, |index| index + 1) + }; + if self.regions_job.is_none() && self.regions.is_empty() { + self.start_region_fetch(); + } + if self.queue_job.is_none() { + self.start_queue_fetch(); + } + } + + fn start_queue_fetch(&mut self) { + let client = self.http_client.clone(); + self.queue_job = Some(PollJob::Pending(tokio::spawn(async move { + crate::gfn::queue_stats::fetch_queue(&client).await + }))); + } + + async fn advance_queue_job(&mut self) { + let Some(PollJob::Pending(handle)) = self.queue_job.take() else { + return; + }; + match poll_job(handle).await { + PollJob::Pending(handle) => { + self.queue_job = Some(PollJob::Pending(handle)); + } + PollJob::Done(Ok(readings)) => self.queue_stats = readings, + PollJob::Done(Err(error)) => { + crate::log_warn!("Queue stats unavailable: {error:#}"); + self.queue_stats.clear(); + } + } + } + + fn measure_regions_for_picker(&mut self) { + if !self.server_picker_open + || self.regions_measured_for_picker + || self.regions.is_empty() + || self.regions_job.is_some() + { + return; + } + self.regions_measured_for_picker = true; + let regions = self.regions.clone(); + self.regions_measuring = true; + self.regions_job = Some(PollJob::Pending(tokio::spawn(async move { + Ok(crate::gfn::regions::measure_all(regions).await) + }))); + } + + fn start_launch( + &mut self, + current_state: AppState, + zone_base_url: String, + bearer_token: Option, + http_client: Client, + ) -> AppState { + let AppState::Catalog { + user, + games, + selected, + filtered_indices, + search_query, + search_requested, + covers, + } = current_state + else { + return current_state; + }; + + let game_index = filtered_indices.get(selected).copied(); + match ( + game_index.and_then(|index| games.get(index)), + bearer_token, + ) { + (Some(game), Some(token)) => { + let app_id = game.app_id.clone(); + let queue_tracker = + Arc::new(std::sync::Mutex::new(cloudmatch::QueueStatus::default())); + let tracker_clone = queue_tracker.clone(); + if let Ok(mut slot) = self.launching_session.lock() { + *slot = None; + } + self.launch_was_queued = false; + let launching_session = self.launching_session.clone(); + let handle: JoinHandle> = tokio::spawn(async move { + let settings = cloudmatch::StreamSettings::for_vita(); + let language_code = crate::gfn::stream_prefs::game_language().code(); + let session = cloudmatch::create_session( + &http_client, + cloudmatch::CreateSessionRequest { + token: token.as_str(), + app_id: &app_id, + vpc_id: "", + settings: &settings, + zone_base_url: &zone_base_url, + language_code, + }, + ) + .await?; + if let Ok(mut slot) = launching_session.lock() { + *slot = Some(session.clone()); + } + let polled = cloudmatch::poll_session( + &http_client, + cloudmatch::PollSessionRequest { + token: token.as_str(), + session_id: &session.session_id, + session: &session, + }, + Some(tracker_clone), + ) + .await; + if polled.is_err() { + cloudmatch::stop_session(&http_client, token.as_str(), &session).await; + } + polled + }); + AppState::CreatingSession { + user, + games, + selected, + filtered_indices, + search_query, + search_requested, + covers, + job: PollJob::Pending(handle), + queue_tracker, + } + } + _ => { + self.status_note = Some(self.tr("status-session-start-failed")); + AppState::Catalog { + user, + games, + selected, + filtered_indices, + search_query, + search_requested, + covers, + } + } + } + } + + fn handle_server_picker_input( + &mut self, + current_state: AppState, + input: InputCommand, + bearer_token: Option, + http_client: Client, + ) -> AppState { + let row_count = 1 + self.regions.len(); + match input { + InputCommand::MoveUp => { + self.server_picker_focus = self.server_picker_focus.saturating_sub(1); + current_state + } + InputCommand::MoveDown => { + self.server_picker_focus = (self.server_picker_focus + 1).min(row_count - 1); + current_state + } + InputCommand::Confirm => { + let zone = self.server_picker_zone(self.server_picker_focus); + self.server_picker_open = false; + self.start_launch(current_state, zone, bearer_token, http_client) + } + InputCommand::Back => { + self.server_picker_open = false; + current_state + } + InputCommand::MoveLeft + | InputCommand::MoveRight + | InputCommand::PrevTab + | InputCommand::NextTab => current_state, + } + } + + fn server_picker_zone(&self, row: usize) -> String { + match row.checked_sub(1) { + None => String::new(), + Some(index) => self + .regions + .get(index) + .map(|region| region.url.clone()) + .unwrap_or_default(), + } + } + + async fn handle_settings_input(&mut self, input: InputCommand) -> Result<()> { + let regions_len = self.regions.len(); + let row_count = self.settings_tab.row_count(); + + if let Some(row) = self.settings_expanded { + let option_count = settings_menu::option_count(self.settings_tab, row, regions_len).max(1); + match input { + InputCommand::MoveUp => { + self.settings_option_focus = self.settings_option_focus.saturating_sub(1); + } + InputCommand::MoveDown => { + self.settings_option_focus = + (self.settings_option_focus + 1).min(option_count - 1); + } + InputCommand::Confirm => { + let (row, option) = (row, self.settings_option_focus); + self.settings_expanded = None; + if let Some(cmd) = + settings_menu::command_for(self.settings_tab, row, option, &self.regions) + { + Box::pin(self.handle_command(cmd)).await?; + } + } + InputCommand::Back => { + self.settings_expanded = None; + } + InputCommand::MoveLeft + | InputCommand::MoveRight + | InputCommand::PrevTab + | InputCommand::NextTab => {} + } + return Ok(()); + } + + match input { + InputCommand::MoveUp => { + self.settings_focus = self.settings_focus.saturating_sub(1); + self.settings_expanded = None; + } + InputCommand::MoveDown => { + if row_count > 0 { + self.settings_focus = (self.settings_focus + 1).min(row_count - 1); + } + self.settings_expanded = None; + } + InputCommand::PrevTab => { + self.settings_tab = self.settings_tab.shifted(-1); + self.settings_focus = 0; + } + InputCommand::NextTab => { + self.settings_tab = self.settings_tab.shifted(1); + self.settings_focus = 0; + } + InputCommand::MoveLeft | InputCommand::MoveRight => { + let delta: i32 = if matches!(input, InputCommand::MoveLeft) { + -1 + } else { + 1 + }; + if let Some(info) = settings_menu::row_info(self.settings_tab, self.settings_focus) { + if matches!(info.kind, settings_menu::RowKind::Choice) { + let count = settings_menu::option_count( + self.settings_tab, + self.settings_focus, + regions_len, + ); + if count > 0 { + let current = settings_menu::current_option_index( + self.settings_tab, + self.settings_focus, + &self.regions, + self.locale, + ); + let next = (current as i32 + delta).rem_euclid(count as i32) as usize; + if let Some(cmd) = settings_menu::command_for( + self.settings_tab, + self.settings_focus, + next, + &self.regions, + ) { + Box::pin(self.handle_command(cmd)).await?; + } + } + } + } + } + InputCommand::Confirm => { + if let Some(info) = settings_menu::row_info(self.settings_tab, self.settings_focus) { + match info.kind { + settings_menu::RowKind::Toggle(_) => { + if let Some(cmd) = settings_menu::command_for( + self.settings_tab, + self.settings_focus, + 0, + &self.regions, + ) { + Box::pin(self.handle_command(cmd)).await?; + } + } + settings_menu::RowKind::Choice + if self.settings_tab == settings_menu::SettingsTab::Controls + && self.settings_focus <= 1 => + { + let count = settings_menu::option_count( + self.settings_tab, + self.settings_focus, + regions_len, + ); + if count > 0 { + let current = settings_menu::current_option_index( + self.settings_tab, + self.settings_focus, + &self.regions, + self.locale, + ); + let next = (current + 1) % count; + if let Some(cmd) = settings_menu::command_for( + self.settings_tab, + self.settings_focus, + next, + &self.regions, + ) { + Box::pin(self.handle_command(cmd)).await?; + } + } + } + settings_menu::RowKind::Choice | settings_menu::RowKind::Region => { + self.settings_expanded = Some(self.settings_focus); + self.settings_option_focus = settings_menu::current_option_index( + self.settings_tab, + self.settings_focus, + &self.regions, + self.locale, + ); + } + } + } + } + InputCommand::Back => { + self.settings_open = false; + } + } + Ok(()) + } + async fn handle_input_command( &mut self, current_state: AppState, @@ -972,6 +1703,18 @@ impl App { bearer_token: Option, http_client: Client, ) -> Result { + if self.settings_open { + self.handle_settings_input(input).await?; + return Ok(current_state); + } + if self.server_picker_open { + return Ok(self.handle_server_picker_input( + current_state, + input, + bearer_token, + http_client, + )); + } Ok(match (current_state, input) { (AppState::Login, InputCommand::Confirm) => self.start_login_state(), (AppState::WaitingForDeviceAuthorization { .. }, InputCommand::Back) => AppState::Login, @@ -1068,86 +1811,15 @@ impl App { covers, } } else { - let game_index = filtered_indices.get(selected).copied(); - match ( - game_index.and_then(|index| games.get(index)), - bearer_token.clone(), - ) { - (Some(game), Some(token)) => { - let app_id = game.app_id.clone(); - let queue_tracker = Arc::new(std::sync::Mutex::new( - cloudmatch::QueueStatus::default(), - )); - let tracker_clone = queue_tracker.clone(); - // Republished for the cancel path; cleared here so a cancelled launch - // can't leave the previous attempt's session behind to be stopped - // twice. - if let Ok(mut slot) = self.launching_session.lock() { - *slot = None; - } - self.launch_was_queued = false; - let launching_session = self.launching_session.clone(); - let handle: JoinHandle> = - tokio::spawn(async move { - let settings = cloudmatch::StreamSettings::for_vita(); - let session = cloudmatch::create_session( - &http_client, - cloudmatch::CreateSessionRequest { - token: token.as_str(), - app_id: &app_id, - vpc_id: "", - settings: &settings, - }, - ) - .await?; - if let Ok(mut slot) = launching_session.lock() { - *slot = Some(session.clone()); - } - let polled = cloudmatch::poll_session( - &http_client, - cloudmatch::PollSessionRequest { - token: token.as_str(), - session_id: &session.session_id, - session: &session, - }, - Some(tracker_clone), - ) - .await; - if polled.is_err() { - cloudmatch::stop_session( - &http_client, - token.as_str(), - &session, - ) - .await; - } - polled - }); - AppState::CreatingSession { - user, - games, - selected, - filtered_indices, - search_query, - search_requested, - covers, - job: PollJob::Pending(handle), - queue_tracker, - } - } - _ => { - self.status_note = - Some(self.tr("status-session-start-failed")); - AppState::Catalog { - user, - games, - selected, - filtered_indices, - search_query, - search_requested, - covers, - } - } + self.open_server_picker(); + AppState::Catalog { + user, + games, + selected, + filtered_indices, + search_query, + search_requested, + covers, } } } @@ -1370,31 +2042,77 @@ impl App { let tokens = tokens.clone(); let cache = vpc_id_cache.clone(); let user_id = user.user_id.clone(); + let handle: JoinHandle> = tokio::spawn(async move { // Renew first when the saved token is near expiry. This runs at startup with whatever // was on the memory card, and the proactive refresh in `tick` only gets a turn *after* // this request is already in flight - so a stale token would fail the VPC lookup and // land the player on a catalog that is wrong rather than on a sign-in prompt. - let bearer = if tokens.needs_refresh() { + let tokens = if tokens.needs_refresh() { match crate::gfn::auth::refresh_tokens(&client, &tokens, &user_id).await { Ok(refreshed) => { if let Err(error) = crate::gfn::auth::save_tokens(&refreshed) { eprintln!("Could not persist refreshed GFN tokens: {error:#}"); } - refreshed.bearer().to_owned() + refreshed } // Let the request go out anyway: the error path already knows how to turn a // rejection into a sign-in prompt, and the token may still be good. Err(error) => { eprintln!("Startup token refresh failed: {error}"); - tokens.bearer().to_owned() + tokens } } } else { - tokens.bearer().to_owned() + tokens }; - catalog::fetch_catalog_page_for_account(&client, &bearer, &cache, None, "", owned_only) - .await + + if tokens.membership_tier.is_none() { + let client_for_tier = client.clone(); + let bearer_for_tier = tokens.bearer().to_owned(); + let cache_for_tier = cache.clone(); + let user_id_for_tier = user_id.clone(); + tokio::spawn(async move { + let Ok(vpc_id) = crate::gfn::catalog::resolve_vpc_id( + &client_for_tier, + &bearer_for_tier, + &cache_for_tier, + ) + .await + else { + return; + }; + let Ok(tier) = crate::gfn::auth::fetch_membership_tier( + &client_for_tier, + &bearer_for_tier, + &vpc_id, + &user_id_for_tier, + ) + .await + else { + return; + }; + let Some(mut current) = crate::gfn::auth::load_tokens() else { + return; + }; + if current.membership_tier.is_none() { + current.membership_tier = Some(tier); + if let Err(error) = crate::gfn::auth::save_tokens(¤t) { + eprintln!("Could not persist membership tier: {error:#}"); + } + } + }); + } + + catalog::fetch_catalog_page_for_account( + &client, + tokens.bearer(), + &cache, + None, + "", + owned_only, + ) + .await }); AppState::LoadingCatalog { user, @@ -1536,6 +2254,41 @@ impl App { self.search_job = Some((query, PollJob::Pending(handle))); } + fn start_region_fetch(&mut self) { + let Some(token) = self.bearer_token().map(str::to_owned) else { + return; + }; + let client = self.http_client.clone(); + self.regions_error = None; + self.regions_measuring = false; + self.regions_job = Some(PollJob::Pending(tokio::spawn(async move { + crate::gfn::regions::fetch_regions(&client, &token).await + }))); + } + + async fn advance_region_job(&mut self) { + let Some(PollJob::Pending(handle)) = self.regions_job.take() else { + return; + }; + match poll_job(handle).await { + PollJob::Pending(handle) => { + self.regions_job = Some(PollJob::Pending(handle)); + } + PollJob::Done(Ok(regions)) => { + self.regions_measuring = false; + if regions.is_empty() { + self.regions_error = Some(self.tr("settings-region-none")); + } + self.regions = regions; + } + PollJob::Done(Err(error)) => { + crate::log_warn!("Region lookup failed: {error:#}"); + self.regions_measuring = false; + self.regions_error = Some(self.tr("settings-region-failed")); + } + } + } + /// Streams the remaining catalog pages in behind the UI, appending each to `games` as it /// lands so the list grows while the user browses. async fn advance_catalog_paging(&mut self) { @@ -1711,10 +2464,18 @@ impl App { pub async fn tick(&mut self) -> Result<()> { self.prune_covers(); self.track_link_quality(); - self.pump_keyboard(); + self.sync_keyboard_state(); self.maintain_session().await; self.advance_catalog_search().await; self.advance_catalog_paging().await; + self.advance_region_job().await; + self.advance_queue_job().await; + self.measure_regions_for_picker(); + self.publish_active_game(); + let state = std::mem::replace(&mut self.state, AppState::Login); + let state = self.handle_suspend(state); + self.state = self.check_battery(state); + self.adapt_stream_bitrate(); match std::mem::replace(&mut self.state, AppState::Login) { AppState::StartingDeviceLogin(job) => self.state = self.advance_login_start(job).await, AppState::WaitingForDeviceAuthorization { @@ -1769,6 +2530,8 @@ impl App { handle, offer_sdp, } => { + self.membership_tier = crate::gfn::auth::load_tokens() + .and_then(|tokens| tokens.membership_tier); self.state = self.advance_signaling( user, games, @@ -1793,6 +2556,7 @@ impl App { session, mut handle, mut peer, + session_start, } => { let mut fatal_reason: Option = None; @@ -1822,9 +2586,11 @@ impl App { self.status_note = Some(status); } crate::gfn::peer::PeerEvent::Connected => { + crate::log_stream!("UI: stream live"); self.status_note = Some(self.tr("status-stream-live")); } crate::gfn::peer::PeerEvent::Error(err) => { + crate::log_error!("Streaming peer error: {err}"); eprintln!("Streaming peer error: {err}"); self.status_note = Some(self.tr1("status-peer-error", "error", &err)); } @@ -1835,9 +2601,11 @@ impl App { 4 => format!("La sesión finalizará en breve ({seconds_left}s)"), _ => format!("Aviso de tiempo de sesión: ~{mins} min restantes"), }; + crate::log_stream!("session time warning code={code} left={seconds_left}s"); self.status_note = Some(msg); } crate::gfn::peer::PeerEvent::Disconnected(reason) => { + crate::log_error!("Streaming peer disconnected: {reason}"); eprintln!("Streaming peer disconnected: {reason}"); fatal_reason .get_or_insert(self.tr1("error-stream-lost", "reason", &reason)); @@ -1874,6 +2642,7 @@ impl App { session, handle, peer, + session_start, }; } } @@ -1922,6 +2691,7 @@ impl App { session, handle, peer, + session_start: std::time::Instant::now(), }; } Err(error) => { @@ -2102,27 +2872,28 @@ impl App { state } - /// Pumps the in-game keyboard and forwards whatever it detected to the game. /// - /// The IME needs `update` called every frame to deliver its events at all, and its handler can - /// only queue keystrokes - it has no route to the peer - so the hand-off happens here. - fn pump_keyboard(&mut self) { - // The keyboard belongs to a running session; leaving it up over the catalog would send - // keystrokes into a game that is no longer there. - if crate::ime::is_open() && !matches!(self.state, AppState::Streaming { .. }) { - crate::ime::close(); - return; + fn sync_keyboard_state(&mut self) { + if self.keyboard_open && !matches!(self.state, AppState::Streaming { .. }) { + self.keyboard_open = false; + let state = std::mem::replace(&mut self.state, AppState::Login); + self.release_keyboard_modifiers(&state); + self.state = state; } - crate::ime::update(); - let keys = crate::ime::take_keys(); - if keys.is_empty() { - return; - } - if let AppState::Streaming { peer, .. } = &self.state { - for key in keys { - peer.tap_key(key); + } + + fn release_keyboard_modifiers(&mut self, state: &AppState) { + if let AppState::Streaming { peer, .. } = state { + if self.key_ctrl { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_CTRL, false); + } + if self.key_alt { + peer.send_key(crate::gfn::input_protocol::KEY_LEFT_ALT, false); } } + self.key_ctrl = false; + self.key_alt = false; + self.key_shift = false; } /// Keeps the saved login ahead of its expiry, so a request is never the thing that discovers diff --git a/src/app/settings_menu.rs b/src/app/settings_menu.rs new file mode 100644 index 0000000..037c57f --- /dev/null +++ b/src/app/settings_menu.rs @@ -0,0 +1,324 @@ + +use crate::gfn::regions::StreamRegion; +use crate::gfn::stream_prefs::{ + AudioBoost, ColorDepth, GameLanguage, RearTouchMode, StickZones, StreamFps, TriggerIntensity, +}; +use crate::i18n::I18n; +use crate::input::AppCommand; +use crate::locale::Locale; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + Stream, + Controls, + App, + Account, +} + +impl SettingsTab { + pub const ALL: [SettingsTab; 4] = [Self::Stream, Self::Controls, Self::App, Self::Account]; + + pub fn label_key(self) -> &'static str { + match self { + Self::Stream => "settings-tab-stream", + Self::Controls => "settings-tab-controls", + Self::App => "settings-tab-app", + Self::Account => "settings-tab-account", + } + } + + pub fn group_key(self) -> &'static str { + match self { + Self::Stream => "settings-group-streaming", + Self::Controls => "settings-group-controls", + Self::App => "settings-group-app", + Self::Account => "settings-group-account", + } + } + + fn next(self) -> Self { + let all = Self::ALL; + let index = all.iter().position(|&tab| tab == self).unwrap_or(0); + all[(index + 1) % all.len()] + } + + fn prev(self) -> Self { + let all = Self::ALL; + let index = all.iter().position(|&tab| tab == self).unwrap_or(0); + all[(index + all.len() - 1) % all.len()] + } + + pub fn shifted(self, delta: i32) -> Self { + if delta < 0 { self.prev() } else { self.next() } + } + + pub fn row_count(self) -> usize { + match self { + Self::Stream => 5, + Self::Controls => 5, + Self::App => 2, + Self::Account => 0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowKind { + Choice, + Toggle(bool), + Region, +} + +pub struct RowInfo { + pub label_key: &'static str, + pub desc_key: Option<&'static str>, + pub kind: RowKind, +} + +pub fn row_info(tab: SettingsTab, row: usize) -> Option { + Some(match (tab, row) { + (SettingsTab::Stream, 0) => RowInfo { + label_key: "settings-region-heading", + desc_key: Some("settings-region-desc"), + kind: RowKind::Region, + }, + (SettingsTab::Stream, 1) => RowInfo { + label_key: "settings-game-language-heading", + desc_key: Some("settings-game-language-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Stream, 2) => RowInfo { + label_key: "settings-fps-heading", + desc_key: Some("settings-fps-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Stream, 3) => RowInfo { + label_key: "settings-audio-boost-heading", + desc_key: Some("settings-audio-boost-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Stream, 4) => RowInfo { + label_key: "settings-color-depth-heading", + desc_key: Some("settings-color-depth-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Controls, 0) => RowInfo { + label_key: "settings-rear-touch-mode-heading", + desc_key: Some("settings-rear-touch-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Controls, 1) => RowInfo { + label_key: "settings-stick-zones-heading", + desc_key: Some("settings-stick-zones-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Controls, 2) => RowInfo { + label_key: "settings-trigger-heading", + desc_key: Some("settings-trigger-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::Controls, 3) => RowInfo { + label_key: "settings-game-profile-heading", + desc_key: Some("settings-game-profile-desc"), + kind: RowKind::Toggle(crate::gfn::stream_prefs::active_game_has_profile()), + }, + (SettingsTab::Controls, 4) => RowInfo { + label_key: "settings-trigger-swap-heading", + desc_key: Some("settings-trigger-swap-desc"), + kind: RowKind::Toggle(crate::gfn::stream_prefs::trigger_swap_enabled()), + }, + (SettingsTab::App, 0) => RowInfo { + label_key: "settings-language-heading", + desc_key: Some("settings-language-desc"), + kind: RowKind::Choice, + }, + (SettingsTab::App, 1) => RowInfo { + label_key: "settings-session-timer-heading", + desc_key: Some("settings-session-timer-desc"), + kind: RowKind::Toggle(crate::gfn::stream_prefs::session_timer_enabled()), + }, + _ => return None, + }) +} + +pub fn option_count(tab: SettingsTab, row: usize, regions_len: usize) -> usize { + match row_info(tab, row).map(|info| info.kind) { + Some(RowKind::Choice) => match (tab, row) { + (SettingsTab::Stream, 1) => GameLanguage::ALL.len(), + (SettingsTab::Stream, 2) => StreamFps::ALL.len(), + (SettingsTab::Stream, 3) => AudioBoost::ALL.len(), + (SettingsTab::Stream, 4) => ColorDepth::ALL.len(), + (SettingsTab::Controls, 0) => RearTouchMode::ALL.len(), + (SettingsTab::Controls, 1) => StickZones::ALL.len(), + (SettingsTab::Controls, 2) => TriggerIntensity::ALL.len(), + (SettingsTab::App, 0) => Locale::ALL.len(), + _ => 0, + }, + Some(RowKind::Region) => 1 + regions_len, + _ => 0, + } +} + +pub fn current_option_index( + tab: SettingsTab, + row: usize, + regions: &[StreamRegion], + current_locale: Locale, +) -> usize { + match (tab, row) { + (SettingsTab::Stream, 1) => GameLanguage::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::game_language()) + .unwrap_or(0), + (SettingsTab::Stream, 2) => StreamFps::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::fps()) + .unwrap_or(0), + (SettingsTab::Stream, 3) => AudioBoost::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::audio_boost()) + .unwrap_or(0), + (SettingsTab::Stream, 4) => ColorDepth::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::color_depth()) + .unwrap_or(0), + (SettingsTab::Controls, 0) => RearTouchMode::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::rear_touch_mode()) + .unwrap_or(0), + (SettingsTab::Controls, 1) => StickZones::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::stick_zones()) + .unwrap_or(0), + (SettingsTab::Controls, 2) => TriggerIntensity::ALL + .iter() + .position(|&c| c == crate::gfn::stream_prefs::trigger_intensity()) + .unwrap_or(0), + (SettingsTab::App, 0) => Locale::ALL + .iter() + .position(|&c| c == current_locale) + .unwrap_or(0), + (SettingsTab::Stream, 0) => { + let selected = crate::gfn::stream_prefs::region(); + if selected.is_empty() { + 0 + } else { + regions + .iter() + .position(|region| region.url == selected) + .map(|index| index + 1) + .unwrap_or(0) + } + } + _ => 0, + } +} + +pub fn option_label( + tab: SettingsTab, + row: usize, + index: usize, + i18n: &I18n, + regions: &[StreamRegion], +) -> String { + match (tab, row) { + (SettingsTab::Stream, 1) => GameLanguage::ALL + .get(index) + .map(|c| c.label().to_owned()) + .unwrap_or_default(), + (SettingsTab::Stream, 2) => StreamFps::ALL + .get(index) + .map(|c| c.value().to_string()) + .unwrap_or_default(), + (SettingsTab::Stream, 3) => AudioBoost::ALL + .get(index) + .map(|c| format!("{}x", c.percent() / 100)) + .unwrap_or_default(), + (SettingsTab::Stream, 4) => ColorDepth::ALL + .get(index) + .map(|c| i18n.text(c.label_key()).to_string()) + .unwrap_or_default(), + (SettingsTab::Controls, 0) => RearTouchMode::ALL + .get(index) + .map(|c| i18n.text(c.label_key()).to_string()) + .unwrap_or_default(), + (SettingsTab::Controls, 1) => StickZones::ALL + .get(index) + .map(|c| i18n.text(c.label_key()).to_string()) + .unwrap_or_default(), + (SettingsTab::Controls, 2) => TriggerIntensity::ALL + .get(index) + .map(|c| format!("{}%", u32::from(c.value()) * 100 / 255)) + .unwrap_or_default(), + (SettingsTab::App, 0) => Locale::ALL + .get(index) + .map(|c| c.label().to_owned()) + .unwrap_or_default(), + (SettingsTab::Stream, 0) => { + if index == 0 { + i18n.text("settings-region-auto").to_string() + } else { + match regions.get(index - 1) { + Some(region) => match region.ping_ms { + Some(ms) => format!("{} · {ms} ms", region.name), + None => region.name.clone(), + }, + None => String::new(), + } + } + } + _ => String::new(), + } +} + +pub fn current_summary( + tab: SettingsTab, + row: usize, + i18n: &I18n, + regions: &[StreamRegion], + current_locale: Locale, +) -> String { + let index = current_option_index(tab, row, regions, current_locale); + option_label(tab, row, index, i18n, regions) +} + +pub fn command_for( + tab: SettingsTab, + row: usize, + index: usize, + regions: &[StreamRegion], +) -> Option { + match (tab, row) { + (SettingsTab::Stream, 1) => GameLanguage::ALL + .get(index) + .copied() + .map(AppCommand::SetGameLanguage), + (SettingsTab::Stream, 2) => StreamFps::ALL.get(index).copied().map(AppCommand::SetStreamFps), + (SettingsTab::Stream, 3) => AudioBoost::ALL.get(index).copied().map(AppCommand::SetAudioBoost), + (SettingsTab::Stream, 4) => ColorDepth::ALL + .get(index) + .copied() + .map(AppCommand::SetColorDepth), + (SettingsTab::Controls, 0) => RearTouchMode::ALL + .get(index) + .copied() + .map(AppCommand::SetRearTouchMode), + (SettingsTab::Controls, 1) => StickZones::ALL.get(index).copied().map(AppCommand::SetStickZones), + (SettingsTab::Controls, 2) => TriggerIntensity::ALL + .get(index) + .copied() + .map(AppCommand::SetTriggerIntensity), + (SettingsTab::App, 0) => Locale::ALL.get(index).copied().map(AppCommand::SetLocale), + (SettingsTab::App, 1) => Some(AppCommand::ToggleSessionTimer), + (SettingsTab::Controls, 3) => Some(AppCommand::ToggleGameProfile), + (SettingsTab::Controls, 4) => Some(AppCommand::ToggleTriggerSwap), + (SettingsTab::Stream, 0) => { + if index == 0 { + Some(AppCommand::SetRegion(String::new())) + } else { + regions.get(index - 1).map(|region| AppCommand::SetRegion(region.url.clone())) + } + } + _ => None, + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index d78f17b..44cdccd 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -93,10 +93,26 @@ fn cart_frame(ctx: &egui::Context) -> Option> { embedded_texture(ctx, "vita_cart_frame", CART_PNG, 200) } +fn vita_front(ctx: &egui::Context) -> Option> { + const FRONT_PNG: &[u8] = include_bytes!("../../assets/front.png"); + embedded_texture(ctx, "vita_front", FRONT_PNG, 480) +} + +fn vita_back(ctx: &egui::Context) -> Option> { + const BACK_PNG: &[u8] = include_bytes!("../../assets/back.png"); + embedded_texture(ctx, "vita_back", BACK_PNG, 480) +} + const CART_ASPECT: f32 = 447.0 / 558.0; const CART_WINDOW_X: (f32, f32) = (0.1611, 0.8479); const CART_WINDOW_Y: (f32, f32) = (0.0376, 0.8513); +const REAR_PAD_X: (f32, f32) = (0.20, 0.80); +const REAR_PAD_Y: (f32, f32) = (0.18, 0.82); + +const FRONT_SCREEN_X: (f32, f32) = (0.20, 0.79); +const FRONT_SCREEN_Y: (f32, f32) = (0.12, 0.83); + /// Decodes a PNG compiled into the binary into exactly one cached egui texture. fn embedded_texture( ctx: &egui::Context, @@ -159,6 +175,13 @@ enum StreamIcon { Collapse, Expand, Controls, + Clock, + Globe, + Monitor, + Person, + Signal, + Check, + ChevronDown, } fn paint_stream_icon(painter: &egui::Painter, rect: egui::Rect, icon: StreamIcon, tint: egui::Color32) { @@ -269,6 +292,116 @@ fn paint_stream_icon(painter: &egui::Painter, rect: egui::Rect, icon: StreamIcon painter.circle_filled(egui::pos2(btn_cx - 1.8, dpad_cy + 1.2), 1.0, tint); painter.circle_filled(egui::pos2(btn_cx + 1.8, dpad_cy - 1.2), 1.0, tint); } + StreamIcon::Clock => { + let stroke = egui::Stroke::new(1.2_f32, tint); + let center = rect.center(); + let radius = rect.width().min(rect.height()) * 0.45; + painter.circle_stroke(center, radius, stroke); + + let cx = center.x; + let cy = center.y; + painter.line_segment([center, egui::pos2(cx + radius * 0.4, cy - radius * 0.5)], stroke); + painter.line_segment([center, egui::pos2(cx - radius * 0.5, cy)], stroke); + } + StreamIcon::Globe => { + let stroke = egui::Stroke::new(1.2_f32, tint); + let center = rect.center(); + let radius = rect.width().min(rect.height()) * 0.42; + painter.circle_stroke(center, radius, stroke); + let meridian: Vec = (0..=8) + .map(|step| { + let t = step as f32 / 8.0 * std::f32::consts::PI - std::f32::consts::FRAC_PI_2; + egui::pos2(center.x + radius * 0.42 * t.sin(), center.y - radius * t.cos()) + }) + .collect(); + painter.line(meridian, stroke); + painter.line_segment( + [egui::pos2(center.x - radius, center.y), egui::pos2(center.x + radius, center.y)], + stroke, + ); + } + StreamIcon::Monitor => { + let stroke = egui::Stroke::new(1.2_f32, tint); + let inset = rect.shrink2(egui::vec2(1.0, 3.0)); + let screen = egui::Rect::from_min_size( + inset.min, + egui::vec2(inset.width(), inset.height() * 0.75), + ); + painter.rect_stroke(screen, 1.5, stroke, egui::StrokeKind::Inside); + let stand_top = screen.max.y; + let cx = inset.center().x; + painter.line_segment( + [egui::pos2(cx, stand_top), egui::pos2(cx, inset.max.y)], + stroke, + ); + painter.line_segment( + [ + egui::pos2(cx - inset.width() * 0.22, inset.max.y), + egui::pos2(cx + inset.width() * 0.22, inset.max.y), + ], + stroke, + ); + } + StreamIcon::Person => { + let stroke = egui::Stroke::new(1.2_f32, tint); + let center = rect.center(); + let head_r = rect.height() * 0.16; + let head_c = egui::pos2(center.x, rect.min.y + rect.height() * 0.32); + painter.circle_stroke(head_c, head_r, stroke); + let shoulders = egui::Rect::from_center_size( + egui::pos2(center.x, rect.max.y - rect.height() * 0.10), + egui::vec2(rect.width() * 0.62, rect.height() * 0.38), + ); + painter.rect_stroke( + shoulders, + egui::CornerRadius { + nw: (shoulders.width() * 0.5) as u8, + ne: (shoulders.width() * 0.5) as u8, + sw: 0, + se: 0, + }, + stroke, + egui::StrokeKind::Inside, + ); + } + StreamIcon::Signal => { + let inset = rect.shrink(2.0); + let bar_width = inset.width() / 5.0; + for (index, height_fraction) in [0.35_f32, 0.62, 0.85, 1.0].into_iter().enumerate() { + let height = inset.height() * height_fraction; + let x = inset.min.x + index as f32 * bar_width * 1.3; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(x, inset.max.y - height), + egui::vec2(bar_width * 0.7, height), + ), + 0.5, + tint, + ); + } + } + StreamIcon::Check => { + let stroke = egui::Stroke::new(1.8_f32, tint); + let c = rect.center(); + let dx = rect.width() * 0.22; + let dy = rect.height() * 0.22; + painter.line_segment( + [egui::pos2(c.x - dx, c.y), egui::pos2(c.x - dx * 0.15, c.y + dy)], + stroke, + ); + painter.line_segment( + [egui::pos2(c.x - dx * 0.15, c.y + dy), egui::pos2(c.x + dx, c.y - dy)], + stroke, + ); + } + StreamIcon::ChevronDown => { + let stroke = egui::Stroke::new(1.6_f32, tint); + let c = rect.center(); + let dx = rect.width() * 0.24; + let dy = rect.height() * 0.16; + painter.line_segment([egui::pos2(c.x - dx, c.y - dy), egui::pos2(c.x, c.y + dy)], stroke); + painter.line_segment([egui::pos2(c.x, c.y + dy), egui::pos2(c.x + dx, c.y - dy)], stroke); + } } } @@ -416,6 +549,23 @@ fn clear_stream_touch_reservations(ctx: &egui::Context) { }); } +const KEYBOARD_CAP_SIZE: egui::Vec2 = egui::vec2(38.0, 26.0); +const KEYBOARD_CAP_SPACING: f32 = 2.0; +const KEYBOARD_COLUMNS: f32 = 15.0; +const KEYBOARD_ROWS: f32 = 6.0; +const KEYBOARD_PADDING: f32 = 8.0; + +pub(crate) fn keyboard_panel_rect(screen: egui::Rect) -> egui::Rect { + let width = KEYBOARD_COLUMNS * KEYBOARD_CAP_SIZE.x + + (KEYBOARD_COLUMNS - 1.0) * KEYBOARD_CAP_SPACING + + KEYBOARD_PADDING * 2.0; + let height = KEYBOARD_ROWS * KEYBOARD_CAP_SIZE.y + + (KEYBOARD_ROWS - 1.0) * KEYBOARD_CAP_SPACING + + KEYBOARD_PADDING * 2.0; + let min = egui::pos2(screen.center().x - width / 2.0, screen.max.y - height); + egui::Rect::from_min_size(min, egui::vec2(width, height)) +} + /// Resolves the currently highlighted game. pub(crate) fn selected_game<'a>( games: &'a [GameSummary], @@ -426,7 +576,7 @@ pub(crate) fn selected_game<'a>( } /// Formats `id` with a single Fluent argument. -fn text1(i18n: &I18n, id: &'static str, key: &'static str, value: impl ToString) -> String { +fn text1(i18n: &I18n, id: &'static str, key: &'static str, value: impl ToString) -> std::rc::Rc { let mut args = FluentArgs::new(); args.set(key, arg_string(value.to_string())); i18n.text_with(id, args) @@ -437,7 +587,7 @@ fn text2( id: &'static str, first: (&'static str, impl ToString), second: (&'static str, impl ToString), -) -> String { +) -> std::rc::Rc { let mut args = FluentArgs::new(); args.set(first.0, arg_string(first.1.to_string())); args.set(second.0, arg_string(second.1.to_string())); @@ -456,7 +606,6 @@ struct CatalogView<'a> { covers: &'a CoverStore, http_client: &'a Client, status_note: Option<&'a str>, - locale: crate::locale::Locale, sort: CatalogSort, filter: CatalogFilter, /// `pageInfo.totalCount` from the server - generally far more than we page in, so the header @@ -467,9 +616,65 @@ struct CatalogView<'a> { /// Starred app ids. Held by the app rather than re-read here, because this is rebuilt on every /// repaint and the list lives on the memory card. favorites: &'a std::collections::BTreeSet, + regions: RegionsView<'a>, + settings: SettingsView, +} + +#[derive(Clone, Copy)] +struct SettingsView { + open: bool, + tab: crate::app::settings_menu::SettingsTab, + focus: usize, + expanded: Option, + option_focus: usize, +} + +impl SettingsView { + fn from_app(app: &App) -> Self { + Self { + open: app.settings_open, + tab: app.settings_tab, + focus: app.settings_focus, + expanded: app.settings_expanded, + option_focus: app.settings_option_focus, + } + } +} + +struct RegionsView<'a> { + list: &'a [crate::gfn::regions::StreamRegion], + busy: bool, + measuring: bool, + error: Option<&'a str>, +} + +impl<'a> RegionsView<'a> { + fn from_app(app: &'a App) -> Self { + Self { + list: &app.regions, + busy: app.is_loading_regions(), + measuring: app.regions_measuring, + error: app.regions_error.as_deref(), + } + } } +const SPLASH_FADE_IN: f64 = 0.55; +const SPLASH_HOLD: f64 = 1.05; +const SPLASH_FADE_OUT: f64 = 0.60; +const SPLASH_TOTAL: f64 = SPLASH_FADE_IN + SPLASH_HOLD + SPLASH_FADE_OUT; +const SPLASH_OPAQUE_UNTIL: f64 = SPLASH_FADE_IN + SPLASH_HOLD; + pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { + let splash_elapsed = ctx.input(|input| input.time); + if splash_elapsed < SPLASH_TOTAL { + ctx.request_repaint(); + } + if splash_elapsed < SPLASH_OPAQUE_UNTIL { + splash_overlay(ctx); + return Vec::new(); + } + let i18n = I18n::new(app.locale); let mut commands = Vec::new(); @@ -502,14 +707,23 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { covers, http_client: &app.http_client, status_note: app.status_note.as_deref(), - locale: app.locale, sort: app.catalog_sort, filter: app.catalog_filter, total_count: app.catalog_total_count(), favorites: &app.favorites, + regions: RegionsView::from_app(app), + settings: SettingsView::from_app(app), loading_more: app.is_loading_more_catalog(), }, )); + if app.server_picker_open { + commands.extend(server_picker_modal( + ctx, + &i18n, + app, + selected_game(games, filtered_indices, *selected), + )); + } } AppState::CreatingSession { user, @@ -544,12 +758,13 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { covers, http_client: &app.http_client, status_note: None, - locale: app.locale, sort: app.catalog_sort, filter: app.catalog_filter, total_count: app.catalog_total_count(), loading_more: app.is_loading_more_catalog(), favorites: &app.favorites, + regions: RegionsView::from_app(app), + settings: SettingsView::from_app(app), }; if let Some(cmd) = session_launch_overlay(ctx, &i18n, &catalog, &launch) { commands.push(cmd); @@ -585,12 +800,13 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { covers, http_client: &app.http_client, status_note: None, - locale: app.locale, sort: app.catalog_sort, filter: app.catalog_filter, total_count: app.catalog_total_count(), loading_more: app.is_loading_more_catalog(), favorites: &app.favorites, + regions: RegionsView::from_app(app), + settings: SettingsView::from_app(app), }; if let Some(cmd) = session_launch_overlay(ctx, &i18n, &catalog, &launch) { commands.push(cmd); @@ -630,12 +846,13 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { covers, http_client: &app.http_client, status_note: None, - locale: app.locale, sort: app.catalog_sort, filter: app.catalog_filter, total_count: app.catalog_total_count(), loading_more: app.is_loading_more_catalog(), favorites: &app.favorites, + regions: RegionsView::from_app(app), + settings: SettingsView::from_app(app), }; if let Some(cmd) = session_launch_overlay(ctx, &i18n, &catalog, &launch) { commands.push(cmd); @@ -654,7 +871,7 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { selected_game(games, filtered_indices, *selected), peer.video_frame().is_some(), app.status_note.as_deref(), - crate::ime::is_open(), + app.keyboard_open, app.show_stream_stats, app.toolbar_expanded, app.mouse_trackpad_enabled, @@ -671,6 +888,21 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { commands.push(cmd); } + if app.keyboard_open && matches!(app.state, AppState::Streaming { .. }) { + commands.extend(on_screen_keyboard(ctx, app.key_shift, app.key_ctrl, app.key_alt)); + } + + if crate::gfn::stream_prefs::session_timer_enabled() { + if let AppState::Streaming { session_start, .. } = &app.state { + session_timer_overlay( + ctx, + *session_start, + app.membership_tier.as_deref(), + app.battery, + ); + } + } + if app.show_controls_hint && matches!(app.state, AppState::Streaming { .. }) && let Some(cmd) = controls_hint_overlay(ctx, &i18n) { @@ -688,12 +920,6 @@ pub fn build_ui(ctx: &egui::Context, app: &App) -> Vec { commands } -const SPLASH_FADE_IN: f64 = 0.55; -const SPLASH_HOLD: f64 = 1.05; -const SPLASH_FADE_OUT: f64 = 0.60; -const SPLASH_TOTAL: f64 = SPLASH_FADE_IN + SPLASH_HOLD + SPLASH_FADE_OUT; - -/// Brief GeForce NOW splash drawn over whatever screen is already live. fn splash_overlay(ctx: &egui::Context) { let elapsed = ctx.input(|input| input.time); if elapsed >= SPLASH_TOTAL { @@ -719,11 +945,15 @@ fn splash_overlay(ctx: &egui::Context) { egui::Id::new("splash_overlay"), )); - painter.rect_filled( - screen, - 0.0, - egui::Color32::from_rgba_unmultiplied(0x0e, 0x0e, 0x0e, alpha_u8), - ); + if alpha_u8 >= 255 { + painter.rect_filled(screen, 0.0, BG_DEEP); + } else { + painter.rect_filled( + screen, + 0.0, + egui::Color32::from_rgba_unmultiplied(0x0e, 0x0e, 0x0e, alpha_u8), + ); + } let Some(logo) = geforce_logo(ctx) else { painter.text( @@ -767,12 +997,12 @@ fn login_screen(ctx: &egui::Context, i18n: &I18n, app: &App) { ui.vertical_centered(|ui| { ui.add_space(80.0); ui.heading(egui::RichText::new("OpenNOW Vita").size(32.0).strong().color(ACCENT)); - ui.label(i18n.text("login-subtitle")); + ui.label(i18n.text("login-subtitle").as_ref()); ui.add_space(24.0); button_hint(ui, &i18n.text("login-hint"), 13.0, TEXT_DIM, true); ui.add_space(24.0); if let Some(last_input) = app.last_input { - ui.weak(text1(i18n, "login-last-input", "input", format!("{last_input:?}"))); + ui.weak(text1(i18n, "login-last-input", "input", format!("{last_input:?}")).as_ref()); } }); }); @@ -784,7 +1014,7 @@ fn starting_login_screen(ctx: &egui::Context, i18n: &I18n) { ui.add_space(120.0); ui.spinner(); ui.add_space(12.0); - ui.label(i18n.text("login-requesting-code")); + ui.label(i18n.text("login-requesting-code").as_ref()); }); }); } @@ -797,13 +1027,13 @@ fn device_code_screen( egui::CentralPanel::default().show(ctx, |ui| { ui.add_space(24.0); ui.vertical_centered(|ui| { - ui.heading(i18n.text("device-title")); + ui.heading(i18n.text("device-title").as_ref()); }); ui.add_space(16.0); ui.horizontal(|ui| { ui.vertical(|ui| { ui.set_width(ui.available_width() - 220.0); - ui.label(i18n.text("device-step-open")); + ui.label(i18n.text("device-step-open").as_ref()); ui.add_space(4.0); ui.label( egui::RichText::new(&challenge.verification_uri_complete) @@ -811,7 +1041,7 @@ fn device_code_screen( .strong(), ); ui.add_space(20.0); - ui.label(i18n.text("device-step-scan")); + ui.label(i18n.text("device-step-scan").as_ref()); ui.add_space(12.0); egui::Frame::NONE .fill(BG_PANEL) @@ -826,7 +1056,7 @@ fn device_code_screen( ); }); ui.add_space(20.0); - ui.label(i18n.text("device-waiting")); + ui.label(i18n.text("device-waiting").as_ref()); }); ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { @@ -840,11 +1070,11 @@ fn loading_catalog_screen(ctx: &egui::Context, i18n: &I18n, user: &GfnUser) { egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(80.0); - ui.heading(text1(i18n, "catalog-welcome", "name", &user.display_name)); + ui.heading(text1(i18n, "catalog-welcome", "name", &user.display_name).as_ref()); ui.add_space(20.0); ui.spinner(); ui.add_space(12.0); - ui.label(i18n.text("catalog-loading")); + ui.label(i18n.text("catalog-loading").as_ref()); }); }); } @@ -880,7 +1110,7 @@ fn catalog_screen(ctx: &egui::Context, i18n: &I18n, view: &CatalogView<'_>) -> V } None => { ui.label( - egui::RichText::new(i18n.text("catalog-library-title")) + egui::RichText::new(i18n.text("catalog-library-title").as_ref()) .strong() .size(20.0) .color(ACCENT), @@ -895,12 +1125,15 @@ fn catalog_screen(ctx: &egui::Context, i18n: &I18n, view: &CatalogView<'_>) -> V "catalog-count" }; ui.label( - egui::RichText::new(text2( - i18n, - key, - ("shown", view.filtered_indices.len()), - ("total", total), - )) + egui::RichText::new( + text2( + i18n, + key, + ("shown", view.filtered_indices.len()), + ("total", total), + ) + .as_ref(), + ) .size(11.0) .color(TEXT_DIM), ); @@ -934,9 +1167,13 @@ fn catalog_screen(ctx: &egui::Context, i18n: &I18n, view: &CatalogView<'_>) -> V ui.painter().circle_filled(dot.center(), 4.0, ACCENT); ui.add_space(10.0); - if let Some(cmd) = language_picker(ui, i18n, view.locale, view.user) { - commands.push(cmd); - } + commands.extend(settings_modal( + ui, + i18n, + view.user, + &view.regions, + view.settings, + )); ui.add_space(6.0); if let Some(cmd) = sort_picker(ui, i18n, view.sort, view.games) { commands.push(cmd); @@ -989,13 +1226,8 @@ fn catalog_screen(ctx: &egui::Context, i18n: &I18n, view: &CatalogView<'_>) -> V /// First-run explainer for the buttons the Vita does not physically have. /// -/// Animated deliberately: the quadrants light up one after another, because a static diagram of a -/// blank black rectangle does not read as "the back of your console" at a glance. fn controls_hint_overlay(ctx: &egui::Context, i18n: &I18n) -> Option { let mut command = None; - // Timed against egui's own clock, the way `splash_overlay` does it. `animate_value_with_time` - // looks like the obvious tool but returns the target outright on its first call, so the - // animation was over before it was ever drawn. const HINT_ANIMATION: f64 = 0.9; let started_id = egui::Id::new("controls_hint_started_at"); let now = ctx.input(|input| input.time); @@ -1003,8 +1235,6 @@ fn controls_hint_overlay(ctx: &egui::Context, i18n: &I18n) -> Option .data_mut(|data| *data.get_temp_mut_or_insert_with(started_id, || now)); let progress = ((now - started_at) / HINT_ANIMATION).clamp(0.0, 1.0) as f32; if progress < 1.0 { - // Nothing else drives frames here, and the reactive repaint would otherwise let the - // animation sit on whatever frame it started on. ctx.request_repaint(); } @@ -1018,73 +1248,25 @@ fn controls_hint_overlay(ctx: &egui::Context, i18n: &I18n) -> Option .inner_margin(egui::Margin::symmetric(16, 14)), ) .show(ctx, |ui| { - ui.set_width(310.0); - ui.heading(egui::RichText::new(i18n.text("controls-hint-heading")).size(15.0)); + ui.set_width(320.0); + ui.heading(egui::RichText::new(i18n.text("controls-hint-heading").as_ref()).size(15.0)); ui.add_space(2.0); ui.label( - egui::RichText::new(i18n.text("controls-hint-rear")) + egui::RichText::new(i18n.text("controls-hint-rear").as_ref()) .size(10.0) .color(TEXT_DIM), ); ui.add_space(8.0); - - // The rear panel, drawn to the same 2x2 split the input code actually uses. - let (rect, _) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), 92.0), egui::Sense::hover()); - let painter = ui.painter(); - painter.rect_filled(rect, 6.0, BG_DEEP); - painter.rect_stroke( - rect, - 6u8, - egui::Stroke::new(1.0_f32, BORDER), - egui::StrokeKind::Inside, - ); - - const QUADRANTS: [(&str, bool, bool); 2] = [("L2", true, true), ("R2", false, true)]; - for (index, (label, left, top)) in QUADRANTS.into_iter().enumerate() { - // Each quadrant starts a quarter of the way after the previous one. - let start = index as f32 * 0.18; - let local = ((progress - start) / 0.4).clamp(0.0, 1.0); - if local <= 0.0 { - continue; - } - let _ = top; - // Halves, not quadrants: the stick clicks live on the front screen now. - let cell = egui::Rect::from_min_size( - egui::pos2(if left { rect.min.x } else { rect.center().x }, rect.min.y), - egui::vec2(rect.width() / 2.0, rect.height()), - ) - .shrink(4.0); - let alpha = (local * 255.0) as u8; - painter.rect_filled( - cell, - 4.0, - ACCENT.gamma_multiply(0.18).linear_multiply(local), - ); - painter.rect_stroke( - cell, - 4u8, - egui::Stroke::new(1.0_f32, ACCENT.linear_multiply(local)), - egui::StrokeKind::Inside, - ); - painter.text( - cell.center(), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::proportional(15.0), - egui::Color32::from_rgba_unmultiplied(255, 255, 255, alpha), - ); - } - + rear_touch_diagram(ui, 112.0, Some(progress)); ui.add_space(10.0); ui.label( - egui::RichText::new(i18n.text("controls-hint-sticks")) + egui::RichText::new(i18n.text("controls-hint-sticks").as_ref()) .size(10.0) .color(TEXT_DIM), ); ui.add_space(4.0); ui.label( - egui::RichText::new(i18n.text("controls-hint-touch")) + egui::RichText::new(i18n.text("controls-hint-touch").as_ref()) .size(10.0) .color(TEXT_DIM), ); @@ -1093,7 +1275,7 @@ fn controls_hint_overlay(ctx: &egui::Context, i18n: &I18n) -> Option if ui .add_sized( [130.0, 28.0], - egui::Button::new(i18n.text("controls-hint-dismiss")).fill(BG_RAISED), + egui::Button::new(i18n.text("controls-hint-dismiss").as_ref()).fill(BG_RAISED), ) .clicked() { @@ -1104,194 +1286,1092 @@ fn controls_hint_overlay(ctx: &egui::Context, i18n: &I18n) -> Option command } -/// Settings button, and the modal it opens. +const SETTINGS_MODAL_W: f32 = 520.0; +const SETTINGS_MODAL_H: f32 = 360.0; +const SETTINGS_BODY_H: f32 = 268.0; + /// -/// This was a dropdown anchored under the button. Every option added made it taller until it ran -/// off the bottom of a 544 px screen with no way to reach the last rows. A modal is centred, sized -/// to the screen, and scrolls - so it cannot outgrow the display. -fn language_picker( +fn settings_modal( + ui: &mut egui::Ui, + i18n: &I18n, + user: &GfnUser, + regions: &RegionsView<'_>, + settings: SettingsView, +) -> Vec { + use crate::app::settings_menu::SettingsTab; + + let mut commands = Vec::new(); + let gear = ui.add_sized( + [34.0, 30.0], + egui::Button::new(egui::RichText::new("\u{2699}").size(15.0)).fill(BG_RAISED), + ); + if gear.clicked() { + commands.push(AppCommand::OpenSettings); + } + if !settings.open { + return commands; + } + + let modal = egui::Modal::new(egui::Id::new("settings_modal")) + .backdrop_color(egui::Color32::from_black_alpha(180)) + .frame( + egui::Frame::default() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(10.0) + .inner_margin(egui::Margin::symmetric(14, 12)), + ) + .show(ui.ctx(), |ui| { + let mut close_requested = false; + ui.set_width(SETTINGS_MODAL_W); + ui.set_min_height(SETTINGS_MODAL_H); + ui.set_max_height(SETTINGS_MODAL_H); + + ui.horizontal(|ui| { + ui.heading(egui::RichText::new(i18n.text("settings-heading").as_ref()).size(15.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_sized( + [30.0, 26.0], + egui::Button::new(egui::RichText::new("X").size(14.0).strong()), + ) + .clicked() + { + close_requested = true; + } + }); + }); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for tab in SettingsTab::ALL { + if let Some(cmd) = settings_tab_button(ui, i18n, tab, settings.tab) { + commands.push(cmd); + } + } + }); + ui.add_space(4.0); + ui.separator(); + + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), SETTINGS_BODY_H), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .id_salt("settings_content") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + if let Some(email) = &user.email { + if settings.tab == SettingsTab::Account { + ui.label( + egui::RichText::new(email).size(12.0).color(egui::Color32::WHITE), + ); + ui.label( + egui::RichText::new(format!( + "OpenNOW-Vita {}", + env!("CARGO_PKG_VERSION") + )) + .size(10.0) + .color(TEXT_DIM), + ); + ui.add_space(6.0); + ui.separator(); + } + } + + if settings.tab == SettingsTab::Controls { + for cmd in controls_settings_panel(ui, i18n, settings, regions) { + commands.push(cmd); + } + } else { + let row_count = settings.tab.row_count(); + for row in 0..row_count { + let Some(info) = + crate::app::settings_menu::row_info(settings.tab, row) + else { + continue; + }; + let focused = settings.focus == row; + let expanded = settings.expanded == Some(row); + if let Some(cmd) = settings_item( + ui, + i18n, + settings.tab, + row, + &info, + focused, + expanded, + settings.option_focus, + regions, + false, + ) { + commands.push(cmd); + } + ui.separator(); + } + } + }); + }, + ); + + close_requested + }); + + if modal.inner || modal.should_close() { + commands.push(AppCommand::CloseSettings); + } + commands +} + +fn controls_settings_panel( + ui: &mut egui::Ui, + i18n: &I18n, + settings: SettingsView, + regions: &RegionsView<'_>, +) -> Vec { + use crate::app::settings_menu::SettingsTab; + + let mut commands = Vec::new(); + let tab = SettingsTab::Controls; + let gap = 10.0; + let half = ((ui.available_width() - gap) / 2.0).max(120.0); + + ui.add_space(2.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(half); + if let Some(info) = crate::app::settings_menu::row_info(tab, 0) { + rear_touch_diagram(ui, 92.0, None); + ui.add_space(4.0); + if let Some(cmd) = + settings_chip_choice(ui, i18n, tab, 0, &info, settings.focus == 0) + { + commands.push(cmd); + } + } + }); + ui.add_space(gap); + ui.vertical(|ui| { + ui.set_width(half); + if let Some(info) = crate::app::settings_menu::row_info(tab, 1) { + front_stick_zones_diagram(ui, 92.0); + ui.add_space(4.0); + if let Some(cmd) = + settings_chip_choice(ui, i18n, tab, 1, &info, settings.focus == 1) + { + commands.push(cmd); + } + } + }); + }); + + ui.add_space(6.0); + ui.separator(); + + for row in 2..tab.row_count() { + let Some(info) = crate::app::settings_menu::row_info(tab, row) else { + continue; + }; + if let Some(cmd) = settings_item( + ui, + i18n, + tab, + row, + &info, + settings.focus == row, + settings.expanded == Some(row), + settings.option_focus, + regions, + false, + ) { + commands.push(cmd); + } + ui.separator(); + } + + commands +} + +fn settings_chip_choice( + ui: &mut egui::Ui, + i18n: &I18n, + tab: crate::app::settings_menu::SettingsTab, + row: usize, + info: &crate::app::settings_menu::RowInfo, + focused: bool, +) -> Option { + let mut command = None; + ui.add_space(4.0); + let current = crate::app::settings_menu::current_option_index(tab, row, &[], i18n.locale()); + let count = crate::app::settings_menu::option_count(tab, row, 0); + + let block = ui.vertical(|ui| { + ui.label(egui::RichText::new(i18n.text(info.label_key).as_ref()).size(12.5).strong()); + if let Some(desc_key) = info.desc_key { + ui.label(egui::RichText::new(i18n.text(desc_key).as_ref()).size(9.5).color(TEXT_DIM)); + } + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for option in 0..count { + let label = crate::app::settings_menu::option_label(tab, row, option, i18n, &[]); + let selected = option == current; + let fill = if selected { + ACCENT.gamma_multiply(0.35) + } else { + BG_RAISED + }; + let text = egui::RichText::new(label) + .size(11.0) + .color(if selected { + egui::Color32::WHITE + } else { + TEXT_DIM + }); + if ui + .add(egui::Button::new(text).fill(fill).min_size(egui::vec2(0.0, 28.0))) + .clicked() + { + command = Some(AppCommand::ChooseSettingsOption(row, option)); + } + } + }); + }); + if focused { + ui.painter().rect_stroke( + block.response.rect.expand(3.0), + 4.0, + egui::Stroke::new(1.5_f32, ACCENT), + egui::StrokeKind::Outside, + ); + } + + command +} + +fn battery_color(battery: crate::power::BatteryStatus) -> egui::Color32 { + if battery.charging { + ACCENT + } else if battery.is_critical() { + DANGER + } else if battery.should_warn() { + egui::Color32::from_rgb(0xe0, 0xa8, 0x30) + } else { + egui::Color32::WHITE + } +} + +fn paint_battery(painter: &egui::Painter, rect: egui::Rect, battery: crate::power::BatteryStatus) { + let color = battery_color(battery); + let body = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.max.x - 3.0, rect.max.y), + ) + .shrink2(egui::vec2(0.0, 3.0)); + painter.rect_stroke( + body, + 2.0, + egui::Stroke::new(1.2_f32, color), + egui::StrokeKind::Inside, + ); + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(body.max.x + 1.0, body.center().y - 3.0), + egui::vec2(2.0, 6.0), + ), + 1.0, + color, + ); + let inner = body.shrink(2.5); + let filled = inner.width() * (f32::from(battery.percent) / 100.0); + if filled > 0.5 { + painter.rect_filled( + egui::Rect::from_min_size(inner.min, egui::vec2(filled, inner.height())), + 1.0, + color, + ); + } + if battery.charging { + let c = body.center(); + painter.add(egui::Shape::convex_polygon( + vec![ + egui::pos2(c.x + 1.0, c.y - 5.0), + egui::pos2(c.x - 2.5, c.y + 0.5), + egui::pos2(c.x - 0.2, c.y + 0.5), + egui::pos2(c.x - 1.0, c.y + 5.0), + egui::pos2(c.x + 2.5, c.y - 0.5), + egui::pos2(c.x + 0.2, c.y - 0.5), + ], + BG_DEEP, + egui::Stroke::new(1.0_f32, BG_DEEP), + )); + } +} + +fn uv_subrect(image: egui::Rect, x: (f32, f32), y: (f32, f32)) -> egui::Rect { + egui::Rect::from_min_max( + egui::pos2( + image.min.x + image.width() * x.0, + image.min.y + image.height() * y.0, + ), + egui::pos2( + image.min.x + image.width() * x.1, + image.min.y + image.height() * y.1, + ), + ) +} + +fn allocate_device_image( + ui: &mut egui::Ui, + texture: &egui::TextureHandle, + max_height: f32, +) -> Option { + let size = texture.size_vec2(); + let width = ui.available_width().max(1.0); + let height = (width * size.y / size.x.max(1.0)).min(max_height).max(1.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + if !ui.is_rect_visible(rect) { + return None; + } + + let painter = ui.painter(); + painter.rect_filled(rect, 6.0, BG_DEEP); + painter.rect_stroke( + rect, + 6u8, + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + + let pad = 4.0; + let inner = rect.shrink(pad); + let scale = (inner.width() / size.x.max(1.0)).min(inner.height() / size.y.max(1.0)); + let draw = egui::vec2(size.x * scale, size.y * scale); + let image = egui::Rect::from_center_size(inner.center(), draw); + painter.image( + texture.id(), + image, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + Some(image) +} + +fn paint_pulsing_zone( + painter: &egui::Painter, + cell: egui::Rect, + label: &str, + time: f64, + phase: f64, + font_size: f32, +) { + let pulse = 0.5 + 0.5 * ((time * 3.0 + phase * std::f64::consts::TAU).sin() as f32); + let cell = cell.shrink(2.0); + painter.rect_filled(cell, 4.0, ACCENT.gamma_multiply(0.12 + pulse * 0.25)); + painter.rect_stroke( + cell, + 4u8, + egui::Stroke::new(1.5_f32, ACCENT.gamma_multiply(0.4 + pulse * 0.6)), + egui::StrokeKind::Inside, + ); + painter.text( + cell.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(font_size), + egui::Color32::WHITE, + ); +} + +fn paint_intro_zone( + painter: &egui::Painter, + cell: egui::Rect, + label: &str, + local: f32, + font_size: f32, +) { + if local <= 0.0 { + return; + } + let cell = cell.shrink(2.0); + let alpha = (local * 255.0) as u8; + painter.rect_filled( + cell, + 4.0, + ACCENT.gamma_multiply(0.18).linear_multiply(local), + ); + painter.rect_stroke( + cell, + 4u8, + egui::Stroke::new(1.0_f32, ACCENT.linear_multiply(local)), + egui::StrokeKind::Inside, + ); + painter.text( + cell.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(font_size), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, alpha), + ); +} + +fn rear_touch_diagram(ui: &mut egui::Ui, max_height: f32, intro: Option) { + let Some(texture) = vita_back(ui.ctx()) else { + return; + }; + let Some(image) = allocate_device_image(ui, &texture, max_height) else { + return; + }; + + let mode = crate::gfn::stream_prefs::rear_touch_mode(); + let trigger_swap = crate::gfn::stream_prefs::trigger_swap_enabled(); + let (tl, tr) = if trigger_swap { + ("L1", "R1") + } else { + ("L2", "R2") + }; + let (bl, br) = ("L3", "R3"); + + let pad = uv_subrect(image, REAR_PAD_X, REAR_PAD_Y); + let painter = ui.painter(); + + if let Some(progress) = intro { + let halves = [("L2", 0.0_f32, 0.0), ("R2", 1.0, 0.5)]; + let cell_size = egui::vec2(pad.width() / 2.0, pad.height()); + for (index, (label, column, phase)) in halves.iter().enumerate() { + let start = index as f32 * 0.18; + let local = ((progress - start) / 0.4).clamp(0.0, 1.0); + let cell = egui::Rect::from_min_size( + egui::pos2(pad.min.x + column * cell_size.x, pad.min.y), + cell_size, + ); + let _ = phase; + paint_intro_zone(painter, cell, label, local, 12.0); + } + return; + } + + let time = ui.ctx().input(|input| input.time); + ui.ctx().request_repaint(); + + match mode { + crate::gfn::stream_prefs::RearTouchMode::Halves => { + let cell_size = egui::vec2(pad.width() / 2.0, pad.height()); + for (label, column, phase) in [(tl, 0.0_f32, 0.0_f64), (tr, 1.0, 0.5)] { + let cell = egui::Rect::from_min_size( + egui::pos2(pad.min.x + column * cell_size.x, pad.min.y), + cell_size, + ); + paint_pulsing_zone(painter, cell, label, time, phase, 12.0); + } + } + crate::gfn::stream_prefs::RearTouchMode::Quadrant => { + let cell_size = egui::vec2(pad.width() / 2.0, pad.height() / 2.0); + for (label, column, row, phase) in [ + (tl, 0.0_f32, 0.0_f32, 0.00_f64), + (tr, 1.0, 0.0, 0.25), + (bl, 0.0, 1.0, 0.50), + (br, 1.0, 1.0, 0.75), + ] { + let cell = egui::Rect::from_min_size( + egui::pos2( + pad.min.x + column * cell_size.x, + pad.min.y + row * cell_size.y, + ), + cell_size, + ); + paint_pulsing_zone(painter, cell, label, time, phase, 10.0); + } + } + } +} + +fn front_stick_zones_diagram(ui: &mut egui::Ui, max_height: f32) { + let Some(texture) = vita_front(ui.ctx()) else { + return; + }; + let Some(image) = allocate_device_image(ui, &texture, max_height) else { + return; + }; + + let zones = crate::gfn::stream_prefs::stick_zones(); + if !zones.is_active() { + return; + } + + let screen = uv_subrect(image, FRONT_SCREEN_X, FRONT_SCREEN_Y); + let time = ui.ctx().input(|input| input.time); + ui.ctx().request_repaint(); + + let painter = ui.painter(); + let top = crate::input::STICK_ZONE_TOP; + let width = crate::input::STICK_ZONE_WIDTH; + let left = egui::Rect::from_min_max( + egui::pos2(screen.min.x, screen.min.y + screen.height() * top), + egui::pos2(screen.min.x + screen.width() * width, screen.max.y), + ); + let right = egui::Rect::from_min_max( + egui::pos2( + screen.max.x - screen.width() * width, + screen.min.y + screen.height() * top, + ), + egui::pos2(screen.max.x, screen.max.y), + ); + paint_pulsing_zone(painter, left, "L3", time, 0.0, 10.0); + paint_pulsing_zone(painter, right, "R3", time, 0.5, 10.0); +} + +fn ping_color(ms: u32) -> egui::Color32 { + match ms { + 0..=40 => ACCENT, + 41..=80 => egui::Color32::from_rgb(0xe0, 0xa8, 0x30), + _ => DANGER, + } +} + +fn queue_color(position: u32) -> egui::Color32 { + match position { + 0..=9 => ACCENT, + 10..=24 => egui::Color32::from_rgb(0xe0, 0xa8, 0x30), + _ => DANGER, + } +} + +fn format_wait(seconds: u64) -> String { + if seconds >= 60 { + format!("~{}m", seconds / 60) + } else { + format!("~{seconds}s") + } +} + +fn server_picker_modal( + ctx: &egui::Context, + i18n: &I18n, + app: &App, + game: Option<&GameSummary>, +) -> Vec { + let mut commands = Vec::new(); + let regions = &app.regions; + let queue = &app.queue_stats; + let focus = app.server_picker_focus; + + let queue_for = |url: &str| { + crate::gfn::queue_stats::server_code_from_url(url).and_then(|code| queue.get(&code).copied()) + }; + let best_index = regions + .iter() + .enumerate() + .filter_map(|(index, region)| region.ping_ms.map(|ping| (index, region, ping))) + .min_by_key(|(_, region, ping)| { + let depth = queue_for(®ion.url).map_or(u32::MAX, |r| r.queue_position); + (*ping, depth) + }) + .map(|(index, _, _)| index); + let closest_index = regions + .iter() + .enumerate() + .filter_map(|(index, region)| region.ping_ms.map(|ping| (index, ping))) + .min_by_key(|(_, ping)| *ping) + .map(|(index, _)| index); + + egui::Modal::new(egui::Id::new("server_picker_modal")) + .backdrop_color(egui::Color32::from_black_alpha(190)) + .frame( + egui::Frame::default() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(10.0) + .inner_margin(egui::Margin::symmetric(14, 12)), + ) + .show(ctx, |ui| { + ui.set_width(520.0); + + ui.horizontal(|ui| { + ui.heading(egui::RichText::new(i18n.text("server-picker-heading").as_ref()).size(15.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_sized( + [30.0, 26.0], + egui::Button::new(egui::RichText::new("X").size(14.0).strong()), + ) + .clicked() + { + commands.push(AppCommand::CloseServerPicker); + } + }); + }); + if let Some(game) = game { + ui.label( + egui::RichText::new(&game.title) + .size(10.5) + .color(TEXT_DIM), + ); + } + ui.separator(); + + if app.is_loading_regions() { + ui.label( + egui::RichText::new(i18n.text(if app.regions_measuring { + "settings-region-measuring" + } else { + "settings-region-loading" + }).as_ref()) + .size(10.0) + .color(TEXT_DIM), + ); + } + if app.is_loading_queue_stats() { + ui.label( + egui::RichText::new(i18n.text("server-picker-queue-loading").as_ref()) + .size(10.0) + .color(TEXT_DIM), + ); + } + + egui::ScrollArea::vertical() + .id_salt("server_picker_list") + .max_height(200.0) + .show(ui, |ui| { + let auto_detail = best_index + .and_then(|index| regions.get(index)) + .map(|region| match region.ping_ms { + Some(ms) => format!("{} · {ms} ms", region.name), + None => region.name.clone(), + }); + if server_picker_row( + ui, + &i18n.text("settings-region-auto"), + auto_detail.as_deref(), + None, + None, + focus == 0, + None, + ) { + commands.push(AppCommand::FocusServerPicker(0)); + commands.push(AppCommand::LaunchOnServer(String::new())); + } + + for (index, region) in regions.iter().enumerate() { + let row = index + 1; + let badge = if Some(index) == best_index { + Some(i18n.text("server-picker-auto-badge")) + } else if Some(index) == closest_index { + Some(i18n.text("server-picker-closest-badge")) + } else { + None + }; + if server_picker_row( + ui, + ®ion.name, + None, + region.ping_ms, + queue_for(®ion.url), + focus == row, + badge.as_deref(), + ) { + commands.push(AppCommand::FocusServerPicker(row)); + commands.push(AppCommand::LaunchOnServer(region.url.clone())); + } + } + }); + + ui.add_space(4.0); + ui.label( + egui::RichText::new(i18n.text("server-picker-hint").as_ref()) + .size(9.5) + .color(TEXT_DIM), + ); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let launch = egui::Button::new( + egui::RichText::new(i18n.text("server-picker-launch").as_ref()) + .size(12.0) + .strong() + .color(BG_DEEP), + ) + .fill(ACCENT) + .min_size(egui::vec2(96.0, 28.0)); + if ui.add(launch).clicked() { + commands.push(AppCommand::LaunchOnServer( + match focus.checked_sub(1) { + None => String::new(), + Some(index) => regions + .get(index) + .map(|region| region.url.clone()) + .unwrap_or_default(), + }, + )); + } + ui.add_space(6.0); + let cancel = egui::Button::new( + egui::RichText::new(i18n.text("server-picker-cancel").as_ref()).size(12.0), + ) + .fill(BG_RAISED) + .min_size(egui::vec2(84.0, 28.0)); + if ui.add(cancel).clicked() { + commands.push(AppCommand::CloseServerPicker); + } + ui.add_space(6.0); + let refresh = ui.add_sized( + [28.0, 28.0], + egui::Button::new("").fill(BG_RAISED), + ); + if refresh.clicked() { + commands.push(AppCommand::LoadQueueStats); + commands.push(AppCommand::TestRegionLatency); + } + paint_stream_icon( + ui.painter(), + refresh.rect.shrink(7.0), + StreamIcon::Signal, + ACCENT, + ); + ui.add_space(8.0); + ui.add( + egui::Label::new( + egui::RichText::new(i18n.text("server-picker-powered-by").as_ref()) + .size(9.5) + .color(TEXT_DIM), + ) + .truncate(), + ); + }); + }); + }); + + commands +} + +fn server_picker_row( + ui: &mut egui::Ui, + name: &str, + detail: Option<&str>, + ping_ms: Option, + queue: Option, + focused: bool, + badge: Option<&str>, +) -> bool { + let height = if detail.is_some() { 34.0 } else { 26.0 }; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), height), egui::Sense::click()); + if !ui.is_rect_visible(rect) { + return response.clicked(); + } + + let painter = ui.painter(); + if focused { + painter.rect_filled(rect, 5.0, ACCENT.gamma_multiply(0.16)); + painter.rect_stroke( + rect, + 5.0, + egui::Stroke::new(1.0_f32, ACCENT), + egui::StrokeKind::Inside, + ); + } + + let text_x = rect.min.x + 8.0; + let name_y = if detail.is_some() { + rect.min.y + 11.0 + } else { + rect.center().y + }; + let name_end = painter.text( + egui::pos2(text_x, name_y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(11.5), + egui::Color32::WHITE, + ); + if let Some(detail) = detail { + painter.text( + egui::pos2(text_x, rect.min.y + 24.0), + egui::Align2::LEFT_CENTER, + detail, + egui::FontId::proportional(9.5), + TEXT_DIM, + ); + } + if let Some(badge) = badge { + painter.text( + egui::pos2(name_end.max.x + 8.0, name_y), + egui::Align2::LEFT_CENTER, + badge, + egui::FontId::proportional(8.5), + ACCENT, + ); + } + + let mut x = rect.max.x - 8.0; + if let Some(seconds) = queue.and_then(|reading| reading.eta_seconds) { + let drawn = painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::RIGHT_CENTER, + format_wait(seconds), + egui::FontId::proportional(10.0), + TEXT_DIM, + ); + x = drawn.min.x - 10.0; + } + if let Some(reading) = queue { + let drawn = painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::RIGHT_CENTER, + format!("Q:{}", reading.queue_position), + egui::FontId::proportional(10.5), + queue_color(reading.queue_position), + ); + x = drawn.min.x - 10.0; + } + if let Some(ms) = ping_ms { + painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::RIGHT_CENTER, + format!("{ms} ms"), + egui::FontId::proportional(10.5), + ping_color(ms), + ); + } + + response.clicked() +} + +fn settings_tab_button( + ui: &mut egui::Ui, + i18n: &I18n, + tab: crate::app::settings_menu::SettingsTab, + current: crate::app::settings_menu::SettingsTab, +) -> Option { + use crate::app::settings_menu::SettingsTab; + + let icon = match tab { + SettingsTab::Stream => StreamIcon::Globe, + SettingsTab::Controls => StreamIcon::Controls, + SettingsTab::App => StreamIcon::Monitor, + SettingsTab::Account => StreamIcon::Person, + }; + let active = tab == current; + let label = i18n.text(tab.label_key()); + let (rect, response) = ui.allocate_exact_size(egui::vec2(122.0, 28.0), egui::Sense::click()); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + if active { + painter.rect_filled(rect, 5.0, ACCENT.gamma_multiply(0.14)); + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + 8.0, rect.max.y - 3.0), + egui::vec2(rect.width() - 16.0, 2.0), + ), + 1.0, + ACCENT, + ); + } + let icon_rect = egui::Rect::from_center_size( + egui::pos2(rect.min.x + 16.0, rect.center().y), + egui::vec2(13.0, 13.0), + ); + paint_stream_icon(painter, icon_rect, icon, if active { ACCENT } else { TEXT_DIM }); + painter.text( + egui::pos2(rect.min.x + 30.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label.as_ref(), + egui::FontId::proportional(12.0), + if active { + egui::Color32::WHITE + } else { + TEXT_DIM + }, + ); + } + response.clicked().then_some(AppCommand::SetSettingsTab(tab)) +} + +fn settings_item( ui: &mut egui::Ui, i18n: &I18n, - current: crate::locale::Locale, - user: &GfnUser, + tab: crate::app::settings_menu::SettingsTab, + row: usize, + info: &crate::app::settings_menu::RowInfo, + focused: bool, + expanded: bool, + option_focus: usize, + regions: &RegionsView<'_>, + show_touch_diagrams: bool, ) -> Option { + use crate::app::settings_menu::RowKind; + let mut command = None; - let response = ui.add_sized( - [34.0, 30.0], - egui::Button::new(egui::RichText::new("\u{2699}").size(15.0)).fill(BG_RAISED), - ); + ui.add_space(4.0); - let open_id = egui::Id::new("settings_modal_open"); - let mut open = ui.ctx().data(|data| data.get_temp::(open_id).unwrap_or(false)); - // Opens only. It used to toggle, but the gear sits in the same screen corner as the modal's - // close button, so one tap could both close the modal and re-open it. - if response.clicked() { - open = true; - } - if !open { - ui.ctx().data_mut(|data| data.insert_temp(open_id, false)); - return command; + if matches!(info.kind, RowKind::Region) + && regions.list.is_empty() + && !regions.busy + && regions.error.is_none() + { + command = Some(AppCommand::LoadRegions); } - let modal = egui::Modal::new(egui::Id::new("settings_modal")) - .backdrop_color(egui::Color32::from_black_alpha(180)) - .frame( - egui::Frame::default() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .corner_radius(10.0) - .inner_margin(egui::Margin::symmetric(14, 12)), - ) - .show(ui.ctx(), |ui| { - let mut close_requested = false; - ui.set_width(300.0); - ui.horizontal(|ui| { - ui.heading(egui::RichText::new(i18n.text("settings-heading")).size(15.0)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // A plain letter, not "\u{2715}": the bundled font has no multiplication-X - // glyph, so that rendered as an empty tofu box. - if ui - .add_sized( - [30.0, 26.0], - egui::Button::new(egui::RichText::new("X").size(14.0).strong()), - ) - .clicked() - { - close_requested = true; - } - }); - }); - if let Some(email) = &user.email { - ui.label(egui::RichText::new(email).size(10.0).color(TEXT_DIM)); + let control_width = if matches!(info.kind, RowKind::Region) { + 200.0 + } else { + 160.0 + }; + let header_response = ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width((ui.available_width() - control_width).max(80.0)); + ui.label(egui::RichText::new(i18n.text(info.label_key).as_ref()).size(12.5).strong()); + if let Some(desc_key) = info.desc_key { + ui.label(egui::RichText::new(i18n.text(desc_key).as_ref()).size(9.5).color(TEXT_DIM)); } - ui.separator(); - - // Capped so the modal can never grow past the screen, however many options it gains. - egui::ScrollArea::vertical() - .max_height(330.0) - .show(ui, |ui| { - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-language-heading", - crate::locale::Locale::ALL.iter().copied(), - current, - |candidate| candidate.label().to_owned(), - ) { - command = Some(AppCommand::SetLocale(chosen)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + match info.kind { + RowKind::Toggle(on) => { + let game_only = info.label_key == "settings-game-profile-heading"; + let can_toggle = !game_only || crate::gfn::stream_prefs::active_game().is_some(); + let mut value = on; + let response = ui.add_enabled( + can_toggle, + egui::Checkbox::without_text(&mut value), + ); + if response.changed() && can_toggle { + command = Some(AppCommand::ChooseSettingsOption(row, 0)); } - - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-fps-heading", - crate::gfn::stream_prefs::StreamFps::ALL.iter().copied(), - crate::gfn::stream_prefs::fps(), - |candidate| candidate.value().to_string(), - ) { - command = Some(AppCommand::SetStreamFps(chosen)); + } + RowKind::Choice | RowKind::Region => { + let summary = if info.kind == RowKind::Region && regions.busy { + i18n.text(if regions.measuring { + "settings-region-measuring" + } else { + "settings-region-loading" + }) + .to_string() + } else { + crate::app::settings_menu::current_summary(tab, row, i18n, regions.list, i18n.locale()) + }; + let button = egui::Button::new(egui::RichText::new(format!("{summary} ")).size(11.0)) + .fill(BG_RAISED) + .min_size(egui::vec2(150.0, 28.0)); + let button_response = ui.add(button); + if button_response.clicked() { + command = Some(AppCommand::ExpandSettingsRow(if expanded { None } else { Some(row) })); } - - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-trigger-heading", - crate::gfn::stream_prefs::TriggerIntensity::ALL.iter().copied(), - crate::gfn::stream_prefs::trigger_intensity(), - |candidate| format!("{}%", u32::from(candidate.value()) * 100 / 255), - ) { - command = Some(AppCommand::SetTriggerIntensity(chosen)); + let chevron_rect = egui::Rect::from_center_size( + egui::pos2(button_response.rect.max.x - 14.0, button_response.rect.center().y), + egui::vec2(12.0, 12.0), + ); + paint_stream_icon(ui.painter(), chevron_rect, StreamIcon::ChevronDown, TEXT_DIM); + if info.kind == RowKind::Region { + let test_btn = ui.add_sized([28.0, 28.0], egui::Button::new("").fill(BG_RAISED)); + if test_btn.clicked() { + command = Some(if regions.list.is_empty() { + AppCommand::LoadRegions + } else { + AppCommand::TestRegionLatency + }); + } + paint_stream_icon(ui.painter(), test_btn.rect.shrink(7.0), StreamIcon::Signal, ACCENT); } + } + } + }); + }); - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-rear-touch-mode-heading", - crate::gfn::stream_prefs::RearTouchMode::ALL.iter().copied(), - crate::gfn::stream_prefs::rear_touch_mode(), - |candidate| i18n.text(candidate.label_key()), - ) { - command = Some(AppCommand::SetRearTouchMode(chosen)); - } + if focused { + ui.painter().rect_stroke( + header_response.response.rect.expand(3.0), + 4.0, + egui::Stroke::new(1.5_f32, ACCENT), + egui::StrokeKind::Outside, + ); + } - // little diagram, 2 halves or 4 quadrants depending on mode - let current_mode = crate::gfn::stream_prefs::rear_touch_mode(); - let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 70.0), egui::Sense::hover()); - let painter = ui.painter(); - painter.rect_filled(rect, 6.0, BG_DEEP); - painter.rect_stroke(rect, 6u8, egui::Stroke::new(1.0_f32, BORDER), egui::StrokeKind::Inside); - - let anim_time = ui.ctx().input(|i| i.time); - ui.ctx().request_repaint(); // for the pulse anim - - match current_mode { - crate::gfn::stream_prefs::RearTouchMode::Quadrant => { - let quadrants = [ - ("L2", rect.min.x, rect.min.y, 0.0), - ("R2", rect.center().x, rect.min.y, 0.25), - ("L3", rect.min.x, rect.center().y, 0.50), - ("R3", rect.center().x, rect.center().y, 0.75), - ]; - for (label, min_x, min_y, phase) in quadrants { - let pulse = 0.5 + 0.5 * ((anim_time * 3.0 + phase * std::f64::consts::TAU).sin() as f32); - let cell = egui::Rect::from_min_size( - egui::pos2(min_x, min_y), - egui::vec2(rect.width() / 2.0, rect.height() / 2.0), - ).shrink(3.0); - painter.rect_filled(cell, 4.0, ACCENT.gamma_multiply(0.12 + pulse * 0.25)); - painter.rect_stroke(cell, 4u8, egui::Stroke::new(1.5_f32, ACCENT.gamma_multiply(0.4 + pulse * 0.6)), egui::StrokeKind::Inside); - painter.text(cell.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), egui::Color32::WHITE); - } + if let RowKind::Region = info.kind { + if let Some(error) = regions.error { + ui.label(egui::RichText::new(error).size(10.0).color(DANGER)); + } + } + + if expanded { + let option_count = crate::app::settings_menu::option_count(tab, row, regions.list.len()); + egui::Frame::default() + .fill(BG_DEEP) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(6.0) + .inner_margin(egui::Margin::same(4)) + .show(ui, |ui| { + let current_index = crate::app::settings_menu::current_option_index( + tab, + row, + regions.list, + i18n.locale(), + ); + for option in 0..option_count { + let label = crate::app::settings_menu::option_label(tab, row, option, i18n, regions.list); + let is_current = option == current_index; + let is_focused = option == option_focus; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), egui::Sense::click()); + if ui.is_rect_visible(rect) { + if is_focused { + ui.painter().rect_filled(rect, 4.0, ACCENT.gamma_multiply(0.18)); } - crate::gfn::stream_prefs::RearTouchMode::Halves => { - let halves = [ - ("L2", rect.min.x, 0.0), - ("R2", rect.center().x, 0.5), - ]; - for (label, min_x, phase) in halves { - let pulse = 0.5 + 0.5 * ((anim_time * 3.0 + phase * std::f64::consts::TAU).sin() as f32); - let cell = egui::Rect::from_min_size( - egui::pos2(min_x, rect.min.y), - egui::vec2(rect.width() / 2.0, rect.height()), - ).shrink(3.0); - painter.rect_filled(cell, 4.0, ACCENT.gamma_multiply(0.12 + pulse * 0.25)); - painter.rect_stroke(cell, 4u8, egui::Stroke::new(1.5_f32, ACCENT.gamma_multiply(0.4 + pulse * 0.6)), egui::StrokeKind::Inside); - painter.text(cell.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(14.0), egui::Color32::WHITE); + ui.painter().text( + egui::pos2(rect.min.x + 8.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(11.5), + if is_current { ACCENT } else { egui::Color32::WHITE }, + ); + if is_current { + let check_rect = egui::Rect::from_center_size( + egui::pos2(rect.max.x - 14.0, rect.center().y), + egui::vec2(12.0, 12.0), + ); + paint_stream_icon(ui.painter(), check_rect, StreamIcon::Check, ACCENT); + } + if info.kind == RowKind::Region && option > 0 { + if let Some(best) = regions + .list + .iter() + .filter_map(|r| r.ping_ms) + .min() + { + if regions.list.get(option - 1).and_then(|r| r.ping_ms) == Some(best) { + ui.painter().text( + egui::pos2(rect.max.x - 46.0, rect.center().y), + egui::Align2::RIGHT_CENTER, + i18n.text("settings-region-best"), + egui::FontId::proportional(8.5), + ACCENT, + ); + } } } } - - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-stick-zones-heading", - crate::gfn::stream_prefs::StickZones::ALL.iter().copied(), - crate::gfn::stream_prefs::stick_zones(), - |candidate| i18n.text(candidate.label_key()), - ) { - command = Some(AppCommand::SetStickZones(chosen)); - } - - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-audio-boost-heading", - crate::gfn::stream_prefs::AudioBoost::ALL.iter().copied(), - crate::gfn::stream_prefs::audio_boost(), - |candidate| format!("{}x", candidate.percent() / 100), - ) { - command = Some(AppCommand::SetAudioBoost(chosen)); + if response.clicked() { + command = Some(AppCommand::ChooseSettingsOption(row, option)); } - }); - close_requested - }); + } + }); + } - // Returned from the closure rather than assigned through a capture, so there is exactly one - // place that decides the modal is done: the button, the backdrop, or Escape. - if modal.inner || modal.should_close() { - open = false; + if show_touch_diagrams { + if info.label_key == "settings-rear-touch-mode-heading" { + ui.add_space(8.0); + rear_touch_diagram(ui, 120.0, None); + ui.add_space(6.0); + } + if info.label_key == "settings-stick-zones-heading" { + ui.add_space(8.0); + front_stick_zones_diagram(ui, 120.0); + ui.add_space(6.0); + } } - ui.ctx().data_mut(|data| data.insert_temp(open_id, open)); + command } @@ -1309,7 +2389,7 @@ fn settings_row( let mut chosen = None; ui.add_space(6.0); ui.label( - egui::RichText::new(i18n.text(heading_key)) + egui::RichText::new(i18n.text(heading_key).as_ref()) .size(10.0) .color(TEXT_DIM), ); @@ -1334,8 +2414,8 @@ fn sort_picker( games: &[GameSummary], ) -> Option { let mut command = None; - let label = text1(i18n, "catalog-sort-button", "sort", i18n.text(current.label_key())); - let response = ui.add_sized([150.0, 30.0], egui::Button::new(label).fill(BG_RAISED)); + let label = text1(i18n, "catalog-sort-button", "sort", i18n.text(current.label_key()).as_ref()); + let response = ui.add_sized([150.0, 30.0], egui::Button::new(label.as_ref()).fill(BG_RAISED)); let popup_id = ui.make_persistent_id("catalog_sort_popup"); if response.clicked() { ui.memory_mut(|mem| mem.toggle_popup(popup_id)); @@ -1352,7 +2432,7 @@ fn sort_picker( let count = games.iter().filter(|g| g.last_played.is_some()).count(); format!("{} ({count})", i18n.text(candidate.label_key())) } else { - i18n.text(candidate.label_key()) + i18n.text(candidate.label_key()).to_string() }; if ui.selectable_label(candidate == current, label).clicked() { command = Some(AppCommand::SetSort(candidate)); @@ -1366,8 +2446,8 @@ fn sort_picker( // same as sort_picker but for my games / all games fn filter_picker(ui: &mut egui::Ui, i18n: &I18n, current: CatalogFilter) -> Option { let mut command = None; - let label = text1(i18n, "catalog-filter-button", "filter", i18n.text(current.label_key())); - let response = ui.add_sized([150.0, 30.0], egui::Button::new(label).fill(BG_RAISED)); + let label = text1(i18n, "catalog-filter-button", "filter", i18n.text(current.label_key()).as_ref()); + let response = ui.add_sized([150.0, 30.0], egui::Button::new(label.as_ref()).fill(BG_RAISED)); let popup_id = ui.make_persistent_id("catalog_filter_popup"); if response.clicked() { ui.memory_mut(|mem| mem.toggle_popup(popup_id)); @@ -1381,7 +2461,7 @@ fn filter_picker(ui: &mut egui::Ui, i18n: &I18n, current: CatalogFilter) -> Opti ui.set_min_width(170.0); for candidate in CatalogFilter::ALL { let label = i18n.text(candidate.label_key()); - if ui.selectable_label(candidate == current, label).clicked() { + if ui.selectable_label(candidate == current, label.as_ref()).clicked() { command = Some(AppCommand::SetFilter(candidate)); } } @@ -1402,7 +2482,7 @@ fn title_list(ui: &mut egui::Ui, i18n: &I18n, view: &CatalogView<'_>) -> Vec) -> Vec) -> Vec) -> Vec { - let tex = image.texture( - ui.ctx(), - &CoverStore::texture_key(&game.app_id, CoverSize::Icon), - ); + let tex = image.texture(ui.ctx(), || { + CoverStore::texture_key(&game.app_id, CoverSize::Icon) + }); let size = tex.size_vec2(); let src_aspect = size.x / size.y.max(1.0); let uv = if src_aspect > 1.0 { @@ -1655,7 +2739,7 @@ fn detail_panel( ui.add_space(40.0); ui.vertical_centered(|ui| { ui.label( - egui::RichText::new(i18n.text("detail-empty")) + egui::RichText::new(i18n.text("detail-empty").as_ref()) .size(13.0) .color(TEXT_DIM), ); @@ -1663,7 +2747,9 @@ fn detail_panel( return commands; }; - if let Some(url) = game.cover_url.clone() { + if !view.covers.is_requested(&game.app_id, CoverSize::Cover) + && let Some(url) = game.cover_url.clone() + { view.covers .request(view.http_client, ctx, game.app_id.clone(), url); } @@ -1741,10 +2827,10 @@ fn detail_panel( Some(date) => text1(i18n, "detail-last-played", "date", short_date(date)), None => i18n.text("detail-never-played"), }; - ui.label(egui::RichText::new(played).size(11.0).color(TEXT_DIM)); + ui.label(egui::RichText::new(played.as_ref()).size(11.0).color(TEXT_DIM)); ui.add_space(2.0); ui.label( - egui::RichText::new(text1(i18n, "detail-app-id", "id", &game.app_id)) + egui::RichText::new(text1(i18n, "detail-app-id", "id", &game.app_id).as_ref()) .size(10.0) .monospace() .color(BORDER.gamma_multiply(3.0)), @@ -1758,7 +2844,7 @@ fn detail_panel( ui.add_space(8.0); ui.horizontal(|ui| { ui.label( - egui::RichText::new(i18n.text("detail-press")) + egui::RichText::new(i18n.text("detail-press").as_ref()) .size(11.0) .color(TEXT_DIM), ); @@ -1773,7 +2859,7 @@ fn detail_panel( ); } ui.label( - egui::RichText::new(i18n.text("detail-to-start")) + egui::RichText::new(i18n.text("detail-to-start").as_ref()) .size(11.0) .color(TEXT_DIM), ); @@ -1864,7 +2950,7 @@ fn draw_panel_backdrop( return; } - let tex = image.texture(ctx, &format!("gfn_cover_{}", game.app_id)); + let tex = image.texture(ctx, || CoverStore::texture_key(&game.app_id, CoverSize::Cover)); let tex_size = tex.size_vec2(); let src_aspect = tex_size.x / tex_size.y.max(1.0); let dst_aspect = rect.width() / rect.height(); @@ -1925,7 +3011,7 @@ fn draw_cover( painter.rect_filled(rect, 4.0, BG_DEEP); let paint_at = |size: CoverSize, image: &Arc| { - let tex = image.texture(ctx, &CoverStore::texture_key(&game.app_id, size)); + let tex = image.texture(ctx, || CoverStore::texture_key(&game.app_id, size)); let tex_size = tex.size_vec2(); let src_aspect = tex_size.x / tex_size.y.max(1.0); let slot_aspect = rect.width() / rect.height(); @@ -2015,9 +3101,9 @@ struct LaunchView<'a> { stage: LaunchStage, game: Option<&'a GameSummary>, /// Large line under the stepper. - headline: String, + headline: std::rc::Rc, /// Small line under the headline, if there's anything more specific to say. - detail: Option, + detail: Option>, /// False on the stages that are waiting on the player rather than on NVIDIA. spinning: bool, /// The launch never sat in NVIDIA's queue, so step 1 is drawn as skipped rather than as @@ -2047,7 +3133,7 @@ fn session_launch_overlay( .frame( egui::Frame::default() .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .corner_radius(10.0) .inner_margin(egui::Margin::symmetric(16, 14)), ) @@ -2065,13 +3151,13 @@ fn session_launch_overlay( ui.add_space(8.0); } ui.label( - egui::RichText::new(&launch.headline) + egui::RichText::new(launch.headline.as_ref()) .size(15.0) .color(egui::Color32::WHITE), ); if let Some(detail) = &launch.detail { ui.add_space(3.0); - button_hint(ui, detail, 11.0, TEXT_DIM, true); + button_hint(ui, detail.as_ref(), 11.0, TEXT_DIM, true); } }); @@ -2083,7 +3169,7 @@ fn session_launch_overlay( .add_sized( egui::vec2(ui.available_width(), 30.0), egui::Button::new( - egui::RichText::new(i18n.text("session-cancel-button")) + egui::RichText::new(i18n.text("session-cancel-button").as_ref()) .size(14.0) .color(DANGER), ) @@ -2198,7 +3284,9 @@ fn launch_header( Some(game) => { // Same request + `draw_cover` path the detail panel uses, so the art, the loading // spinner and the initial-letter fallback all behave identically here. - if let Some(url) = game.cover_url.clone() { + if !catalog.covers.is_requested(&game.app_id, CoverSize::Cover) + && let Some(url) = game.cover_url.clone() + { catalog .covers .request(catalog.http_client, ui.ctx(), game.app_id.clone(), url); @@ -2218,7 +3306,7 @@ fn launch_header( ui.add_space(10.0); ui.vertical(|ui| { ui.label( - egui::RichText::new(i18n.text("session-now-loading")) + egui::RichText::new(i18n.text("session-now-loading").as_ref()) .size(10.0) .color(ACCENT), ); @@ -2267,7 +3355,7 @@ fn launch_stepper(ui: &mut egui::Ui, i18n: &I18n, stage: LaunchStage, queue_skip egui::pos2(x - gap + STEP_DOT_RADIUS + 2.0, dot_y), egui::pos2(x - STEP_DOT_RADIUS - 2.0, dot_y), ], - egui::Stroke::new(2.0, if reached { ACCENT } else { BORDER }), + egui::Stroke::new(2.0_f32, if reached { ACCENT } else { BORDER }), ); } @@ -2281,7 +3369,7 @@ fn launch_stepper(ui: &mut egui::Ui, i18n: &I18n, stage: LaunchStage, queue_skip }, ); if reached && step != stage.index() { - painter.circle_stroke(center, STEP_DOT_RADIUS, egui::Stroke::new(1.5, ACCENT)); + painter.circle_stroke(center, STEP_DOT_RADIUS, egui::Stroke::new(1.5_f32, ACCENT)); } painter.text( center, @@ -2328,6 +3416,19 @@ fn creating_session_launch<'a>( }; } + if queue_status.has_video_ad { + let percent = (queue_status.ad_progress_pct.clamp(0.0, 1.0) * 100.0).round() as u32; + return LaunchView { + stage: LaunchStage::Queue, + game, + headline: i18n.text("session-ad-playing"), + detail: Some(text1(i18n, "session-ad-progress", "percent", percent)), + spinning: true, + session_id: None, + queue_skipped: !was_queued, + }; + } + // A run of 5xx replies looks identical to a stalled launch from the outside, so it gets said // out loud rather than hidden behind the queue position. if queue_status.server_errors > 0 { @@ -2419,7 +3520,7 @@ fn confirm_exit_modal(ctx: &egui::Context, i18n: &I18n) -> Option { .frame( egui::Frame::default() .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .corner_radius(10.0) .inner_margin(egui::Margin::symmetric(16, 14)), ) @@ -2427,13 +3528,13 @@ fn confirm_exit_modal(ctx: &egui::Context, i18n: &I18n) -> Option { ui.set_width(LAUNCH_MODAL_WIDTH); ui.vertical_centered(|ui| { ui.add_space(8.0); - ui.heading(egui::RichText::new(i18n.text("exit-heading")).size(17.0)); + ui.heading(egui::RichText::new(i18n.text("exit-heading").as_ref()).size(17.0)); ui.add_space(10.0); - ui.label(i18n.text("exit-body")); + ui.label(i18n.text("exit-body").as_ref()); ui.add_space(18.0); ui.horizontal(|ui| { if ui - .add(egui::Button::new(i18n.text("exit-cancel")).fill(BG_RAISED)) + .add(egui::Button::new(i18n.text("exit-cancel").as_ref()).fill(BG_RAISED)) .clicked() { command = Some(AppCommand::CancelConfirmExit); @@ -2442,7 +3543,7 @@ fn confirm_exit_modal(ctx: &egui::Context, i18n: &I18n) -> Option { if ui .add( egui::Button::new( - egui::RichText::new(i18n.text("exit-confirm")).color(DANGER), + egui::RichText::new(i18n.text("exit-confirm").as_ref()).color(DANGER), ) .fill(BG_RAISED), ) @@ -2480,16 +3581,16 @@ fn streaming_screen( ui.add_space(16.0); match game { Some(game) => ui.heading( - egui::RichText::new(text1(i18n, "streaming-game", "game", &game.title)) + egui::RichText::new(text1(i18n, "streaming-game", "game", &game.title).as_ref()) .size(18.0), ), None => { - ui.heading(egui::RichText::new(i18n.text("streaming-generic")).size(18.0)) + ui.heading(egui::RichText::new(i18n.text("streaming-generic").as_ref()).size(18.0)) } }; ui.add_space(12.0); ui.label( - egui::RichText::new(i18n.text("streaming-signaling-done")) + egui::RichText::new(i18n.text("streaming-signaling-done").as_ref()) .color(ACCENT) .strong(), ); @@ -2497,7 +3598,7 @@ fn streaming_screen( ui.label( status_note .map(str::to_owned) - .unwrap_or_else(|| i18n.text("streaming-waiting-negotiation")), + .unwrap_or_else(|| i18n.text("streaming-waiting-negotiation").to_string()), ); }); } @@ -2555,6 +3656,17 @@ fn streaming_screen( command = Some(AppCommand::ToggleStreamStats); } + let timer_active = crate::gfn::stream_prefs::session_timer_enabled(); + let timer = stream_icon_button( + ui, + StreamIcon::Clock, + if timer_active { ACCENT } else { TEXT_DIM }, + ); + reserve_stream_touch(ui.ctx(), timer.rect); + if timer.clicked() { + command = Some(AppCommand::ToggleSessionTimer); + } + // 3. Controls Settings (L2/R2 and L3/R3 modal) let controls_active = crate::gfn::stream_prefs::stick_zones().is_active() || crate::gfn::stream_prefs::trigger_intensity().value() > 0; @@ -2622,82 +3734,299 @@ fn stream_controls_modal(ctx: &egui::Context, i18n: &I18n) -> Option let mut command = None; egui::Modal::new(egui::Id::new("stream_controls_modal")) - .frame(egui::Frame::window(&ctx.style()).fill(BG_PANEL)) + .backdrop_color(egui::Color32::from_black_alpha(160)) + .frame( + egui::Frame::default() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(10.0) + .inner_margin(egui::Margin::symmetric(12, 10)), + ) .show(ctx, |ui| { - ui.set_max_width(320.0); + ui.set_width(280.0); - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.heading( - egui::RichText::new(i18n.text("settings-title")) - .size(16.0) - .strong(), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(egui::RichText::new("X").strong()).clicked() { - command = Some(AppCommand::ToggleControlsModal); - } - }); + ui.horizontal(|ui| { + ui.heading( + egui::RichText::new(i18n.text("controls-hint-heading").as_ref()) + .size(14.0) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_sized( + [26.0, 22.0], + egui::Button::new(egui::RichText::new("X").size(12.0).strong()), + ) + .clicked() + { + command = Some(AppCommand::ToggleControlsModal); + } }); - ui.add_space(8.0); - ui.separator(); - ui.add_space(8.0); + }); + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-trigger-heading", - crate::gfn::stream_prefs::TriggerIntensity::ALL.iter().copied(), - crate::gfn::stream_prefs::trigger_intensity(), - |candidate| format!("{}%", u32::from(candidate.value()) * 100 / 255), - ) { - command = Some(AppCommand::SetTriggerIntensity(chosen)); - } + rear_touch_diagram(ui, 72.0, None); + ui.add_space(6.0); - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-rear-touch-mode-heading", - crate::gfn::stream_prefs::RearTouchMode::ALL.iter().copied(), - crate::gfn::stream_prefs::rear_touch_mode(), - |candidate| i18n.text(candidate.label_key()), - ) { - command = Some(AppCommand::SetRearTouchMode(chosen)); - } + if let Some(chosen) = settings_row( + ui, + i18n, + "settings-trigger-heading", + crate::gfn::stream_prefs::TriggerIntensity::ALL.iter().copied(), + crate::gfn::stream_prefs::trigger_intensity(), + |candidate| format!("{}%", u32::from(candidate.value()) * 100 / 255), + ) { + command = Some(AppCommand::SetTriggerIntensity(chosen)); + } - ui.add_space(6.0); + if let Some(chosen) = settings_row( + ui, + i18n, + "settings-rear-touch-mode-heading", + crate::gfn::stream_prefs::RearTouchMode::ALL.iter().copied(), + crate::gfn::stream_prefs::rear_touch_mode(), + |candidate| i18n.text(candidate.label_key()).to_string(), + ) { + command = Some(AppCommand::SetRearTouchMode(chosen)); + } - if let Some(chosen) = settings_row( - ui, - i18n, - "settings-stick-zones-heading", - crate::gfn::stream_prefs::StickZones::ALL.iter().copied(), - crate::gfn::stream_prefs::stick_zones(), - |candidate| i18n.text(candidate.label_key()), - ) { - command = Some(AppCommand::SetStickZones(chosen)); - } + ui.add_space(2.0); - ui.add_space(12.0); + if let Some(chosen) = settings_row( + ui, + i18n, + "settings-stick-zones-heading", + crate::gfn::stream_prefs::StickZones::ALL.iter().copied(), + crate::gfn::stream_prefs::stick_zones(), + |candidate| i18n.text(candidate.label_key()).to_string(), + ) { + command = Some(AppCommand::SetStickZones(chosen)); + } - if ui - .add_sized( - [ui.available_width(), 26.0], - egui::Button::new( - egui::RichText::new(i18n.text("account-close")).size(12.0), - ) - .fill(BG_RAISED), - ) - .clicked() - { - command = Some(AppCommand::ToggleControlsModal); - } + ui.add_space(4.0); + ui.separator(); + ui.add_space(2.0); + + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(i18n.text("settings-trigger-swap-heading").as_ref()) + .size(11.0) + .color(egui::Color32::WHITE), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut value = crate::gfn::stream_prefs::trigger_swap_enabled(); + if ui + .add(egui::Checkbox::without_text(&mut value)) + .changed() + { + command = Some(AppCommand::ToggleTriggerSwap); + } + }); }); + + ui.add_space(8.0); + + if ui + .add_sized( + [ui.available_width(), 24.0], + egui::Button::new( + egui::RichText::new(i18n.text("account-close").as_ref()).size(11.0), + ) + .fill(BG_RAISED), + ) + .clicked() + { + command = Some(AppCommand::ToggleControlsModal); + } }); command } +enum KeyCap { + Char(char, char), + Key(&'static str, crate::gfn::input_protocol::KeyStroke), + Backspace, + Enter, + Space, + Shift, + Ctrl, + Alt, +} + +fn keyboard_layout() -> [Vec<(KeyCap, f32)>; 6] { + use crate::gfn::input_protocol::*; + [ + vec![ + (KeyCap::Key("Esc", KEY_ESCAPE), 1.0), + (KeyCap::Key("F1", KEY_F1), 1.0), (KeyCap::Key("F2", KEY_F2), 1.0), + (KeyCap::Key("F3", KEY_F3), 1.0), (KeyCap::Key("F4", KEY_F4), 1.0), + (KeyCap::Key("F5", KEY_F5), 1.0), (KeyCap::Key("F6", KEY_F6), 1.0), + (KeyCap::Key("F7", KEY_F7), 1.0), (KeyCap::Key("F8", KEY_F8), 1.0), + (KeyCap::Key("F9", KEY_F9), 1.0), (KeyCap::Key("F10", KEY_F10), 1.0), + (KeyCap::Key("F11", KEY_F11), 1.0), (KeyCap::Key("F12", KEY_F12), 1.0), + (KeyCap::Key("Home", KEY_HOME), 1.0), + (KeyCap::Key("End", KEY_END), 1.0), + ], + vec![ + (KeyCap::Char('`', '~'), 1.0), + (KeyCap::Char('1', '!'), 1.0), (KeyCap::Char('2', '@'), 1.0), + (KeyCap::Char('3', '#'), 1.0), (KeyCap::Char('4', '$'), 1.0), + (KeyCap::Char('5', '%'), 1.0), (KeyCap::Char('6', '^'), 1.0), + (KeyCap::Char('7', '&'), 1.0), (KeyCap::Char('8', '*'), 1.0), + (KeyCap::Char('9', '('), 1.0), (KeyCap::Char('0', ')'), 1.0), + (KeyCap::Char('-', '_'), 1.0), (KeyCap::Char('=', '+'), 1.0), + (KeyCap::Backspace, 2.0), + ], + vec![ + (KeyCap::Key("Tab", KEY_TAB), 1.5), + (KeyCap::Char('q', 'Q'), 1.0), (KeyCap::Char('w', 'W'), 1.0), + (KeyCap::Char('e', 'E'), 1.0), (KeyCap::Char('r', 'R'), 1.0), + (KeyCap::Char('t', 'T'), 1.0), (KeyCap::Char('y', 'Y'), 1.0), + (KeyCap::Char('u', 'U'), 1.0), (KeyCap::Char('i', 'I'), 1.0), + (KeyCap::Char('o', 'O'), 1.0), (KeyCap::Char('p', 'P'), 1.0), + (KeyCap::Char('[', '{'), 1.0), (KeyCap::Char(']', '}'), 1.0), + (KeyCap::Char('\\', '|'), 1.5), + ], + vec![ + (KeyCap::Key("Caps", KEY_CAPS_LOCK), 1.75), + (KeyCap::Char('a', 'A'), 1.0), (KeyCap::Char('s', 'S'), 1.0), + (KeyCap::Char('d', 'D'), 1.0), (KeyCap::Char('f', 'F'), 1.0), + (KeyCap::Char('g', 'G'), 1.0), (KeyCap::Char('h', 'H'), 1.0), + (KeyCap::Char('j', 'J'), 1.0), (KeyCap::Char('k', 'K'), 1.0), + (KeyCap::Char('l', 'L'), 1.0), (KeyCap::Char(';', ':'), 1.0), + (KeyCap::Char('\'', '"'), 1.0), + (KeyCap::Enter, 2.25), + ], + vec![ + (KeyCap::Shift, 2.25), + (KeyCap::Char('z', 'Z'), 1.0), (KeyCap::Char('x', 'X'), 1.0), + (KeyCap::Char('c', 'C'), 1.0), (KeyCap::Char('v', 'V'), 1.0), + (KeyCap::Char('b', 'B'), 1.0), (KeyCap::Char('n', 'N'), 1.0), + (KeyCap::Char('m', 'M'), 1.0), (KeyCap::Char(',', '<'), 1.0), + (KeyCap::Char('.', '>'), 1.0), (KeyCap::Char('/', '?'), 1.0), + (KeyCap::Shift, 2.75), + ], + vec![ + (KeyCap::Ctrl, 1.25), + (KeyCap::Alt, 1.25), + (KeyCap::Key("Win", KEY_LEFT_WIN), 1.25), + (KeyCap::Space, 4.25), + (KeyCap::Key("AltGr", KEY_RIGHT_ALT), 1.0), + (KeyCap::Key("Menu", KEY_MENU), 1.0), + (KeyCap::Key("Ctrl", KEY_RIGHT_CTRL), 1.0), + (KeyCap::Key("<", KEY_LEFT), 1.0), (KeyCap::Key("^", KEY_UP), 1.0), + (KeyCap::Key("v", KEY_DOWN), 1.0), (KeyCap::Key(">", KEY_RIGHT), 1.0), + ], + ] +} + +fn on_screen_keyboard(ctx: &egui::Context, shift: bool, ctrl: bool, alt: bool) -> Vec { + use crate::gfn::input_protocol::key_for_char; + + let mut commands = Vec::new(); + let panel_rect = keyboard_panel_rect(ctx.screen_rect()); + reserve_stream_touch(ctx, panel_rect); + + egui::Area::new(egui::Id::new("on_screen_keyboard")) + .fixed_pos(panel_rect.min) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::window(&ui.style()) + .fill(BG_PANEL.gamma_multiply(0.96)) + .inner_margin(egui::Margin::same(KEYBOARD_PADDING as i8)) + .outer_margin(egui::Margin::ZERO) + .show(ui, |ui| { + ui.set_width(panel_rect.width() - KEYBOARD_PADDING * 2.0); + ui.spacing_mut().item_spacing = egui::vec2(KEYBOARD_CAP_SPACING, KEYBOARD_CAP_SPACING); + + for row in keyboard_layout() { + ui.horizontal(|ui| { + for (cap, units) in row { + let (label, active) = match &cap { + KeyCap::Char(lower, upper) => { + (if shift { upper.to_string() } else { lower.to_string() }, false) + } + KeyCap::Key(label, _) => (label.to_string(), false), + KeyCap::Backspace => ("Bksp".to_string(), false), + KeyCap::Enter => ("Enter".to_string(), false), + KeyCap::Space => (String::new(), false), + KeyCap::Shift => ("Shift".to_string(), shift), + KeyCap::Ctrl => ("Ctrl".to_string(), ctrl), + KeyCap::Alt => ("Alt".to_string(), alt), + }; + let width = + KEYBOARD_CAP_SIZE.x * units + KEYBOARD_CAP_SPACING * (units - 1.0); + let mut button = egui::Button::new( + egui::RichText::new(label).size(11.0), + ); + button = if active { + button.fill(ACCENT.gamma_multiply(0.35)) + } else { + button.fill(BG_RAISED) + }; + let response = + ui.add_sized([width, KEYBOARD_CAP_SIZE.y], button); + if !response.clicked() { + continue; + } + match cap { + KeyCap::Char(lower, upper) => { + let ch = if shift { upper } else { lower }; + if let Some(key) = key_for_char(ch) { + commands.push(if ctrl || alt { + AppCommand::SendChord { ctrl, alt, key } + } else { + AppCommand::SendKey(key) + }); + } + } + KeyCap::Key(_, key) => { + commands.push(if ctrl || alt { + AppCommand::SendChord { ctrl, alt, key } + } else { + AppCommand::SendKey(key) + }); + } + KeyCap::Backspace => { + let key = crate::gfn::input_protocol::KEY_BACKSPACE; + commands.push(if ctrl || alt { + AppCommand::SendChord { ctrl, alt, key } + } else { + AppCommand::SendKey(key) + }); + } + KeyCap::Enter => { + let key = crate::gfn::input_protocol::KEY_ENTER; + commands.push(if ctrl || alt { + AppCommand::SendChord { ctrl, alt, key } + } else { + AppCommand::SendKey(key) + }); + } + KeyCap::Space => { + let key = crate::gfn::input_protocol::KEY_SPACE; + commands.push(if ctrl || alt { + AppCommand::SendChord { ctrl, alt, key } + } else { + AppCommand::SendKey(key) + }); + } + KeyCap::Shift => commands.push(AppCommand::ToggleKeyShift), + KeyCap::Ctrl => commands.push(AppCommand::ToggleKeyCtrl), + KeyCap::Alt => commands.push(AppCommand::ToggleKeyAlt), + } + } + }); + } + }); + }); + + commands +} + /// How long a fallback error body may run before it is cut. Past this it wraps into a wall of text /// that nobody reads and that pushes the hint off the screen. const MAX_ERROR_BODY: usize = 220; @@ -2735,12 +4064,12 @@ fn present_error( ) -> (String, String) { if let Some(code) = code { if let Some((title, body)) = code.message_keys() { - return (i18n.text(title), i18n.text(body)); + return (i18n.text(title).to_string(), i18n.text(body).to_string()); } // A code NVIDIA has not given wording to. Naming it still beats the raw JSON this used to // print, and it is the string a player can search for or quote in a bug report. return ( - i18n.text("error-gfn-unknown-title"), + i18n.text("error-gfn-unknown-title").to_string(), text1( i18n, "error-gfn-unknown-body", @@ -2749,12 +4078,13 @@ fn present_error( Some(name) => format!("{name} ({})", code.0), None => code.0.to_string(), }, - ), + ) + .to_string(), ); } if let Some((title, body)) = legacy_error_keys(message) { - return (i18n.text(title), i18n.text(body)); + return (i18n.text(title).to_string(), i18n.text(body).to_string()); } let mut body = message.trim().to_owned(); @@ -2762,7 +4092,7 @@ fn present_error( // By chars, not bytes: truncating mid-codepoint would panic on an accented message. body = body.chars().take(MAX_ERROR_BODY - 3).collect::() + "..."; } - (i18n.text("error-title"), body) + (i18n.text("error-title").to_string(), body) } fn error_screen( @@ -2780,7 +4110,7 @@ fn error_screen( ui.label(egui::RichText::new(body).size(13.0)); ui.add_space(24.0); ui.label( - egui::RichText::new(i18n.text("error-hint")) + egui::RichText::new(i18n.text("error-hint").as_ref()) .size(11.0) .color(TEXT_DIM), ); @@ -2848,9 +4178,93 @@ fn draw_qr(ui: &mut egui::Ui, verification_uri: &str, target_size: f32) { } } +fn session_timer_overlay( + ctx: &egui::Context, + start_time: std::time::Instant, + tier_str: Option<&str>, + battery: Option, +) { + let tier_val = tier_str.unwrap_or("Free"); + let max_duration: u32 = match tier_val { + "Ultimate" | "RTX3080" => 8 * 60 * 60, // 8 hours + "Premium" | "Priority" => 6 * 60 * 60, // 6 hours + _ => 60 * 60, // 1 hour for Free + }; + + let elapsed = start_time.elapsed().as_secs() as u32; + let _remaining = max_duration.saturating_sub(elapsed); + let progress = (elapsed as f32 / max_duration as f32).clamp(0.0, 1.0); + + let format_time = |secs: u32| -> String { + let hours = secs / 3600; + let mins = (secs % 3600) / 60; + let s = secs % 60; + if hours > 0 { + format!("{hours}:{mins:02}:{s:02}") + } else { + format!("{mins:02}:{s:02}") + } + }; + + let elapsed_str = format_time(elapsed); + let total_str = format_time(max_duration); + let text = format!("{elapsed_str} / {total_str}"); + + egui::Window::new("session_timer_overlay") + .anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -10.0)) + .title_bar(false) + .resizable(false) + .collapsible(false) + .frame( + egui::Frame::window(&ctx.style()) + .fill(egui::Color32::from_black_alpha(200)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .corner_radius(8.0), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + paint_stream_icon(ui.painter(), icon_rect, StreamIcon::Clock, egui::Color32::WHITE); + + let (arrow_rect, _) = ui.allocate_exact_size(egui::vec2(10.0, 16.0), egui::Sense::hover()); + let arrow_center = arrow_rect.center(); + let arrow_points = [ + egui::pos2(arrow_center.x - 3.5, arrow_center.y - 2.0), + egui::pos2(arrow_center.x + 3.5, arrow_center.y - 2.0), + egui::pos2(arrow_center.x, arrow_center.y + 2.5), + ]; + ui.painter().add(egui::Shape::convex_polygon( + arrow_points.to_vec(), + egui::Color32::WHITE, + egui::Stroke::NONE, + )); + + let bar = egui::ProgressBar::new(progress) + .text(egui::RichText::new(text).color(egui::Color32::WHITE)) + .desired_width(120.0); + ui.add(bar); + + if let Some(battery) = battery { + ui.add_space(6.0); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(22.0, 16.0), egui::Sense::hover()); + paint_battery(ui.painter(), rect, battery); + ui.label( + egui::RichText::new(format!("{}%", battery.percent)) + .size(10.5) + .color(battery_color(battery)), + ); + } + }); + }); +} + #[cfg(test)] mod error_presentation_tests { - use super::legacy_error_keys; + use super::{ + keyboard_layout, keyboard_panel_rect, legacy_error_keys, KEYBOARD_CAP_SIZE, + KEYBOARD_CAP_SPACING, KEYBOARD_COLUMNS, KEYBOARD_PADDING, + }; use crate::gfn::error_codes::GfnErrorCode; fn classify(message: &str) -> &'static str { @@ -2908,4 +4322,29 @@ mod error_presentation_tests { let truncated: String = long.chars().take(MAX - 3).collect::() + "..."; assert_eq!(truncated.chars().count(), MAX); } + + #[test] + fn keyboard_rows_all_span_full_width() { + for (index, row) in keyboard_layout().iter().enumerate() { + let units: f32 = row.iter().map(|(_, units)| units).sum(); + assert!( + (units - KEYBOARD_COLUMNS).abs() < f32::EPSILON, + "row {index} sums to {units} cap-units, but the panel is sized for \ + {KEYBOARD_COLUMNS}; caps outside it cannot be touched", + ); + } + } + + #[test] + fn a_full_width_row_exactly_fills_the_panel() { + let inner = keyboard_panel_rect(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0 / 1.3, 544.0 / 1.3), + )) + .width() + - KEYBOARD_PADDING * 2.0; + let row = KEYBOARD_COLUMNS * KEYBOARD_CAP_SIZE.x + + (KEYBOARD_COLUMNS - 1.0) * KEYBOARD_CAP_SPACING; + assert!((inner - row).abs() < f32::EPSILON, "{inner} != {row}"); + } } diff --git a/src/gfn/auth.rs b/src/gfn/auth.rs index 0017b25..9fbd139 100644 --- a/src/gfn/auth.rs +++ b/src/gfn/auth.rs @@ -56,6 +56,7 @@ pub struct DeviceCodeChallenge { device_code: String, pub interval: Duration, deadline: Instant, + pub provider: Option, } impl DeviceCodeChallenge { @@ -76,6 +77,25 @@ struct DeviceAuthorizationResponse { } pub async fn start_device_login(client: &Client) -> Result { + let (provider, _) = match crate::gfn::providers::discover_providers(client).await { + Ok((provider, list)) => (provider, list), + Err(_) => (crate::gfn::providers::GfnProvider::default(), vec![]), + }; + start_device_login_with_provider(client, provider).await +} + +pub async fn start_device_login_with_idp(client: &Client, idp_id: &str) -> Result { + let provider = crate::gfn::providers::GfnProvider { + idp_id: idp_id.to_owned(), + ..Default::default() + }; + start_device_login_with_provider(client, provider).await +} + +pub async fn start_device_login_with_provider( + client: &Client, + provider: crate::gfn::providers::GfnProvider, +) -> Result { let response = client .post(DEVICE_AUTHORIZE_ENDPOINT) .header("Accept", "application/json, text/plain, */*") @@ -96,7 +116,7 @@ pub async fn start_device_login(client: &Client) -> Result ("scope", SCOPE), ("device_id", &device_id()), ("display_name", DISPLAY_NAME), - ("idp_id", IDP_ID), + ("idp_id", &provider.idp_id), ]) .send() .await @@ -122,6 +142,7 @@ pub async fn start_device_login(client: &Client) -> Result ), deadline: Instant::now() + Duration::from_secs(payload.expires_in.unwrap_or(DEFAULT_CHALLENGE_TTL_SECS)), + provider: Some(provider), }) } @@ -195,6 +216,8 @@ pub async fn poll_device_login( expires_at_unix: expires_at_unix(payload.expires_in), client_token: payload.client_token, client_token_expires_at_unix: 0, + membership_tier: None, + provider: challenge.provider.clone(), }; // Grab the long-lived credential right away, while the access token is certainly valid. if tokens.client_token.is_none() { @@ -240,6 +263,10 @@ pub struct AuthTokens { pub client_token: Option, #[serde(default)] pub client_token_expires_at_unix: u64, + #[serde(default)] + pub membership_tier: Option, + #[serde(default)] + pub provider: Option, } impl AuthTokens { @@ -344,6 +371,8 @@ fn merge_refreshed(previous: &AuthTokens, response: TokenResponse) -> AuthTokens previous.client_token_expires_at_unix }, client_token: response.client_token.or_else(|| previous.client_token.clone()), + membership_tier: previous.membership_tier.clone(), + provider: previous.provider.clone(), } } @@ -599,6 +628,45 @@ pub fn device_id() -> String { id } +pub async fn fetch_membership_tier( + client: &reqwest::Client, + token: &str, + vpc_id: &str, + user_id: &str, +) -> Result { + let url = format!( + "https://mes.geforcenow.com/v4/subscriptions?serviceName=gfn_pc&languageCode=en_US&vpcId={vpc_id}&userId={user_id}" + ); + + let request = client + .get(&url) + .header(reqwest::header::AUTHORIZATION, format!("GFNJWT {token}")) + .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ACCEPT, "application/json"); + + let response = request + .send() + .await + .context("subscription network request failed")?; + + if !response.status().is_success() { + anyhow::bail!("subscription API failed with {}", response.status()); + } + + let payload: serde_json::Value = response + .json() + .await + .context("subscription response was not valid JSON")?; + + let tier = payload + .get("membershipTier") + .and_then(|t| t.as_str()) + .context("membershipTier field missing in subscription response")?; + + Ok(tier.to_owned()) +} + const DEVICE_ID_PATH: &str = "ux0:data/opennow-vita/device-id.txt"; fn load_device_id() -> Option { @@ -776,6 +844,8 @@ mod tests { expires_at_unix: 1_000, client_token: Some("old-client".to_owned()), client_token_expires_at_unix: 2_000, + membership_tier: None, + provider: None, } } diff --git a/src/gfn/catalog.rs b/src/gfn/catalog.rs index 254defc..690f776 100644 --- a/src/gfn/catalog.rs +++ b/src/gfn/catalog.rs @@ -50,8 +50,17 @@ struct ServerInfoRequestStatus { /// The "VPC id" CloudMatch expects on catalog/session calls - not documented anywhere beyond /// `requestStatus.serverId` showing up in `serverInfo` responses (see protocol notes §2). pub async fn fetch_vpc_id(client: &Client, token: &str) -> Result { + fetch_vpc_id_with_base_url(client, token, CLOUDMATCH_BASE_URL).await +} + +pub async fn fetch_vpc_id_with_base_url(client: &Client, token: &str, base_url: &str) -> Result { + let base_url = if base_url.ends_with('/') { + base_url.to_owned() + } else { + format!("{base_url}/") + }; let response = headers::apply_lcars_headers( - client.get(format!("{CLOUDMATCH_BASE_URL}v2/serverInfo")), + client.get(format!("{base_url}v2/serverInfo")), token, "WEBRTC", ) @@ -401,7 +410,12 @@ pub async fn resolve_vpc_id(client: &Client, token: &str, cache: &VpcIdCache) -> if let Some(cached) = cache.get() { return Ok(cached.clone()); } - match fetch_vpc_id(client, token).await { + let base_url = crate::gfn::auth::load_tokens() + .and_then(|t| t.provider) + .map(|p| p.normalized_streaming_url()) + .unwrap_or_else(|| CLOUDMATCH_BASE_URL.to_owned()); + + match fetch_vpc_id_with_base_url(client, token, &base_url).await { Ok(vpc_id) => { let _ = cache.set(vpc_id.clone()); Ok(vpc_id) diff --git a/src/gfn/cloudmatch.rs b/src/gfn/cloudmatch.rs index 2c3cfa8..172783f 100644 --- a/src/gfn/cloudmatch.rs +++ b/src/gfn/cloudmatch.rs @@ -6,12 +6,13 @@ use super::active_session; use super::error_codes::{GfnError, GfnErrorCode}; use super::headers::{self, error_for_status_with_body}; +use crate::{log_info, log_warn}; use anyhow::{Context, Result, bail}; use reqwest::Client; use serde::Deserialize; use serde_json::json; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::time::sleep; const DEFAULT_CLOUDMATCH_BASE_URL: &str = "https://prod.cloudmatchbeta.nvidiagrid.net/"; @@ -90,12 +91,232 @@ pub struct NegotiatedStreamProfile { pub codec: Option, } +#[derive(Debug, Clone)] +pub struct SessionAdInfo { + pub ad_id: String, + pub title: Option, + pub description: Option, + pub length_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdAction { + Start, + Pause, + Resume, + Finish, + Cancel, +} + +impl AdAction { + fn code(self) -> u32 { + match self { + AdAction::Start => 1, + AdAction::Pause => 2, + AdAction::Resume => 3, + AdAction::Finish => 4, + AdAction::Cancel => 5, + } + } +} + +pub async fn report_session_ad( + client: &Client, + request: &PollSessionRequest<'_>, + ad_id: &str, + action: AdAction, + watched_ms: Option, +) -> Result<()> { + const SESSION_MODIFY_ACTION_AD_UPDATE: u32 = 6; + let base_url = request.session.streaming_base_url.trim_end_matches('/'); + let url = format!("{base_url}/v2/session/{}", request.session_id); + let identity = &request.session.identity; + let client_timestamp = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut ad_update = json!({ + "adId": ad_id, + "adAction": action.code(), + "clientTimestamp": client_timestamp, + }); + if let Some(watched_ms) = watched_ms { + ad_update["watchedTimeInMs"] = json!(watched_ms); + ad_update["pausedTimeInMs"] = json!(0); + } + let body = json!({ + "action": SESSION_MODIFY_ACTION_AD_UPDATE, + "adUpdates": [ad_update], + }); + + log_info!( + "reportSessionAd: adId={ad_id} action={action:?} watchedMs={watched_ms:?} sessionId={}", + request.session_id + ); + + let response = headers::apply_cloudmatch_headers( + client.put(&url), + request.token, + &identity.client_id, + &identity.device_id, + ) + .json(&body) + .send() + .await + .with_context(|| format!("failed to send ad {action:?} for {ad_id}"))?; + + let response = error_for_status_with_body(response) + .await + .with_context(|| format!("CloudMatch rejected ad {action:?} for {ad_id}"))?; + + let body_text = response + .text() + .await + .context("failed to read ad update response body")?; + let payload: CloudMatchResponse = serde_json::from_str(&body_text) + .context("failed to decode ad update response")?; + if payload.request_status.status_code != 1 { + log_warn!( + "reportSessionAd: adId={ad_id} action={action:?} rejected: {} ({})", + payload.request_status.status_code, + payload.request_status.describe() + ); + return Err(payload + .request_status + .to_error(format!( + "ad update rejected: {} ({})", + payload.request_status.status_code, + payload.request_status.describe() + )) + .into()); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum AdPlayback { + Playing { started_at: Instant }, + Finished, +} + +struct QueueAdRunner { + ads: Vec, + states: std::collections::HashMap, +} + +impl QueueAdRunner { + fn new() -> Self { + Self { + ads: Vec::new(), + states: std::collections::HashMap::new(), + } + } + + fn observe(&mut self, ads: Vec) { + if ads.is_empty() { + return; + } + let known: Vec<&str> = self.ads.iter().map(|a| a.ad_id.as_str()).collect(); + if ads.iter().any(|a| !known.contains(&a.ad_id.as_str())) { + log_info!( + "QueueAds: ad list now has {} ad(s): {}", + ads.len(), + ads.iter() + .map(|a| { + let id: String = a.ad_id.chars().take(24).collect(); + format!("{}({}s)", id, a.length_ms.unwrap_or(0) / 1000) + }) + .collect::>() + .join(", ") + ); + } + self.ads = ads; + } + + fn current_ad(&self) -> Option<&SessionAdInfo> { + self.ads + .iter() + .find(|ad| !matches!(self.states.get(&ad.ad_id), Some(AdPlayback::Finished))) + } + + fn progress_pct(&self) -> f32 { + let Some(ad) = self.current_ad() else { + return 1.0; + }; + match self.states.get(&ad.ad_id) { + Some(AdPlayback::Playing { started_at }) => { + let length_ms = ad.length_ms.unwrap_or(15_000).max(1) as f32; + let elapsed_ms = started_at.elapsed().as_millis() as f32; + (elapsed_ms / length_ms).min(1.0) + } + Some(AdPlayback::Finished) => 1.0, + None => 0.0, + } + } + + fn has_pending_ad(&self) -> bool { + self.current_ad().is_some() + } + + async fn tick(&mut self, client: &Client, request: &PollSessionRequest<'_>) { + let Some(ad) = self.current_ad().cloned() else { + return; + }; + + match self.states.get(&ad.ad_id).copied() { + None => { + match report_session_ad(client, request, &ad.ad_id, AdAction::Start, None).await { + Ok(()) => { + self.states.insert( + ad.ad_id.clone(), + AdPlayback::Playing { + started_at: Instant::now(), + }, + ); + } + Err(error) => { + log_warn!("QueueAds: failed to start ad {}: {error:#}", ad.ad_id); + } + } + } + Some(AdPlayback::Playing { started_at }) => { + let length_ms = ad.length_ms.unwrap_or(15_000); + let elapsed_ms = started_at.elapsed().as_millis() as u64; + if elapsed_ms < length_ms { + return; + } + match report_session_ad( + client, + request, + &ad.ad_id, + AdAction::Finish, + Some(elapsed_ms), + ) + .await + { + Ok(()) => { + log_info!("QueueAds: finished ad after {elapsed_ms}ms"); + self.states.insert(ad.ad_id.clone(), AdPlayback::Finished); + } + Err(error) => { + log_warn!("QueueAds: failed to finish ad {}: {error:#}", ad.ad_id); + } + } + } + Some(AdPlayback::Finished) => {} + } + } +} + /// CloudMatch session creation request. pub struct CreateSessionRequest<'a> { pub token: &'a str, pub app_id: &'a str, pub vpc_id: &'a str, pub settings: &'a StreamSettings, + pub zone_base_url: &'a str, + pub language_code: &'a str, } /// CloudMatch session poll request. @@ -117,7 +338,24 @@ pub async fn create_session( client_id: uuid::Uuid::new_v4().to_string(), device_id: super::auth::device_id(), }; - let base_url = DEFAULT_CLOUDMATCH_BASE_URL.trim_end_matches('/'); + let provider_url = super::auth::load_tokens() + .and_then(|t| t.provider) + .map(|p| p.normalized_streaming_url()); + let default_url = provider_url.as_deref().unwrap_or(DEFAULT_CLOUDMATCH_BASE_URL); + let global_base_url = default_url.trim_end_matches('/'); + let base_url = match normalize_zone_base_url(request.zone_base_url) { + Some(zone) => { + log_info!("Creating session on pinned zone {zone}"); + zone + } + None => global_base_url.to_owned(), + }; + let base_url = base_url.as_str(); + let cleanup_bases: Vec<&str> = if base_url == global_base_url { + vec![global_base_url] + } else { + vec![base_url, global_base_url] + }; let (width, height) = request.settings.dimensions(); let body = build_session_request_body( @@ -127,8 +365,15 @@ pub async fn create_session( height, request.settings.fps, ); + let language_code = match request.language_code.trim() { + "" => DEFAULT_LOCALE, + candidate => crate::gfn::stream_prefs::GameLanguage::ALL + .into_iter() + .find(|language| language.code() == candidate) + .map_or(DEFAULT_LOCALE, |language| language.code()), + }; let url = format!( - "{base_url}/v2/session?keyboardLayout={DEFAULT_KEYBOARD_LAYOUT}&languageCode={DEFAULT_LOCALE}" + "{base_url}/v2/session?keyboardLayout={DEFAULT_KEYBOARD_LAYOUT}&languageCode={language_code}" ); // Clear the decks first. Anything still open would reject this launch anyway, and finding that @@ -136,7 +381,7 @@ pub async fn create_session( // // our own note goes first, its the only thing that survives a crash and knows the zone stop_remembered_session(client, request.token, &identity).await; - stop_active_sessions_before_launch(client, request.token, &identity, base_url).await; + stop_active_sessions_before_launch(client, request.token, &identity, &cleanup_bases).await; let send_request = || async { let mut last_err = None; @@ -171,7 +416,7 @@ pub async fn create_session( request.token, Some(&payload), &identity, - base_url, + &cleanup_bases, ) .await { @@ -198,7 +443,7 @@ pub async fn create_session( request.token, limit_payload.as_ref(), &identity, - base_url, + &cleanup_bases, ) .await && let Some(limit_payload) = limit_payload @@ -225,7 +470,7 @@ pub async fn create_session( throttled += 1; let wait = retry_after(&headers) .unwrap_or_else(|| Duration::from_secs(2 << throttled.min(3))); - eprintln!( + log_warn!( "CloudMatch replied {status}, waiting {:?} before retry {throttled}", wait ); @@ -261,7 +506,7 @@ pub async fn create_session( if !was_limit_exceeded { break payload; } - if cleanups >= 2 { + if cleanups >= 15 { // still hitting the limit after cleanup means its a session this device cant // delete, report it as the per-device limit and let the error screen explain return Err(GfnError::new( @@ -306,6 +551,8 @@ pub struct QueueStatus { pub was_queued: bool, // rig is patching the game, can take a while so we tell the player instead of looking stuck pub app_patching: bool, + pub has_video_ad: bool, + pub ad_progress_pct: f32, } pub type QueueProgressTracker = Arc>; @@ -328,6 +575,7 @@ pub async fn poll_session( const MAX_CONSECUTIVE_SERVER_ERRORS: usize = 12; const SERVER_ERROR_BACKOFF_CAP: Duration = Duration::from_secs(15); let mut consecutive_server_errors = 0usize; + let mut ad_runner = QueueAdRunner::new(); for attempt in 0..MAX_ATTEMPTS { let response = match headers::apply_cloudmatch_headers( @@ -442,6 +690,37 @@ pub async fn poll_session( .as_ref() .context("CloudMatch poll response had no session")?; + if body_text.contains("sessionAds") || body_text.contains("AdsRequired") { + let raw_ads = serde_json::from_str::(&body_text) + .ok() + .map(|v| { + format!( + "sessionAdsRequired={} sessionAds={}", + v["session"]["sessionAdsRequired"], + v["session"]["sessionAds"] + ) + }) + .unwrap_or_default(); + log_info!( + "QueueAds: poll {attempt} status={} queuePos={:?} raw: {}", + session.status, + session.seat_setup_info.as_ref().map(|s| s.queue_position), + raw_ads.chars().take(1500).collect::() + ); + } + + if let Some(ads) = &session.session_ads { + let parsed: Vec = ads + .iter() + .cloned() + .filter_map(CloudMatchSessionAd::into_session_ad_info) + .collect(); + ad_runner.observe(parsed); + } + if ad_runner.has_pending_ad() { + ad_runner.tick(client, &request).await; + } + if let Some(tr) = &tracker { if let Ok(mut st) = tr.lock() { st.attempt = attempt + 1; @@ -451,6 +730,8 @@ pub async fn poll_session( st.eta_ms = seat.seat_setup_eta; st.was_queued |= seat.queue_position > 0; } + st.has_video_ad = ad_runner.has_pending_ad(); + st.ad_progress_pct = ad_runner.progress_pct(); } } @@ -545,6 +826,28 @@ pub async fn stop_session_by_id( identity: &SessionIdentity, base_url: &str, ) -> StopOutcome { + match delete_session_once(client, token, session_id, identity, base_url).await { + DeleteOutcome::Deleted | DeleteOutcome::NotFound => StopOutcome::Stopped, + DeleteOutcome::Forbidden => StopOutcome::Forbidden, + DeleteOutcome::Failed => StopOutcome::Failed, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeleteOutcome { + Deleted, + NotFound, + Forbidden, + Failed, +} + +async fn delete_session_once( + client: &Client, + token: &str, + session_id: &str, + identity: &SessionIdentity, + base_url: &str, +) -> DeleteOutcome { let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/v2/session/{session_id}"); @@ -557,25 +860,50 @@ pub async fn stop_session_by_id( .send() .await else { - eprintln!("CloudMatch stop for session {session_id} could not be sent"); - return StopOutcome::Failed; + log_warn!("CloudMatch stop for session {session_id} could not be sent"); + return DeleteOutcome::Failed; }; let status = response.status(); let body = response.text().await.unwrap_or_default(); - // 404 means the session is already gone, which is exactly the state we wanted. - if status.is_success() || status == reqwest::StatusCode::NOT_FOUND { - return StopOutcome::Stopped; + if status.is_success() { + return DeleteOutcome::Deleted; + } + if status == reqwest::StatusCode::NOT_FOUND { + return DeleteOutcome::NotFound; } if status == reqwest::StatusCode::FORBIDDEN { - eprintln!( + log_warn!( "CloudMatch refused to stop session {session_id} (HTTP 403); it belongs to another \ device identity and has to expire on its own" ); - return StopOutcome::Forbidden; + return DeleteOutcome::Forbidden; + } + log_warn!("CloudMatch stop for session {session_id} failed: HTTP {status}: {body}"); + DeleteOutcome::Failed +} + +async fn stop_session_across_zones( + client: &Client, + token: &str, + session_id: &str, + identity: &SessionIdentity, + bases: &[&str], +) -> StopOutcome { + let mut missing_everywhere = true; + for base in bases { + match delete_session_once(client, token, session_id, identity, base).await { + DeleteOutcome::Deleted => return StopOutcome::Stopped, + DeleteOutcome::NotFound => {} + DeleteOutcome::Forbidden => return StopOutcome::Forbidden, + DeleteOutcome::Failed => missing_everywhere = false, + } + } + if missing_everywhere { + StopOutcome::Stopped + } else { + StopOutcome::Failed } - eprintln!("CloudMatch stop for session {session_id} failed: HTTP {status}: {body}"); - StopOutcome::Failed } // deletes at the session's own zone url, not the generic entrypoint, sessions only @@ -608,40 +936,66 @@ pub async fn get_active_sessions( client: &Client, token: &str, identity: &SessionIdentity, + bases: &[&str], ) -> Result> { - let base_url = DEFAULT_CLOUDMATCH_BASE_URL.trim_end_matches('/'); - let url = format!("{base_url}/v2/session"); + let mut all_sessions = Vec::new(); + let mut any_zone_ok = false; + let query_bases: Vec<&str> = if bases.is_empty() { + vec![DEFAULT_CLOUDMATCH_BASE_URL.trim_end_matches('/')] + } else { + bases.to_vec() + }; - // Deliberately the launch's own identity rather than a fresh random one: CloudMatch scopes the - // per-device session limit by these headers, so listing under a different client id can hide - // the very sessions that are blocking us. - let response = headers::apply_cloudmatch_headers( - client.get(&url), - token, - &identity.client_id, - &identity.device_id, - ) - .send() - .await?; - let body_text = response.text().await.unwrap_or_default(); - let payload: GetSessionsResponse = match serde_json::from_str(&body_text) { - Ok(payload) => payload, - Err(error) => { - // Worth shouting about: the caller treats a failure here as "no zombies found", so a - // silent decode error looks exactly like a clean account while launches keep failing. - eprintln!("Could not read CloudMatch active sessions: {error}: {body_text}"); - return Err(anyhow::Error::new(error) - .context("failed to decode CloudMatch active sessions response")); + for base in query_bases { + let base_url = base.trim_end_matches('/'); + let url = format!("{base_url}/v2/session"); + + let response = match headers::apply_cloudmatch_headers( + client.get(&url), + token, + &identity.client_id, + &identity.device_id, + ) + .send() + .await + { + Ok(response) => response, + Err(error) => { + // One zone flaking must not hide live sessions on the others — that is the + // orphan-session case this cleanup exists to prevent. + log_warn!("Could not list CloudMatch sessions on {base_url}: {error}"); + continue; + } + }; + let body_text = response.text().await.unwrap_or_default(); + let payload: GetSessionsResponse = match serde_json::from_str(&body_text) { + Ok(payload) => payload, + Err(error) => { + log_warn!("Could not read CloudMatch active sessions from {base_url}: {error}: {body_text}"); + continue; + } + }; + any_zone_ok = true; + + for s in payload.sessions { + if s.status.occupies_device_slot() + && let Some(id) = s.session_id + { + let id_str = id.as_string(); + if !id_str.is_empty() { + all_sessions.push(id_str); + } + } } - }; + } + + if !any_zone_ok { + anyhow::bail!("could not list CloudMatch sessions on any zone"); + } - Ok(payload - .sessions - .into_iter() - .filter(|s| s.status.occupies_device_slot()) - .filter_map(|s| s.session_id.map(|id| id.as_string())) - .filter(|id| !id.is_empty()) - .collect()) + all_sessions.sort(); + all_sessions.dedup(); + Ok(all_sessions) } /// Deletes every session squatting on this device id: the ones the error payload names, or - @@ -653,7 +1007,7 @@ async fn stop_conflicting_sessions( token: &str, payload: Option<&CloudMatchResponse>, identity: &SessionIdentity, - base_url: &str, + bases: &[&str], ) -> bool { let mut old_ids = Vec::new(); if let Some(payload) = payload { @@ -668,11 +1022,11 @@ async fn stop_conflicting_sessions( } } } - if old_ids.is_empty() { - old_ids = get_active_sessions(client, token, identity) + old_ids.extend( + get_active_sessions(client, token, identity, bases) .await - .unwrap_or_default(); - } + .unwrap_or_default(), + ); old_ids.retain(|id| !id.is_empty()); // `dedup` only collapses *adjacent* duplicates, so the same id named by both the payload's // session and `otherUserSessions` would otherwise be deleted twice. @@ -681,8 +1035,8 @@ async fn stop_conflicting_sessions( let mut stopped_any = false; for old_id in &old_ids { - eprintln!("CloudMatch session limit hit; stopping zombie session {old_id}"); - if stop_session_by_id(client, token, old_id, identity, base_url).await + log_warn!("CloudMatch session limit hit; stopping zombie session {old_id}"); + if stop_session_across_zones(client, token, old_id, identity, bases).await == StopOutcome::Stopped { stopped_any = true; @@ -695,7 +1049,7 @@ async fn stop_conflicting_sessions( // A 200 on the DELETE only means NVIDIA accepted the request. Deprovisioning a rig that was // mid-setup takes appreciably longer than that, and retrying the launch before the slot is // actually released just spends an attempt on the same limit error. - wait_for_sessions_to_clear(client, token, identity).await + wait_for_sessions_to_clear(client, token, identity, bases).await } // cleans up a session we recorded but never confirmed closed (crash/force-quit path). @@ -711,17 +1065,17 @@ async fn stop_remembered_session(client: &Client, token: &str, identity: &Sessio &stale.streaming_base_url }; - eprintln!( + log_warn!( "Ending the session left over from a previous run: {}", stale.session_id ); match stop_session_by_id(client, token, &stale.session_id, identity, base_url).await { StopOutcome::Stopped => { active_session::forget(&stale.session_id); - wait_for_sessions_to_clear(client, token, identity).await; + wait_for_sessions_to_clear(client, token, identity, &[base_url]).await; } StopOutcome::Forbidden => { - eprintln!( + log_warn!( "Session {} belongs to an older device identity and has to expire on its own; \ dropping the note so it stops blocking launches", stale.session_id @@ -745,9 +1099,9 @@ async fn stop_active_sessions_before_launch( client: &Client, token: &str, identity: &SessionIdentity, - base_url: &str, + bases: &[&str], ) { - let Ok(active) = get_active_sessions(client, token, identity).await else { + let Ok(active) = get_active_sessions(client, token, identity, bases).await else { // Can't tell, so just launch: the session-limit handler is still there as a backstop. return; }; @@ -755,21 +1109,21 @@ async fn stop_active_sessions_before_launch( return; } - eprintln!( + log_warn!( "Ending {} session(s) still open before launching: {}", active.len(), active.join(", ") ); let mut stopped_any = false; for session_id in &active { - if stop_session_by_id(client, token, session_id, identity, base_url).await + if stop_session_across_zones(client, token, session_id, identity, bases).await == StopOutcome::Stopped { stopped_any = true; } } if stopped_any { - wait_for_sessions_to_clear(client, token, identity).await; + wait_for_sessions_to_clear(client, token, identity, bases).await; } } @@ -781,19 +1135,20 @@ async fn wait_for_sessions_to_clear( client: &Client, token: &str, identity: &SessionIdentity, + bases: &[&str], ) -> bool { const MAX_CHECKS: usize = 8; const CHECK_INTERVAL: Duration = Duration::from_secs(3); for check in 0..MAX_CHECKS { sleep(CHECK_INTERVAL).await; - match get_active_sessions(client, token, identity).await { + match get_active_sessions(client, token, identity, bases).await { Ok(remaining) if remaining.is_empty() => { - eprintln!("CloudMatch device slot is clear after {}s", (check + 1) * 3); + log_warn!("CloudMatch device slot is clear after {}s", (check + 1) * 3); return true; } Ok(remaining) => { - eprintln!( + log_warn!( "Waiting for CloudMatch to release {} session(s): {}", remaining.len(), remaining.join(", ") @@ -801,12 +1156,12 @@ async fn wait_for_sessions_to_clear( } // Can't tell - assume it cleared rather than blocking a launch that might work. Err(error) => { - eprintln!("Could not confirm CloudMatch session cleanup: {error:#}"); + log_warn!("Could not confirm CloudMatch session cleanup: {error:#}"); return true; } } } - eprintln!("CloudMatch still reports active sessions after {MAX_CHECKS} checks"); + log_warn!("CloudMatch still reports active sessions after {MAX_CHECKS} checks"); false } @@ -814,6 +1169,11 @@ fn is_ready_status(status: u32) -> bool { status == 2 || status == 3 } +fn normalize_zone_base_url(zone_base_url: &str) -> Option { + let normalized = super::regions::normalize_base_url(zone_base_url)?; + Some(normalized.trim_end_matches('/').to_owned()) +} + /// Zone load balancer hostnames (e.g. fn is_zone_hostname(host: &str) -> bool { host.contains("cloudmatchbeta.nvidiagrid.net") || host.contains("cloudmatch.nvidiagrid.net") @@ -949,7 +1309,7 @@ fn build_session_request_body( "remoteControllersBitmap": 0, "clientTimezoneOffset": 0, "enhancedStreamMode": 1, - "appLaunchMode": 0, + "appLaunchMode": 2, "secureRTSPSupported": false, "partnerCustomData": "", "accountLinked": true, @@ -964,8 +1324,8 @@ fn build_session_request_body( "profile": 0, "fallbackToLogicalResolution": false, "chromaFormat": 0, - "prefilterMode": 0, - "prefilterSharpness": 0, + "prefilterMode": 1, + "prefilterSharpness": 50, "prefilterNoiseReduction": 0, "hudStreamingMode": 0, } @@ -1277,6 +1637,41 @@ struct CloudMatchSession { ice_server_configuration: Option, #[serde(default)] session_request_data: Option, + #[serde(default, alias = "ads")] + session_ads: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CloudMatchSessionAd { + #[serde(default)] + ad_id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + description: Option, + #[serde(default)] + ad_length_in_seconds: Option, + #[serde(default)] + duration_ms: Option, +} + +impl CloudMatchSessionAd { + fn into_session_ad_info(self) -> Option { + let ad_id = self.ad_id?.as_string(); + if ad_id.is_empty() { + return None; + } + let length_ms = self + .duration_ms + .or_else(|| self.ad_length_in_seconds.map(|secs| (secs * 1000.0) as u64)); + Some(SessionAdInfo { + ad_id, + title: self.title, + description: self.description, + length_ms, + }) + } } #[derive(Debug, Clone, Deserialize, Default)] diff --git a/src/gfn/covers.rs b/src/gfn/covers.rs index 1db0894..cfc1a32 100644 --- a/src/gfn/covers.rs +++ b/src/gfn/covers.rs @@ -45,10 +45,10 @@ impl TitleImage { } /// Lazily uploads the RGBA to the egui context. - pub fn texture(&self, ctx: &egui::Context, key: &str) -> &egui::TextureHandle { + pub fn texture(&self, ctx: &egui::Context, key: impl FnOnce() -> String) -> &egui::TextureHandle { self.texture.get_or_init(|| { ctx.load_texture( - key.to_owned(), + key(), egui::ColorImage::from_rgba_unmultiplied( [self.width as usize, self.height as usize], &self.rgba, @@ -69,51 +69,72 @@ enum CoverState { Failed { at: Instant }, } -/// The map plus its LRU ordering, kept together so both are mutated under the one `Mutex`. +struct CoverEntry { + state: CoverState, + generation: u64, +} + #[derive(Default)] struct CoverCache { - entries: HashMap, - /// App ids ordered oldest-touched first. - lru: Vec, + entries: HashMap, + next_generation: u64, + ready_count: usize, } impl CoverCache { - /// Moves `app_id` to the most-recently-used end. + fn get(&self, app_id: &str) -> Option<&CoverState> { + self.entries.get(app_id).map(|entry| &entry.state) + } + + fn insert(&mut self, app_id: String, state: CoverState) { + let is_ready = matches!(state, CoverState::Ready(_)); + self.next_generation += 1; + let generation = self.next_generation; + if let Some(previous) = self.entries.insert( + app_id, + CoverEntry { + state, + generation, + }, + ) { + if matches!(previous.state, CoverState::Ready(_)) { + self.ready_count -= 1; + } + } + if is_ready { + self.ready_count += 1; + } + } + fn touch(&mut self, app_id: &str) { - if let Some(index) = self.lru.iter().position(|id| id == app_id) { - let id = self.lru.remove(index); - self.lru.push(id); - } else { - self.lru.push(app_id.to_owned()); + self.next_generation += 1; + let generation = self.next_generation; + if let Some(entry) = self.entries.get_mut(app_id) { + entry.generation = generation; } } fn forget(&mut self, app_id: &str) { - self.entries.remove(app_id); - self.lru.retain(|id| id != app_id); + if let Some(entry) = self.entries.remove(app_id) + && matches!(entry.state, CoverState::Ready(_)) + { + self.ready_count -= 1; + } } /// Drops `Ready` covers until at most `max_ready` remain, oldest-touched first, never /// touching `keep`. fn evict_to(&mut self, keep: Option<&str>, max_ready: usize) { - let ready_count = |cache: &Self| { - cache + while self.ready_count > max_ready { + let victim = self .entries - .values() - .filter(|state| matches!(state, CoverState::Ready(_))) - .count() - }; - - while ready_count(self) > max_ready { - let victim = self.lru.iter().find(|id| { - if keep == Some(id.as_str()) { - return false; - } - matches!(self.entries.get(*id), Some(CoverState::Ready(_))) - }); - let Some(victim) = victim.cloned() else { - break; - }; + .iter() + .filter(|(id, entry)| { + keep != Some(id.as_str()) && matches!(entry.state, CoverState::Ready(_)) + }) + .min_by_key(|(_, entry)| entry.generation) + .map(|(id, _)| id.clone()); + let Some(victim) = victim else { break }; self.forget(&victim); } } @@ -217,13 +238,12 @@ impl CoverStore { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; - match inner.entries.get(&app_id) { + match inner.get(&app_id) { Some(CoverState::Loading | CoverState::Ready(_)) => return, Some(CoverState::Failed { at }) if at.elapsed() < COVER_RETRY_AFTER => return, Some(CoverState::Failed { .. }) | None => {} } - inner.entries.insert(app_id.clone(), CoverState::Loading); - inner.touch(&app_id); + inner.insert(app_id.clone(), CoverState::Loading); } let permits = self.download_permits.clone(); @@ -238,16 +258,13 @@ impl CoverStore { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; - inner - .entries - .insert(app_id, CoverState::Failed { at: Instant::now() }); + inner.insert(app_id, CoverState::Failed { at: Instant::now() }); return; } }; let outcome = fetch_and_decode(&http_client, &app_id, &url, size.max_dimension()).await; - let texture_key = size.texture_key(&app_id); let mut inner = match cache.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -255,18 +272,13 @@ impl CoverStore { match outcome { Ok(image) => { let texture = Arc::new(image); - let _ = texture.texture(&ctx, &texture_key); - inner - .entries - .insert(app_id.clone(), CoverState::Ready(texture)); - inner.touch(&app_id); + let _ = texture.texture(&ctx, || size.texture_key(&app_id)); + inner.insert(app_id.clone(), CoverState::Ready(texture)); inner.evict_to(Some(&app_id), size.cache_capacity()); } Err(error) => { eprintln!("Cover fetch for {app_id} failed: {error:#}"); - inner - .entries - .insert(app_id, CoverState::Failed { at: Instant::now() }); + inner.insert(app_id, CoverState::Failed { at: Instant::now() }); } } }); @@ -308,7 +320,7 @@ impl CoverStore { fn get_sized(&self, app_id: &str, size: CoverSize) -> Option { let mut inner = self.cache_for(size).lock().ok()?; - let snapshot = match inner.entries.get(app_id)? { + let snapshot = match inner.get(app_id)? { CoverState::Loading => CoverSnapshot::Loading, CoverState::Ready(image) => CoverSnapshot::Ready(image.clone()), CoverState::Failed { .. } => CoverSnapshot::Failed, @@ -317,6 +329,16 @@ impl CoverStore { Some(snapshot) } + pub fn is_requested(&self, app_id: &str, size: CoverSize) -> bool { + let Ok(inner) = self.cache_for(size).lock() else { + return false; + }; + matches!( + inner.get(app_id), + Some(CoverState::Loading | CoverState::Ready(_)) + ) + } + /// egui texture key for a cached image, so callers pass the right one to /// [`TitleImage::texture`]. pub fn texture_key(app_id: &str, size: CoverSize) -> String { diff --git a/src/gfn/input_protocol.rs b/src/gfn/input_protocol.rs index 7753b74..5b62f04 100644 --- a/src/gfn/input_protocol.rs +++ b/src/gfn/input_protocol.rs @@ -76,6 +76,29 @@ pub const KEY_F1: KeyStroke = KeyStroke::new(0x70, 0x3B); pub const KEY_F2: KeyStroke = KeyStroke::new(0x71, 0x3C); pub const KEY_F3: KeyStroke = KeyStroke::new(0x72, 0x3D); pub const KEY_F4: KeyStroke = KeyStroke::new(0x73, 0x3E); +pub const KEY_F5: KeyStroke = KeyStroke::new(0x74, 0x3F); +pub const KEY_F6: KeyStroke = KeyStroke::new(0x75, 0x40); +pub const KEY_F7: KeyStroke = KeyStroke::new(0x76, 0x41); +pub const KEY_F8: KeyStroke = KeyStroke::new(0x77, 0x42); +pub const KEY_F9: KeyStroke = KeyStroke::new(0x78, 0x43); +pub const KEY_F10: KeyStroke = KeyStroke::new(0x79, 0x44); +pub const KEY_F11: KeyStroke = KeyStroke::new(0x7A, 0x57); +pub const KEY_F12: KeyStroke = KeyStroke::new(0x7B, 0x58); +pub const KEY_LEFT: KeyStroke = KeyStroke::new(0x25, 0x4B); +pub const KEY_RIGHT: KeyStroke = KeyStroke::new(0x27, 0x4D); +pub const KEY_UP: KeyStroke = KeyStroke::new(0x26, 0x48); +pub const KEY_DOWN: KeyStroke = KeyStroke::new(0x28, 0x50); +pub const KEY_HOME: KeyStroke = KeyStroke::new(0x24, 0x47); +pub const KEY_END: KeyStroke = KeyStroke::new(0x23, 0x4F); +pub const KEY_PAGE_UP: KeyStroke = KeyStroke::new(0x21, 0x49); +pub const KEY_PAGE_DOWN: KeyStroke = KeyStroke::new(0x22, 0x51); +pub const KEY_DELETE: KeyStroke = KeyStroke::new(0x2E, 0x53); +pub const KEY_INSERT: KeyStroke = KeyStroke::new(0x2D, 0x52); +pub const KEY_CAPS_LOCK: KeyStroke = KeyStroke::new(0x14, 0x3A); +pub const KEY_RIGHT_CTRL: KeyStroke = KeyStroke::new(0xA3, 0x1D); +pub const KEY_RIGHT_ALT: KeyStroke = KeyStroke::new(0xA5, 0x38); +pub const KEY_LEFT_WIN: KeyStroke = KeyStroke::new(0x5B, 0x5B); +pub const KEY_MENU: KeyStroke = KeyStroke::new(0x5D, 0x5D); /// Scancodes for the digits `1`..`9`, `0`, in that order - the top number row. const DIGIT_SCANCODES: [u16; 10] = [ @@ -425,6 +448,26 @@ mod tests { assert_eq!(&payload[0..4], &4u32.to_le_bytes()); } + #[test] + fn special_key_constants_are_all_distinct() { + let keys = [ + KEY_ESCAPE, KEY_ENTER, KEY_TAB, KEY_BACKSPACE, KEY_SPACE, KEY_LEFT_SHIFT, + KEY_LEFT_CTRL, KEY_LEFT_ALT, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, + KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, + KEY_HOME, KEY_END, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_DELETE, KEY_INSERT, KEY_CAPS_LOCK, + KEY_RIGHT_CTRL, KEY_RIGHT_ALT, KEY_LEFT_WIN, KEY_MENU, + ]; + for (i, a) in keys.iter().enumerate() { + for b in &keys[i + 1..] { + assert_ne!( + (a.keycode, a.scancode), + (b.keycode, b.scancode), + "duplicate keystroke among special key constants" + ); + } + } + } + /// The same single-input framing mouse buttons use: `[0x23][u64 BE ts][0x22][payload]`, with /// no length prefix. #[test] diff --git a/src/gfn/link_estimate.rs b/src/gfn/link_estimate.rs index 937b78e..577a49f 100644 --- a/src/gfn/link_estimate.rs +++ b/src/gfn/link_estimate.rs @@ -14,11 +14,8 @@ const STORE_DIR: &str = "ux0:data/opennow-vita"; /// Never ask for less than this: below it the picture is unusable anyway, so a bad measurement /// should not be able to strand the client on a permanently terrible stream. pub const MIN_CEILING_MBPS: u32 = 5; -/// The Vita decodes 960x544; past this, extra bitrate buys nothing visible and only risks the -/// 2.4 GHz radio. -pub const MAX_CEILING_MBPS: u32 = 25; -/// Used until a session has been measured. -pub const DEFAULT_CEILING_MBPS: u32 = 15; +pub const MAX_CEILING_MBPS: u32 = 12; +pub const DEFAULT_CEILING_MBPS: u32 = 8; /// Ignore the opening moments: the stream ramps up, so an early sample understates the link. const WARMUP: Duration = Duration::from_secs(5); @@ -148,9 +145,15 @@ fn stored_ceiling() -> Option { /// The ceiling to request, from the last measured session or the default. pub fn ceiling_mbps() -> u32 { - stored_ceiling() - .map(|mbps| mbps.clamp(MIN_CEILING_MBPS, MAX_CEILING_MBPS)) - .unwrap_or(DEFAULT_CEILING_MBPS) + let Some(raw) = stored_ceiling() else { + return DEFAULT_CEILING_MBPS; + }; + let clamped = raw.clamp(MIN_CEILING_MBPS, MAX_CEILING_MBPS); + if clamped != raw { + let _ = std::fs::create_dir_all(STORE_DIR); + let _ = std::fs::write(STORE_PATH, clamped.to_string()); + } + clamped } #[cfg(test)] diff --git a/src/gfn/mod.rs b/src/gfn/mod.rs index 5590695..08ea8da 100644 --- a/src/gfn/mod.rs +++ b/src/gfn/mod.rs @@ -8,8 +8,11 @@ pub mod favorites; pub mod headers; pub mod input_protocol; pub mod link_estimate; +pub mod queue_stats; +pub mod regions; pub mod signaling; pub mod stream_prefs; pub mod sdp; pub mod peer; +pub mod providers; pub mod rtp; diff --git a/src/gfn/peer.rs b/src/gfn/peer.rs index a0b4af1..bec3ab7 100644 --- a/src/gfn/peer.rs +++ b/src/gfn/peer.rs @@ -12,14 +12,12 @@ use crate::streaming::video::{ }; use anyhow::{Context, Result}; use bytes::BytesMut; +use rtc::interceptor::{NackGeneratorBuilder, NackResponderBuilder, Registry}; use rtc::peer_connection::RTCPeerConnectionBuilder; use rtc::peer_connection::configuration::RTCConfigurationBuilder; +use rtc::peer_connection::configuration::interceptor_registry::configure_rtcp_reports; use rtc::peer_connection::configuration::media_engine::MediaEngine; use rtc::peer_connection::configuration::setting_engine::SettingEngine; -use rtc::interceptor::Registry; -use rtc::peer_connection::configuration::interceptor_registry::{ - configure_nack, configure_rtcp_reports, -}; use rtc::peer_connection::event::{RTCDataChannelEvent, RTCPeerConnectionEvent, RTCTrackEvent}; use rtc::peer_connection::message::RTCMessage; use rtc::peer_connection::sdp::RTCSessionDescription; @@ -29,9 +27,10 @@ use rtc::peer_connection::transport::{ RTCIceServer, }; use rtc::rtp_transceiver::RTCRtpReceiverId; -use rtc::rtp_transceiver::rtp_sender::RtpCodecKind; +use rtc::rtp_transceiver::rtp_sender::{RTCPFeedback, RtpCodecKind, TYPE_RTCP_FB_NACK}; use rtc::sansio::Protocol; use rtc::shared::{TaggedBytesMut, TransportContext, TransportProtocol}; +use rtc::statistics::StatsSelector; use rtcp::payload_feedbacks::picture_loss_indication::PictureLossIndication; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -39,6 +38,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::mpsc; +use crate::log_stream; + const DEFAULT_STREAM_WIDTH: u32 = 1280; const DEFAULT_STREAM_HEIGHT: u32 = 720; @@ -46,6 +47,7 @@ const NATIVE_OUTPUT_WIDTH: u32 = 960; const NATIVE_OUTPUT_HEIGHT: u32 = 544; const PLI_MIN_INTERVAL: Duration = Duration::from_millis(250); +const QUEUE_FULL_RECOVERY_THRESHOLD: f32 = 5.0; /// The resolution NVIDIA actually streams at, per the session response. @@ -346,8 +348,33 @@ async fn run_peer( media_engine .register_default_codecs() .context("failed to register codecs")?; - let registry = configure_nack(Registry::new(), &mut media_engine); + media_engine.register_feedback( + RTCPFeedback { + typ: TYPE_RTCP_FB_NACK.to_owned(), + parameter: "".to_owned(), + }, + RtpCodecKind::Video, + ); + media_engine.register_feedback( + RTCPFeedback { + typ: TYPE_RTCP_FB_NACK.to_owned(), + parameter: "pli".to_owned(), + }, + RtpCodecKind::Video, + ); + let registry = Registry::new() + .with( + NackGeneratorBuilder::new() + .with_size(512) + .with_interval(Duration::from_millis(100)) + .build(), + ) + .with(NackResponderBuilder::new().with_size(1024).build()); let registry = configure_rtcp_reports(registry); + log_stream!( + "RTC interceptors: NACK gen size=512 interval=100ms responder=1024 + RTCP reports; \ + NVST advertises enableRtpNack + queue 1024/512/25" + ); let mut setting_engine = SettingEngine::default(); setting_engine .set_answering_dtls_role(RTCDtlsRole::Client) @@ -395,7 +422,7 @@ async fn run_peer( } }; let mut partial_input_ready = false; - let mut partial_sequence: u16 = 0; + let mut _partial_sequence: u16 = 0; let mut control_channel_id: Option = None; @@ -444,6 +471,17 @@ async fn run_peer( &stream_settings, &ri_caps, ); + let _ = std::fs::write("ux0:data/opennow-vita/nvst.sdp", &nvst_sdp); + log_stream!( + "session profile {}x{} @ {}fps ref_frames={} bitrate_ceiling={}Mbps peer_loop=reorder_grace_drain decoder=avcdec_internal present=latest", + stream_settings + .dimensions() + .0, + stream_settings.dimensions().1, + stream_settings.fps, + crate::streaming::video::AVCDEC_NUM_REF_FRAMES, + stream_settings.max_bitrate_mbps, + ); let our_ufrag = crate::gfn::sdp::extract_ice_credentials(&answer_sdp).ufrag; let _ = event_tx.send(PeerEvent::LocalAnswer { answer_sdp: saved_answer_sdp.clone(), @@ -465,6 +503,13 @@ async fn run_peer( let mut last_pli_sent: Option = None; let mut pli_sent_count: u64 = 0; let mut dropped_frames_total: u64 = 0; + let mut reorder_rescued_total: u64 = 0; + let mut reorder_expired_total: u64 = 0; + let mut reorder_rescued_last: u64 = 0; + let mut reorder_expired_last: u64 = 0; + let mut rtc_nack_last: u64 = 0; + let mut rtc_rtx_last: u64 = 0; + let mut rtc_lost_last: i64 = 0; let mut in_stun: u64 = 0; let mut in_dtls: u64 = 0; let mut in_media: u64 = 0; @@ -493,8 +538,45 @@ async fn run_peer( let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(2)); heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let mut pending_commands: Vec = Vec::new(); + let mut mouse_events = Vec::new(); + let mut key_events: Vec<(KeyStroke, bool)> = Vec::new(); + let mut ingress = BytesMut::with_capacity(2048); const IDLE_TIMEOUT: Duration = Duration::from_secs(86400); + let send_pli_if_needed = |pc: &mut rtc::peer_connection::RTCPeerConnection<_>, + last_pli_sent: &mut Option, + pli_sent_count: &mut u64, + keyframe_requested: bool, + count_for_bwe: bool, + video_receiver_id: Option, + video_ssrc: Option| { + if !keyframe_requested { + return; + } + let (Some(receiver_id), Some(ssrc)) = (video_receiver_id, video_ssrc) else { + return; + }; + let now = Instant::now(); + let should_send = last_pli_sent + .map(|last| now.duration_since(last) >= PLI_MIN_INTERVAL) + .unwrap_or(true); + if should_send + && let Some(mut receiver) = pc.rtp_receiver(receiver_id) + && receiver + .write_rtcp(vec![Box::new(PictureLossIndication { + sender_ssrc: 0, + media_ssrc: ssrc, + })]) + .is_ok() + { + *last_pli_sent = Some(now); + *pli_sent_count += 1; + if count_for_bwe { + keyframe_requests.fetch_add(1, Ordering::Relaxed); + } + } + }; + loop { while let Some(msg) = pc.poll_write() { match classify(msg.message.first()) { @@ -510,9 +592,11 @@ async fn run_peer( RTCPeerConnectionEvent::OnConnectionStateChangeEvent(state) => match state { RTCPeerConnectionState::Connected => { is_connected.store(true, Ordering::Relaxed); + log_stream!("peer Connected"); let _ = event_tx.send(PeerEvent::Connected); } RTCPeerConnectionState::Failed | RTCPeerConnectionState::Closed => { + log_stream!("peer state terminal: {state}"); let _ = event_tx.send(PeerEvent::Disconnected(format!( "peer connection state: {state}" ))); @@ -520,10 +604,12 @@ async fn run_peer( } other => { // stays english, this is just debug status from the peer thread, no locale here + log_stream!("Connection: {other}"); let _ = event_tx.send(PeerEvent::Status(format!("Connection: {other}"))); } }, RTCPeerConnectionEvent::OnIceConnectionStateChangeEvent(state) => { + log_stream!("ICE: {state}"); let _ = event_tx.send(PeerEvent::Status(format!("ICE: {state}"))); } RTCPeerConnectionEvent::OnTrack(track_event) => { @@ -583,6 +669,10 @@ async fn run_peer( rtp_packets += 1; if !first_rtp_seen { first_rtp_seen = true; + log_stream!( + "first RTP packet (payload type {})", + packet.header.payload_type + ); let _ = event_tx.send(PeerEvent::Status(format!( "Recibiendo RTP (payload type {})", packet.header.payload_type @@ -613,42 +703,62 @@ async fn run_peer( continue; }; dropped_frames_total += u64::from(sample_stats.dropped); + reorder_rescued_total += u64::from(sample_stats.reorder_rescued); + reorder_expired_total += u64::from(sample_stats.reorder_expired); if sample_stats.source_frame_duration_us.is_some() { access_units_sent += 1; if !first_au_submitted { first_au_submitted = true; + log_stream!("first H.264 AU submitted to decoder"); let _ = event_tx.send(PeerEvent::Status("Decodificando H.264".to_owned())); } } - if keyframe_requested - && let (Some(receiver_id), Some(ssrc)) = (video_receiver_id, video_ssrc) - { - let now = Instant::now(); - let should_send = last_pli_sent - .map(|last| now.duration_since(last) >= PLI_MIN_INTERVAL) - .unwrap_or(true); - if should_send - && let Some(mut receiver) = pc.rtp_receiver(receiver_id) - && receiver - .write_rtcp(vec![Box::new(PictureLossIndication { - sender_ssrc: 0, - media_ssrc: ssrc, - })]) - .is_ok() - { - last_pli_sent = Some(now); - pli_sent_count += 1; - keyframe_requests.fetch_add(1, Ordering::Relaxed); - } - } + send_pli_if_needed( + &mut pc, + &mut last_pli_sent, + &mut pli_sent_count, + keyframe_requested, + true, + video_receiver_id, + video_ssrc, + ); } } - let timeout = pc + let now_wall = Instant::now(); + let mut timeout = pc .poll_timeout() - .unwrap_or_else(|| Instant::now() + IDLE_TIMEOUT); + .unwrap_or_else(|| now_wall + IDLE_TIMEOUT); + if let Some(deadline_us) = video_rtp.reorder_deadline_us() { + let elapsed_us = session_clock.elapsed().as_micros() as u64; + let remaining_us = deadline_us.saturating_sub(elapsed_us); + let reorder_at = now_wall + Duration::from_micros(remaining_us); + if reorder_at < timeout { + timeout = reorder_at; + } + } let delay = timeout.saturating_duration_since(Instant::now()); if delay.is_zero() { + let now_us = session_clock.elapsed().as_micros() as u64; + if let Some(worker) = &decode_worker + && video_rtp.reorder_deadline_us().is_some_and(|d| now_us >= d) + { + let mut keyframe_requested = false; + let expire_stats = + video_rtp.expire_reorder_grace_if_due(worker, &mut keyframe_requested, now_us); + dropped_frames_total += u64::from(expire_stats.dropped); + reorder_rescued_total += u64::from(expire_stats.reorder_rescued); + reorder_expired_total += u64::from(expire_stats.reorder_expired); + send_pli_if_needed( + &mut pc, + &mut last_pli_sent, + &mut pli_sent_count, + keyframe_requested, + true, + video_receiver_id, + video_ssrc, + ); + } pc.handle_timeout(Instant::now())?; continue; } @@ -660,6 +770,29 @@ async fn run_peer( biased; _ = &mut timer => { + let now_us = session_clock.elapsed().as_micros() as u64; + if let Some(worker) = &decode_worker + && video_rtp.reorder_deadline_us().is_some_and(|d| now_us >= d) + { + let mut keyframe_requested = false; + let expire_stats = video_rtp.expire_reorder_grace_if_due( + worker, + &mut keyframe_requested, + now_us, + ); + dropped_frames_total += u64::from(expire_stats.dropped); + reorder_rescued_total += u64::from(expire_stats.reorder_rescued); + reorder_expired_total += u64::from(expire_stats.reorder_expired); + send_pli_if_needed( + &mut pc, + &mut last_pli_sent, + &mut pli_sent_count, + keyframe_requested, + true, + video_receiver_id, + video_ssrc, + ); + } pc.handle_timeout(Instant::now())?; } _ = heartbeat_interval.tick() => { @@ -722,17 +855,65 @@ async fn run_peer( } let jitter_ms = video_rtp.current_jitter_ms(); - let _ = event_tx.send(PeerEvent::Status(format!( - "fps:{fps:.0} src:{src_rate:.0} sub:{sub:.0} qf:{qfull:.0} dec:{avg_decode_ms:.1}ms wait:{avg_wait_ms:.1}ms jit:{jitter_ms:.1}ms nof:{noframe:.0} err:{errs:.0} reb:{rebuilds} stall:{stalls:.0} rtp:{rtp_rate:.0} drop:{drop_rate:.0} pli:{pli_sent_count} wfk:{} in:{} pr:{}", + let report = pc.get_stats(Instant::now(), StatsSelector::None); + let inbound = report.inbound_rtp_streams().find(|stream| { + video_ssrc + .map(|ssrc| stream.received_rtp_stream_stats.rtp_stream_stats.ssrc == ssrc) + .unwrap_or(false) + }); + let rtc_nack = inbound.map(|s| u64::from(s.nack_count)).unwrap_or(0); + let rtc_rtx = inbound + .map(|s| s.retransmitted_packets_received) + .unwrap_or(0); + let rtc_lost = inbound + .map(|s| s.received_rtp_stream_stats.packets_lost) + .unwrap_or(0); + let rtc_pli_stat = inbound.map(|s| u64::from(s.pli_count)).unwrap_or(0); + let nack_rate = rate(rtc_nack, rtc_nack_last); + let rtx_rate = rate(rtc_rtx, rtc_rtx_last); + let lost_delta = rtc_lost.saturating_sub(rtc_lost_last); + let rescue_rate = rate(reorder_rescued_total, reorder_rescued_last); + let expire_rate = rate(reorder_expired_total, reorder_expired_last); + rtc_nack_last = rtc_nack; + rtc_rtx_last = rtc_rtx; + rtc_lost_last = rtc_lost; + reorder_rescued_last = reorder_rescued_total; + reorder_expired_last = reorder_expired_total; + + if qfull >= QUEUE_FULL_RECOVERY_THRESHOLD { + send_pli_if_needed( + &mut pc, + &mut last_pli_sent, + &mut pli_sent_count, + true, + false, + video_receiver_id, + video_ssrc, + ); + } + + if lost_delta > 5 && nack_rate < 0.5 && drop_rate > 0.0 { + log_stream!( + "NACK suspicion: lost_delta={lost_delta} nack/s={nack_rate:.1} rtx/s={rtx_rate:.1} drop/s={drop_rate:.1} — \ + if this persists, the interceptor may not be delivering NACKs (custom rtc patch)" + ); + } + + let line = format!( + "fps:{fps:.0} src:{src_rate:.0} sub:{sub:.0} qf:{qfull:.0} dec:{avg_decode_ms:.1}ms wait:{avg_wait_ms:.1}ms jit:{jitter_ms:.1}ms nof:{noframe:.0} err:{errs:.0} reb:{rebuilds} stall:{stalls:.0} rtp:{rtp_rate:.0} drop:{drop_rate:.0} pli:{pli_sent_count}/{rtc_pli_stat} nack:{rtc_nack}({nack_rate:.0}/s) rtx:{rtc_rtx}({rtx_rate:.0}/s) lost:{rtc_lost}(+{lost_delta}) rescue:{rescue_rate:.0}/s expire:{expire_rate:.0}/s wfk:{} in:{} pr:{}", u8::from(video_rtp.waiting_for_keyframe()), u8::from(input_ready), u8::from(partial_input_ready) - ))); + ); + log_stream!("{line}"); + let _ = event_tx.send(PeerEvent::Status(line)); if frames == 0 { - let _ = event_tx.send(PeerEvent::Status(format!( + let idle = format!( "IN s:{in_stun} d:{in_dtls} m:{in_media} | OUT s:{out_stun} d:{out_dtls} m:{out_media} | RTP:{rtp_packets} AU:{access_units_sent}" - ))); + ); + log_stream!("{idle}"); + let _ = event_tx.send(PeerEvent::Status(idle)); } if fps == 0.0 { @@ -755,7 +936,6 @@ async fn run_peer( last_pli_sent = Some(now); pli_sent_count += 1; keyframe_requests.fetch_add(1, Ordering::Relaxed); - keyframe_requests.fetch_add(1, Ordering::Relaxed); } } } @@ -777,20 +957,25 @@ async fn run_peer( media_bytes.fetch_add(n as u64, Ordering::Relaxed); } } + let now = Instant::now(); + ingress.clear(); + ingress.extend_from_slice(&buf[..n]); pc.handle_read(TaggedBytesMut { - now: Instant::now(), + now, transport: TransportContext { local_addr, peer_addr, ecn: None, transport_protocol: TransportProtocol::UDP, }, - message: BytesMut::from(&buf[..n]), + message: ingress.split(), })?; } } } + let reorder_was_active = video_rtp.reorder_deadline_us().is_some(); + let drain_now = Instant::now(); while let Ok((n, peer_addr)) = socket.try_recv_from(&mut buf) { match classify(buf.first()) { 0 => in_stun += 1, @@ -800,24 +985,49 @@ async fn run_peer( media_bytes.fetch_add(n as u64, Ordering::Relaxed); } } + ingress.clear(); + ingress.extend_from_slice(&buf[..n]); pc.handle_read(TaggedBytesMut { - now: Instant::now(), + now: drain_now, transport: TransportContext { local_addr, peer_addr, ecn: None, transport_protocol: TransportProtocol::UDP, }, - message: BytesMut::from(&buf[..n]), + message: ingress.split(), })?; + if reorder_was_active || video_rtp.reorder_deadline_us().is_some() { + break; + } + } + if let Some(worker) = &decode_worker { + let now_us = session_clock.elapsed().as_micros() as u64; + if video_rtp.reorder_deadline_us().is_some_and(|d| now_us >= d) { + let mut keyframe_requested = false; + let expire_stats = + video_rtp.expire_reorder_grace_if_due(worker, &mut keyframe_requested, now_us); + dropped_frames_total += u64::from(expire_stats.dropped); + reorder_rescued_total += u64::from(expire_stats.reorder_rescued); + reorder_expired_total += u64::from(expire_stats.reorder_expired); + send_pli_if_needed( + &mut pc, + &mut last_pli_sent, + &mut pli_sent_count, + keyframe_requested, + true, + video_receiver_id, + video_ssrc, + ); + } } while let Ok(command) = command_rx.try_recv() { pending_commands.push(command); } let mut latest_gamepad = None; - let mut mouse_events = Vec::new(); - let mut key_events: Vec<(KeyStroke, bool)> = Vec::new(); + mouse_events.clear(); + key_events.clear(); for command in pending_commands.drain(..) { match command { PeerCommand::RemoteIce(candidate) => { diff --git a/src/gfn/providers.rs b/src/gfn/providers.rs new file mode 100644 index 0000000..07e3f22 --- /dev/null +++ b/src/gfn/providers.rs @@ -0,0 +1,143 @@ + +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +pub const SERVICE_URLS_ENDPOINT: &str = "https://pcs.geforcenow.com/v1/serviceUrls"; +pub const DEFAULT_NVIDIA_IDP_ID: &str = "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg"; +pub const DEFAULT_NVIDIA_STREAMING_URL: &str = "https://prod.cloudmatchbeta.nvidiagrid.net/"; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; Steam Deck) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GfnProvider { + pub code: String, + pub display_name: String, + pub idp_id: String, + pub streaming_service_url: String, + #[serde(default)] + pub priority: i32, +} + +impl Default for GfnProvider { + fn default() -> Self { + Self { + code: "NVIDIA".to_owned(), + display_name: "NVIDIA".to_owned(), + idp_id: DEFAULT_NVIDIA_IDP_ID.to_owned(), + streaming_service_url: DEFAULT_NVIDIA_STREAMING_URL.to_owned(), + priority: 1, + } + } +} + +impl GfnProvider { + pub fn is_nvidia(&self) -> bool { + self.code.trim().eq_ignore_ascii_case("NVIDIA") + } + + pub fn normalized_streaming_url(&self) -> String { + if self.streaming_service_url.ends_with('/') { + self.streaming_service_url.clone() + } else { + format!("{}/", self.streaming_service_url) + } + } +} + +#[derive(Debug, Deserialize)] +struct ServiceUrlsResponse { + #[serde(default, rename = "gfnServiceInfo")] + gfn_service_info: Option, +} + +#[derive(Debug, Deserialize)] +struct GfnServiceInfo { + #[serde(default, rename = "defaultProvider")] + default_provider: Option, + #[serde(default, rename = "clientCountryCode")] + client_country_code: Option, + #[serde(default, rename = "loginPreferredProviders")] + login_preferred_providers: Vec, + #[serde(default, rename = "gfnServiceEndpoints")] + gfn_service_endpoints: Vec, +} + +#[derive(Debug, Deserialize)] +struct GfnServiceEndpoint { + #[serde(rename = "loginProviderCode")] + login_provider_code: String, + #[serde(rename = "loginProviderDisplayName")] + login_provider_display_name: String, + #[serde(rename = "idpId")] + idp_id: String, + #[serde(rename = "streamingServiceUrl")] + streaming_service_url: String, + #[serde(default, rename = "loginProviderPriority")] + login_provider_priority: Option, +} + +pub async fn discover_providers(client: &Client) -> Result<(GfnProvider, Vec)> { + let response = client + .get(SERVICE_URLS_ENDPOINT) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .timeout(REQUEST_TIMEOUT) + .send() + .await + .context("failed to request service URLs")?; + + let payload: ServiceUrlsResponse = response + .json() + .await + .context("failed to parse service URLs JSON")?; + + let Some(service_info) = payload.gfn_service_info else { + return Ok((GfnProvider::default(), vec![GfnProvider::default()])); + }; + + let mut providers: Vec = service_info + .gfn_service_endpoints + .into_iter() + .map(|ep| GfnProvider { + display_name: if ep.login_provider_code == "BPC" { + "bro.game".to_owned() + } else { + ep.login_provider_display_name + }, + code: ep.login_provider_code, + idp_id: ep.idp_id, + streaming_service_url: if ep.streaming_service_url.ends_with('/') { + ep.streaming_service_url + } else { + format!("{}/", ep.streaming_service_url) + }, + priority: ep.login_provider_priority.unwrap_or(100), + }) + .collect(); + + providers.sort_by_key(|p| p.priority); + + if providers.is_empty() { + return Ok((GfnProvider::default(), vec![GfnProvider::default()])); + } + + let preferred = if let Some(pref_name) = service_info.login_preferred_providers.first() { + providers + .iter() + .find(|p| p.display_name.eq_ignore_ascii_case(pref_name) || p.code.eq_ignore_ascii_case(pref_name)) + .cloned() + } else if let Some(default_code) = &service_info.default_provider { + providers + .iter() + .find(|p| p.code.eq_ignore_ascii_case(default_code) || p.display_name.eq_ignore_ascii_case(default_code)) + .cloned() + } else { + None + } + .unwrap_or_else(|| providers[0].clone()); + + Ok((preferred, providers)) +} diff --git a/src/gfn/queue_stats.rs b/src/gfn/queue_stats.rs new file mode 100644 index 0000000..53ba08e --- /dev/null +++ b/src/gfn/queue_stats.rs @@ -0,0 +1,161 @@ + +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::Deserialize; +use std::collections::HashMap; +use std::time::Duration; + +const QUEUE_API_URL: &str = "https://api.printedwaste.com/gfn/queue"; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(6); + +const MAX_AGE_SECONDS: u64 = 30 * 60; + +#[derive(Debug, Clone, Deserialize)] +struct QueueEntry { + #[serde(rename = "QueuePosition")] + queue_position: Option, + #[serde(rename = "eta")] + eta_ms: Option, + #[serde(rename = "Last Updated")] + last_updated: Option, +} + +#[derive(Debug, Deserialize)] +struct QueueResponse { + #[serde(default)] + status: bool, + #[serde(default)] + data: HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QueueReading { + pub queue_position: u32, + pub eta_seconds: Option, +} + +pub type QueueMap = HashMap; + +pub fn server_code_from_url(url: &str) -> Option { + let authority = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .unwrap_or(url) + .split('/') + .next()?; + let label = authority.split('.').next()?; + if !label.contains('-') { + return None; + } + if !label + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + { + return None; + } + let code = label.to_ascii_uppercase(); + code.starts_with("NP").then_some(code) +} + +pub async fn fetch_queue(client: &Client) -> Result { + let response = client + .get(QUEUE_API_URL) + .timeout(REQUEST_TIMEOUT) + .send() + .await + .context("queue API request failed")? + .error_for_status() + .context("queue API returned an error status")?; + + let payload: QueueResponse = response + .json() + .await + .context("failed to decode the queue API response")?; + if !payload.status { + anyhow::bail!("queue API reported failure"); + } + + let now = unix_time_seconds(); + let mut readings = QueueMap::new(); + let mut stale = 0usize; + for (code, entry) in payload.data { + let Some(queue_position) = entry.queue_position else { + continue; + }; + if let (Some(updated), Some(now)) = (entry.last_updated, now) { + if now.saturating_sub(updated) > MAX_AGE_SECONDS { + stale += 1; + continue; + } + } + readings.insert( + code.to_ascii_uppercase(), + QueueReading { + queue_position, + eta_seconds: entry.eta_ms.map(|ms| ms / 1000), + }, + ); + } + + crate::log_info!( + "Queue stats: {} live server(s), {stale} stale reading(s) ignored", + readings.len() + ); + Ok(readings) +} + +fn unix_time_seconds() -> Option { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|elapsed| elapsed.as_secs()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_zone_endpoint_yields_its_server_code() { + assert_eq!( + server_code_from_url("https://np-ams-02.cloudmatchbeta.nvidiagrid.net/"), + Some("NP-AMS-02".to_owned()) + ); + assert_eq!( + server_code_from_url("https://npa-gkr-sel-01.cloudmatchbeta.nvidiagrid.net/v2/"), + Some("NPA-GKR-SEL-01".to_owned()) + ); + } + + #[test] + fn non_server_hosts_yield_nothing() { + assert_eq!( + server_code_from_url("https://prod.cloudmatchbeta.nvidiagrid.net/"), + None + ); + assert_eq!(server_code_from_url("https://example.com/"), None); + assert_eq!(server_code_from_url(""), None); + } + + #[test] + fn stale_readings_are_dropped() { + let body = r#"{"status":true,"errors":[],"data":{ + "NP-FRESH-01":{"QueuePosition":4,"eta":144000,"Last Updated":4000000000}, + "NP-STALE-01":{"QueuePosition":3,"eta":138000,"Last Updated":1000} + }}"#; + let payload: QueueResponse = serde_json::from_str(body).expect("body should decode"); + let now = 4_000_000_100u64; + let live: Vec<&String> = payload + .data + .iter() + .filter(|(_, entry)| { + entry + .last_updated + .is_some_and(|updated| now.saturating_sub(updated) <= MAX_AGE_SECONDS) + }) + .map(|(code, _)| code) + .collect(); + assert_eq!(live, vec![&"NP-FRESH-01".to_owned()]); + } +} diff --git a/src/gfn/regions.rs b/src/gfn/regions.rs new file mode 100644 index 0000000..5c77dbb --- /dev/null +++ b/src/gfn/regions.rs @@ -0,0 +1,257 @@ + +use super::headers::{self, error_for_status_with_body}; +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::Deserialize; +use std::time::{Duration, Instant}; + +const CLOUDMATCH_BASE_URL: &str = "https://prod.cloudmatchbeta.nvidiagrid.net/"; + + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); + +const LATENCY_SAMPLES: u32 = 3; + +const PARALLEL_PROBES: usize = 4; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamRegion { + + pub name: String, + + pub url: String, + + pub ping_ms: Option, +} +#[derive(Debug, Deserialize)] +struct ServerInfoResponse { + #[serde(default, rename = "metaData")] + meta_data: Vec, +} + +#[derive(Debug, Deserialize)] +struct MetaEntry { + #[serde(default)] + key: String, + #[serde(default)] + value: String, +} + +pub fn normalize_base_url(value: &str) -> Option { + let value = value.trim(); + let rest = value.strip_prefix("https://")?; + if rest.is_empty() { + return None; + } + let authority = rest.split('/').next()?; + if authority.is_empty() || authority.contains('@') { + return None; + } + if value + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) + { + return None; + } + Some(if value.ends_with('/') { + value.to_owned() + } else { + format!("{value}/") + }) +} + +fn host_and_port(url: &str) -> Option<(String, u16)> { + let authority = url.strip_prefix("https://")?.split('/').next()?; + match authority.rsplit_once(':') { + Some((host, port)) if !host.is_empty() => { + Some((host.to_owned(), port.parse().unwrap_or(443))) + } + _ => Some((authority.to_owned(), 443)), + } +} + +pub async fn fetch_regions(client: &Client, token: &str) -> Result> { + let provider_url = super::auth::load_tokens() + .and_then(|t| t.provider) + .map(|p| p.normalized_streaming_url()); + let base_url = provider_url.as_deref().unwrap_or(CLOUDMATCH_BASE_URL); + let base_url = if base_url.ends_with('/') { + base_url.to_owned() + } else { + format!("{base_url}/") + }; + let response = headers::apply_lcars_headers( + client.get(format!("{base_url}v2/serverInfo")), + token, + "WEBRTC", + ) + .send() + .await + .context("serverInfo request failed")?; + let response = error_for_status_with_body(response).await?; + + let payload: ServerInfoResponse = response + .json() + .await + .context("failed to decode serverInfo response")?; + + let mut regions: Vec = Vec::new(); + for entry in payload.meta_data { + let name = entry.key.trim(); + if name.is_empty() || name.starts_with("gfn-") { + continue; + } + let Some(url) = normalize_base_url(&entry.value) else { + continue; + }; + if regions.iter().any(|existing| existing.url == url) { + continue; + } + regions.push(StreamRegion { + name: name.to_owned(), + url, + ping_ms: None, + }); + } + + regions.sort_by(|left, right| left.name.cmp(&right.name)); + crate::log_info!("Regions: serverInfo advertised {} zone(s)", regions.len()); + for region in ®ions { + crate::log_info!( + "Regions: zone {:?} -> {} (queue key {:?})", + region.name, + region.url, + crate::gfn::queue_stats::server_code_from_url(®ion.url) + ); + } + Ok(regions) +} + +async fn connect_once(host: &str, port: u16) -> Option { + let started = Instant::now(); + let stream = tokio::time::timeout( + CONNECT_TIMEOUT, + tokio::net::TcpStream::connect((host, port)), + ) + .await + .ok()? + .ok()?; + drop(stream); + Some(started.elapsed().as_millis() as u32) +} + +pub async fn measure_latency(url: &str) -> Option { + let (host, port) = host_and_port(url)?; + + let _ = connect_once(&host, port).await; + + let mut total_ms = 0u32; + let mut answered = 0u32; + for sample in 0..LATENCY_SAMPLES { + if sample > 0 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + if let Some(ms) = connect_once(&host, port).await { + total_ms += ms; + answered += 1; + } + } + + (answered > 0).then(|| (total_ms + answered / 2) / answered) +} + +pub async fn measure_all(regions: Vec) -> Vec { + let mut measured = Vec::with_capacity(regions.len()); + + for chunk in regions.chunks(PARALLEL_PROBES) { + let handles: Vec<_> = chunk + .iter() + .map(|region| { + let url = region.url.clone(); + tokio::spawn(async move { measure_latency(&url).await }) + }) + .collect(); + + for (region, handle) in chunk.iter().zip(handles) { + let ping_ms = handle.await.ok().flatten(); + measured.push(StreamRegion { + ping_ms, + ..region.clone() + }); + } + } + + crate::log_info!( + "Regions: latency sweep done - {}", + measured + .iter() + .map(|region| match region.ping_ms { + Some(ms) => format!("{}={ms}ms", region.name), + None => format!("{}=unreachable", region.name), + }) + .collect::>() + .join(" ") + ); + measured +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_https_url_gains_a_trailing_slash() { + assert_eq!( + normalize_base_url("https://prod-eu.example.net"), + Some("https://prod-eu.example.net/".to_owned()) + ); + assert_eq!( + normalize_base_url(" https://prod-eu.example.net/ "), + Some("https://prod-eu.example.net/".to_owned()) + ); + } + + #[test] + fn anything_other_than_a_plain_https_url_is_refused() { + assert_eq!(normalize_base_url("http://insecure.example.net"), None); + assert_eq!(normalize_base_url("https://"), None); + assert_eq!(normalize_base_url("https://user@evil.example.net"), None); + assert_eq!(normalize_base_url("https://host.example\n.net"), None); + assert_eq!(normalize_base_url("not a url"), None); + assert_eq!(normalize_base_url(""), None); + } + + #[test] + fn host_and_port_defaults_to_443() { + assert_eq!( + host_and_port("https://prod-eu.example.net/"), + Some(("prod-eu.example.net".to_owned(), 443)) + ); + assert_eq!( + host_and_port("https://prod-eu.example.net:8443/v2/"), + Some(("prod-eu.example.net".to_owned(), 8443)) + ); + } + + #[test] + fn config_blobs_are_not_mistaken_for_zones() { + let body = r#"{"metaData":[ + {"key":"gfn-regions","value":"https://config.example.net/"}, + {"key":"EU Central","value":"https://prod-eu.example.net"}, + {"key":"US West","value":"not-a-url"}, + {"key":"","value":"https://nameless.example.net"} + ]}"#; + let payload: ServerInfoResponse = serde_json::from_str(body).expect("body should decode"); + let names: Vec = payload + .meta_data + .into_iter() + .filter(|entry| { + let name = entry.key.trim(); + !name.is_empty() && !name.starts_with("gfn-") + }) + .filter(|entry| normalize_base_url(&entry.value).is_some()) + .map(|entry| entry.key) + .collect(); + assert_eq!(names, vec!["EU Central".to_owned()]); + } +} diff --git a/src/gfn/rtp.rs b/src/gfn/rtp.rs index 513988a..1901977 100644 --- a/src/gfn/rtp.rs +++ b/src/gfn/rtp.rs @@ -1,13 +1,3 @@ -//! H.264 RTP reassembly with loss-aware recovery, ported from green-vita's -//! `api/streaming/rtc/rtp.rs` (MPL-2.0, https://github.com/Day-OS/green-vita). See -//! THIRD_PARTY_NOTICES.md. -//! -//! Unlike the naive "extend the access unit as packets arrive, flush on the marker bit" -//! approach, this buffers every packet of a frame (keyed by RTP timestamp) and only -//! depacketizes once the whole run is present *and* sequence-contiguous starting from the -//! packet right after the previous frame's marker. That catches reordering, not just gaps - -//! and gaps that do slip through still get flagged as damage so a resync can be forced before -//! AVCDEC spends cycles decoding data it has no valid reference for. use crate::streaming::video::VideoDecodeWorker; use h264_reader::annexb::AnnexBReader; @@ -22,6 +12,8 @@ const MAX_H264_ACCESS_UNIT_BYTES: usize = 2 * 1024 * 1024; const VIDEO_RTP_CLOCK_RATE: u32 = 90_000; const LOW_FPS_DAMAGE_LIMIT: u8 = 8; const HIGH_FPS_DAMAGE_LIMIT: u8 = 3; +const MIN_REORDER_GRACE_US: u64 = 2_000; +const MAX_REORDER_GRACE_US: u64 = 12_000; #[derive(Default)] pub struct VideoSampleStats { @@ -29,6 +21,23 @@ pub struct VideoSampleStats { pub source_frame_duration_us: Option, pub encoded_resolution: Option<(u32, u32)>, pub jitter_ms: f32, + pub reorder_rescued: u32, + pub reorder_expired: u32, +} + +pub(crate) trait VideoAuSink { + fn submit_access_unit(&self, data: Vec, source_frame_duration_us: Option) -> bool; + fn begin_resync(&self); +} + +impl VideoAuSink for VideoDecodeWorker { + fn submit_access_unit(&self, data: Vec, source_frame_duration_us: Option) -> bool { + VideoDecodeWorker::submit_access_unit(self, data, source_frame_duration_us) + } + + fn begin_resync(&self) { + VideoDecodeWorker::begin_resync(self); + } } // jitter estimator, rfc 3550 formula @@ -50,9 +59,12 @@ impl Default for JitterEstimator { impl JitterEstimator { pub fn update(&mut self, arrival_us: u64, rtp_timestamp: u32) -> f32 { - if let (Some(last_arrival), Some(last_rtp)) = (self.last_arrival_us, self.last_rtp_timestamp) { + if let (Some(last_arrival), Some(last_rtp)) = + (self.last_arrival_us, self.last_rtp_timestamp) + { let arrival_diff_us = arrival_us.saturating_sub(last_arrival) as f64; - let rtp_diff_us = (rtp_timestamp.wrapping_sub(last_rtp) as f64 * 1_000_000.0) / u64::from(VIDEO_RTP_CLOCK_RATE) as f64; + let rtp_diff_us = (rtp_timestamp.wrapping_sub(last_rtp) as f64 * 1_000_000.0) + / f64::from(VIDEO_RTP_CLOCK_RATE); let transit_diff_us = (arrival_diff_us - rtp_diff_us).abs(); // RFC 3550 EWMA smoother: J = J + (|D| - J) / 16 self.jitter_us += (transit_diff_us - self.jitter_us) / 16.0; @@ -67,9 +79,21 @@ impl JitterEstimator { } } +pub(crate) fn reorder_grace_us_from_jitter(jitter_ms: f32) -> u64 { + let twice_jitter_us = (jitter_ms.max(0.0) * 2.0 * 1000.0) as u64; + twice_jitter_us.clamp(MIN_REORDER_GRACE_US, MAX_REORDER_GRACE_US) +} + pub struct VideoRtp { depacketizer: H264Packet, pending: Option, + reorder_hold: Option, + reorder_hold_started_at_us: u64, + reorder_hold_grace_us: u64, + reorder_hold_expected_sequence: Option, + parked_au: Option, + assemble_order: Vec, + assemble_buf: Vec, next_sequence: Option, last_frame_timestamp: Option, source_frame_duration_us: Option, @@ -86,6 +110,12 @@ struct PendingVideoFrame { packets: Vec, } +struct AssembledVideoFrame { + data: Vec, + timestamp: u32, + marker_sequence: u16, +} + enum FrameAssembly { Pending, Complete { data: Vec, marker_sequence: u16 }, @@ -121,44 +151,67 @@ impl PendingVideoFrame { &self, depacketizer: &mut H264Packet, expected_sequence: Option, + order: &mut Vec, + buf: &mut Vec, ) -> FrameAssembly { let Some(marker_sequence) = self.marker_sequence() else { return FrameAssembly::Pending; }; - let mut packets = self.packets.iter().collect::>(); - packets.sort_unstable_by_key(|packet| { - std::cmp::Reverse(marker_sequence.wrapping_sub(packet.header.sequence_number)) - }); - let Some(first) = packets.first() else { + + order.clear(); + if self.packets.len() == 1 { + order.push(0); + } else { + order.extend(0..self.packets.len()); + order.sort_unstable_by_key(|&index| { + std::cmp::Reverse( + marker_sequence.wrapping_sub(self.packets[index].header.sequence_number), + ) + }); + } + + let Some(&first_index) = order.first() else { return FrameAssembly::Pending; }; + let first = &self.packets[first_index]; if expected_sequence.is_some_and(|expected| first.header.sequence_number != expected) || !depacketizer.is_partition_head(&first.payload) { return FrameAssembly::Pending; } - if packets.windows(2).any(|pair| { - pair[1].header.sequence_number != pair[0].header.sequence_number.wrapping_add(1) + if order.windows(2).any(|pair| { + self.packets[pair[1]].header.sequence_number + != self.packets[pair[0]] + .header + .sequence_number + .wrapping_add(1) }) { return FrameAssembly::Pending; } *depacketizer = H264Packet::default(); - let mut data = Vec::new(); - for packet in packets { - let Ok(nalu) = depacketizer.depacketize(&packet.payload) else { + let payload_bytes: usize = order + .iter() + .map(|&index| self.packets[index].payload.len()) + .sum(); + buf.clear(); + buf.reserve(payload_bytes); + for &index in order.iter() { + let Ok(nalu) = depacketizer.depacketize(&self.packets[index].payload) else { *depacketizer = H264Packet::default(); + buf.clear(); return FrameAssembly::Invalid; }; - data.extend_from_slice(&nalu); - if data.len() > MAX_H264_ACCESS_UNIT_BYTES { + buf.extend_from_slice(&nalu); + if buf.len() > MAX_H264_ACCESS_UNIT_BYTES { *depacketizer = H264Packet::default(); + buf.clear(); return FrameAssembly::Invalid; } } *depacketizer = H264Packet::default(); FrameAssembly::Complete { - data, + data: std::mem::take(buf), marker_sequence, } } @@ -169,6 +222,13 @@ impl VideoRtp { Self { depacketizer: H264Packet::default(), pending: None, + reorder_hold: None, + reorder_hold_started_at_us: 0, + reorder_hold_grace_us: MIN_REORDER_GRACE_US, + reorder_hold_expected_sequence: None, + parked_au: None, + assemble_order: Vec::new(), + assemble_buf: Vec::new(), next_sequence: None, last_frame_timestamp: None, source_frame_duration_us: None, @@ -189,16 +249,33 @@ impl VideoRtp { self.jitter_estimator.current_jitter_ms() } + pub fn reorder_deadline_us(&self) -> Option { + self.reorder_hold + .as_ref() + .map(|_| self.reorder_hold_started_at_us.saturating_add(self.reorder_hold_grace_us)) + } + pub fn receive( &mut self, worker: &VideoDecodeWorker, packet: Packet, keyframe_requested: &mut bool, arrival_us: u64, + ) -> VideoSampleStats { + self.receive_into(worker, packet, keyframe_requested, arrival_us) + } + + pub(crate) fn receive_into( + &mut self, + sink: &S, + packet: Packet, + keyframe_requested: &mut bool, + arrival_us: u64, ) -> VideoSampleStats { let mut stats = VideoSampleStats::default(); stats.jitter_ms = self.jitter_estimator.update(arrival_us, packet.header.timestamp); - let mut frame_was_damaged = false; + self.expire_reorder_grace(sink, keyframe_requested, arrival_us, &mut stats); + if packet.payload.is_empty() { if self.next_sequence == Some(packet.header.sequence_number) { self.next_sequence = Some(packet.header.sequence_number.wrapping_add(1)); @@ -207,6 +284,41 @@ impl VideoRtp { } let packet_timestamp = packet.header.timestamp; + + if self + .reorder_hold + .as_ref() + .is_some_and(|hold| hold.timestamp == packet_timestamp) + { + if let Some(hold) = &mut self.reorder_hold { + hold.insert(packet); + } + self.try_complete_reorder_hold(sink, keyframe_requested, &mut stats); + return stats; + } + + let belongs_to_pending = self + .pending + .as_ref() + .is_some_and(|pending| pending.timestamp == packet_timestamp); + if !belongs_to_pending && (self.reorder_hold.is_some() || self.parked_au.is_some()) { + let reference_ts = self + .pending + .as_ref() + .map(|pending| pending.timestamp) + .or_else(|| self.parked_au.as_ref().map(|ready| ready.timestamp)) + .or_else(|| self.reorder_hold.as_ref().map(|hold| hold.timestamp)); + if reference_ts.is_some_and(|ts| !timestamp_is_newer(packet_timestamp, ts)) { + return stats; + } + if self.reorder_hold.is_some() { + self.reorder_hold_started_at_us = arrival_us.saturating_sub(self.reorder_hold_grace_us); + self.expire_reorder_grace(sink, keyframe_requested, arrival_us, &mut stats); + } else if let Some(ready) = self.parked_au.take() { + self.process_assembled(ready, sink, keyframe_requested, &mut stats); + } + } + if let Some(pending) = &self.pending && pending.timestamp != packet_timestamp { @@ -214,16 +326,15 @@ impl VideoRtp { return stats; } if let Some(incomplete) = self.pending.take() { - self.next_sequence = incomplete - .marker_sequence() - .map(|sequence| sequence.wrapping_add(1)); - self.depacketizer = H264Packet::default(); - *keyframe_requested = true; - self.record_damage(worker); - frame_was_damaged = true; - stats.dropped = stats.dropped.saturating_add(1); + let grace = reorder_grace_us_from_jitter(self.jitter_estimator.current_jitter_ms()); + self.reorder_hold_expected_sequence = self.next_sequence; + self.reorder_hold_grace_us = grace; + self.reorder_hold_started_at_us = arrival_us; + self.next_sequence = Some(packet.header.sequence_number); + self.reorder_hold = Some(incomplete); } } + if self.pending.is_none() { if self .last_frame_timestamp @@ -236,20 +347,24 @@ impl VideoRtp { pending.insert(packet); } - let assembly = self - .pending - .as_ref() - .map(|pending| pending.assemble(&mut self.depacketizer, self.next_sequence)); - let Some(assembly) = assembly else { + let Some(pending) = self.pending.take() else { return stats; }; + let assembly = pending.assemble( + &mut self.depacketizer, + self.next_sequence, + &mut self.assemble_order, + &mut self.assemble_buf, + ); let (data, marker_sequence) = match assembly { - FrameAssembly::Pending => return stats, + FrameAssembly::Pending => { + self.pending = Some(pending); + return stats; + } FrameAssembly::Invalid => { - self.pending = None; self.next_sequence = None; *keyframe_requested = true; - self.record_damage(worker); + self.record_damage(sink); stats.dropped = stats.dropped.saturating_add(1); return stats; } @@ -258,10 +373,126 @@ impl VideoRtp { marker_sequence, } => (data, marker_sequence), }; - let completed = self.pending.take().expect("assembled pending video frame"); + let completed = pending; + + if self.reorder_hold.is_some() { + self.parked_au = Some(AssembledVideoFrame { + data, + timestamp: completed.timestamp, + marker_sequence, + }); + return stats; + } + + self.process_assembled( + AssembledVideoFrame { + data, + timestamp: completed.timestamp, + marker_sequence, + }, + sink, + keyframe_requested, + &mut stats, + ); + stats + } + + pub fn expire_reorder_grace_if_due( + &mut self, + sink: &S, + keyframe_requested: &mut bool, + now_us: u64, + ) -> VideoSampleStats { + let mut stats = VideoSampleStats::default(); + self.expire_reorder_grace(sink, keyframe_requested, now_us, &mut stats); + stats + } + + fn expire_reorder_grace( + &mut self, + sink: &S, + keyframe_requested: &mut bool, + now_us: u64, + stats: &mut VideoSampleStats, + ) { + if self.reorder_hold.is_none() + || now_us.saturating_sub(self.reorder_hold_started_at_us) < self.reorder_hold_grace_us + { + return; + } + let Some(_hold) = self.reorder_hold.take() else { + return; + }; + stats.reorder_expired = stats.reorder_expired.saturating_add(1); + stats.dropped = stats.dropped.saturating_add(1); + *keyframe_requested = true; + self.record_damage(sink); + self.reorder_hold_started_at_us = 0; + self.reorder_hold_expected_sequence = None; + self.depacketizer = H264Packet::default(); + if let Some(ready) = self.parked_au.take() { + self.process_assembled(ready, sink, keyframe_requested, stats); + } + } + + fn try_complete_reorder_hold( + &mut self, + sink: &S, + keyframe_requested: &mut bool, + stats: &mut VideoSampleStats, + ) { + let Some(hold) = self.reorder_hold.take() else { + return; + }; + let assembly = hold.assemble( + &mut self.depacketizer, + self.reorder_hold_expected_sequence, + &mut self.assemble_order, + &mut self.assemble_buf, + ); + let FrameAssembly::Complete { + data, + marker_sequence, + } = assembly + else { + self.reorder_hold = Some(hold); + return; + }; + let completed = hold; + self.reorder_hold_started_at_us = 0; + self.reorder_hold_expected_sequence = None; + self.depacketizer = H264Packet::default(); + stats.reorder_rescued = stats.reorder_rescued.saturating_add(1); + self.process_assembled( + AssembledVideoFrame { + data, + timestamp: completed.timestamp, + marker_sequence, + }, + sink, + keyframe_requested, + stats, + ); + if let Some(ready) = self.parked_au.take() { + self.process_assembled(ready, sink, keyframe_requested, stats); + } + } + + fn process_assembled( + &mut self, + assembled: AssembledVideoFrame, + sink: &S, + keyframe_requested: &mut bool, + stats: &mut VideoSampleStats, + ) { + let AssembledVideoFrame { + data, + timestamp, + marker_sequence, + } = assembled; self.next_sequence = Some(marker_sequence.wrapping_add(1)); stats.source_frame_duration_us = self.last_frame_timestamp.map(|previous| { - u64::from(completed.timestamp.wrapping_sub(previous)) * 1_000_000 + u64::from(timestamp.wrapping_sub(previous)) * 1_000_000 / u64::from(VIDEO_RTP_CLOCK_RATE) }); if let Some(duration) = stats.source_frame_duration_us { @@ -271,7 +502,7 @@ impl VideoRtp { .unwrap_or(duration), ); } - self.last_frame_timestamp = Some(completed.timestamp); + self.last_frame_timestamp = Some(timestamp); let unit = inspect_h264_access_unit(&data); stats.encoded_resolution = unit.resolution; @@ -286,18 +517,18 @@ impl VideoRtp { self.stream_too_large = true; *keyframe_requested = true; if !self.waiting_for_keyframe { - worker.begin_resync(); + sink.begin_resync(); } self.waiting_for_keyframe = true; stats.dropped = stats.dropped.saturating_add(1); - return stats; + return; } if self.stream_too_large { if unit.resolution.is_none() || !unit.has_idr { *keyframe_requested = true; self.waiting_for_keyframe = true; stats.dropped = stats.dropped.saturating_add(1); - return stats; + return; } self.stream_too_large = false; } @@ -305,39 +536,20 @@ impl VideoRtp { if !unit.has_idr { *keyframe_requested = true; stats.dropped = stats.dropped.saturating_add(1); - return stats; + return; } self.waiting_for_keyframe = false; self.damage_score = 0; - } else if !frame_was_damaged { + } else { self.damage_score = self.damage_score.saturating_sub(1); } - // A damaged access unit is missing macroblocks the decoder cannot reconstruct. Handing it - // over anyway does not just corrupt this frame: every following P-frame predicts from it, - // so a smear in one region outlives the packet loss by seconds and stays anchored to - // whatever was moving there. Holding the previous frame instead keeps the damage from - // entering the reference chain at all - the visible cost is one stale frame rather than a - // patch of the picture that stops updating. - // Note this deliberately does *not* ask for a keyframe. Doing so per damaged frame turned - // a steady trickle of loss into a keyframe storm: an IDR costs several times a P-frame, - // so on a link already dropping packets the repair traffic crowds out the content and - // causes the next loss. `record_damage` above owns that escalation and only spends a - // keyframe once damage has actually accumulated past its fps-scaled threshold. - if frame_was_damaged { + if !sink.submit_access_unit(data, self.source_frame_duration_us) { stats.dropped = stats.dropped.saturating_add(1); - return stats; } - - if !worker.submit_access_unit(data) { - eprintln!("Video decoder queue is full; continuing while requesting a keyframe"); - *keyframe_requested = true; - stats.dropped = stats.dropped.saturating_add(1); - } - stats } - fn record_damage(&mut self, worker: &VideoDecodeWorker) { + fn record_damage(&mut self, sink: &S) { if self.waiting_for_keyframe { return; } @@ -362,7 +574,7 @@ impl VideoRtp { return; } - worker.begin_resync(); + sink.begin_resync(); self.waiting_for_keyframe = true; self.damage_score = 0; } @@ -407,3 +619,151 @@ fn inspect_h264_access_unit(data: &[u8]) -> AccessUnitInfo { reader.reset(); info } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use std::cell::RefCell; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct FakeSink { + submitted: RefCell>>, + resyncs: AtomicU64, + accept: RefCell, + } + + impl FakeSink { + fn new() -> Self { + Self { + submitted: RefCell::new(Vec::new()), + resyncs: AtomicU64::new(0), + accept: RefCell::new(true), + } + } + } + + impl VideoAuSink for FakeSink { + fn submit_access_unit( + &self, + data: Vec, + _source_frame_duration_us: Option, + ) -> bool { + if !*self.accept.borrow() { + return false; + } + self.submitted.borrow_mut().push(data); + true + } + + fn begin_resync(&self) { + self.resyncs.fetch_add(1, Ordering::Relaxed); + } + } + + fn single_nal_payload() -> Bytes { + Bytes::from_static(&[0x41, 0x9a, 0x00]) + } + + fn pkt(seq: u16, ts: u32, marker: bool) -> Packet { + Packet { + header: rtc::rtp::Header { + version: 2, + padding: false, + extension: false, + marker, + payload_type: 96, + sequence_number: seq, + timestamp: ts, + ssrc: 1, + csrc: Vec::new(), + extension_profile: 0, + extensions: Vec::new(), + extensions_padding: 0, + }, + payload: single_nal_payload(), + } + } + + #[test] + fn reorder_grace_clamps_to_2_12_ms() { + assert_eq!(reorder_grace_us_from_jitter(0.0), MIN_REORDER_GRACE_US); + assert_eq!(reorder_grace_us_from_jitter(0.5), MIN_REORDER_GRACE_US); + assert_eq!(reorder_grace_us_from_jitter(3.0), 6_000); + assert_eq!(reorder_grace_us_from_jitter(100.0), MAX_REORDER_GRACE_US); + } + + #[test] + fn reorder_hold_rescues_late_packet_before_deadline() { + let sink = FakeSink::new(); + let mut rtp = VideoRtp::new(960, 544); + let mut pli = false; + + rtp.receive_into(&sink, pkt(1, 90000, false), &mut pli, 1_000); + rtp.receive_into(&sink, pkt(3, 180000, true), &mut pli, 1_500); + assert!(rtp.reorder_hold.is_some()); + assert!(rtp.reorder_deadline_us().is_some()); + assert_eq!(sink.submitted.borrow().len(), 0); + + let stats = rtp.receive_into(&sink, pkt(2, 90000, true), &mut pli, 2_000); + assert_eq!(stats.reorder_rescued, 1); + assert!(rtp.reorder_hold.is_none()); + assert_eq!(sink.submitted.borrow().len(), 2); + } + + #[test] + fn reorder_hold_expires_after_grace_and_flushes_ready() { + let sink = FakeSink::new(); + let mut rtp = VideoRtp::new(960, 544); + let mut pli = false; + + rtp.receive_into(&sink, pkt(1, 90000, false), &mut pli, 1_000); + rtp.receive_into(&sink, pkt(3, 180000, true), &mut pli, 1_200); + assert!(rtp.parked_au.is_some()); + + let deadline = rtp.reorder_deadline_us().expect("reorder hold active"); + let stats = rtp.expire_reorder_grace_if_due(&sink, &mut pli, deadline); + assert_eq!(stats.reorder_expired, 1); + assert!(rtp.reorder_hold.is_none()); + assert_eq!(sink.submitted.borrow().len(), 1); + assert!(pli); + } + + #[test] + fn third_timestamp_force_expires_before_parked_overwrite() { + let sink = FakeSink::new(); + let mut rtp = VideoRtp::new(960, 544); + let mut pli = false; + + rtp.receive_into(&sink, pkt(1, 90000, false), &mut pli, 1_000); + rtp.receive_into(&sink, pkt(3, 180000, true), &mut pli, 1_200); + assert!(rtp.reorder_hold.is_some()); + assert!(rtp.parked_au.is_some()); + assert!(rtp.pending.is_none()); + let parked_ts_before = rtp.parked_au.as_ref().unwrap().timestamp; + assert_eq!(parked_ts_before, 180000); + + rtp.receive_into(&sink, pkt(5, 270000, false), &mut pli, 1_400); + assert!( + sink.submitted.borrow().iter().any(|au| !au.is_empty()), + "parked AU B must be flushed on third-TS force expire" + ); + assert!( + rtp.parked_au + .as_ref() + .is_none_or(|parked| parked.timestamp != parked_ts_before), + "parked B must not remain after third timestamp" + ); + } + + #[test] + fn queue_full_does_not_request_keyframe() { + let sink = FakeSink::new(); + *sink.accept.borrow_mut() = false; + let mut rtp = VideoRtp::new(960, 544); + let mut pli = false; + let stats = rtp.receive_into(&sink, pkt(1, 90000, true), &mut pli, 1_000); + assert_eq!(stats.dropped, 1); + assert!(!pli); + } +} diff --git a/src/gfn/stream_prefs.rs b/src/gfn/stream_prefs.rs index 07a21df..89d1607 100644 --- a/src/gfn/stream_prefs.rs +++ b/src/gfn/stream_prefs.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Mutex; const STORE_DIR: &str = "ux0:data/opennow-vita"; @@ -25,6 +24,85 @@ pub struct AppSettings { pub rear_touch_mode: String, #[serde(default = "default_catalog_filter")] pub catalog_filter: String, + #[serde(default = "default_true")] + pub session_timer_enabled: bool, + #[serde(default)] + pub region: String, + #[serde(default)] + pub game_language: String, + #[serde(default = "default_color_depth")] + pub color_depth: String, + #[serde(default)] + pub game_profiles: std::collections::BTreeMap, + #[serde(default)] + pub trigger_swap_enabled: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GameProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rear_touch_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stick_zones: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_intensity: Option, +} + +static ACTIVE_GAME: Mutex> = Mutex::new(None); + +pub fn set_active_game(app_id: Option<&str>) { + if let Ok(mut guard) = ACTIVE_GAME.lock() { + let next = app_id.map(str::to_owned); + if *guard != next { + *guard = next; + } + } +} + +pub fn active_game() -> Option { + ACTIVE_GAME.lock().ok().and_then(|guard| guard.clone()) +} + +fn active_profile() -> Option { + let app_id = active_game()?; + with_cached_settings(|s| s.game_profiles.get(&app_id).cloned()) +} + +pub fn active_game_has_profile() -> bool { + let Some(app_id) = active_game() else { + return false; + }; + with_cached_settings(|s| s.game_profiles.contains_key(&app_id)) +} + +pub fn set_active_game_profile(enabled: bool) { + let Some(app_id) = active_game() else { + return; + }; + update_settings(|s| { + if enabled { + let seed = GameProfile { + rear_touch_mode: Some(s.rear_touch_mode.clone()), + stick_zones: Some(s.stick_zones.clone()), + trigger_intensity: Some(s.trigger_intensity), + }; + s.game_profiles.entry(app_id).or_insert(seed); + } else { + s.game_profiles.remove(&app_id); + } + }); +} + +fn update_control_setting(apply: F) +where + F: FnOnce(&mut AppSettings, Option<&str>), +{ + let app_id = active_game().filter(|_| active_game_has_profile()); + update_settings(|s| apply(s, app_id.as_deref())); +} + +fn default_true() -> bool { + true } fn default_catalog_sort() -> String { @@ -39,6 +117,10 @@ fn default_rear_touch_mode() -> String { "quadrant".to_owned() } +fn default_color_depth() -> String { + ColorDepth::default().key().to_owned() +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -50,27 +132,45 @@ impl Default for AppSettings { catalog_sort: "last_played".to_owned(), rear_touch_mode: "quadrant".to_owned(), catalog_filter: "my_games".to_owned(), + session_timer_enabled: true, + region: String::new(), + game_language: GameLanguage::default().code().to_owned(), + color_depth: ColorDepth::default().key().to_owned(), + game_profiles: std::collections::BTreeMap::new(), + trigger_swap_enabled: false, } } } static CACHED_SETTINGS: Mutex> = Mutex::new(None); -fn load_or_init_settings() -> AppSettings { - let mut guard = CACHED_SETTINGS.lock().unwrap(); - if let Some(ref settings) = *guard { - return settings.clone(); +fn with_cached_settings(f: impl FnOnce(&AppSettings) -> R) -> R { + { + let guard = CACHED_SETTINGS.lock().unwrap(); + if let Some(ref settings) = *guard { + return f(settings); + } } + let _ = load_or_init_settings(); + let guard = CACHED_SETTINGS.lock().unwrap(); + f(guard.as_ref().expect("settings cache populated")) +} - // Try reading settings.json +/// Disk load / legacy migration. Must not touch `CACHED_SETTINGS` — callers that already hold +/// that lock (notably `update_settings`) would otherwise self-deadlock on `std::sync::Mutex`. +fn read_or_migrate_settings() -> AppSettings { if let Ok(content) = std::fs::read_to_string(SETTINGS_JSON_PATH) { - if let Ok(settings) = serde_json::from_str::(&content) { - *guard = Some(settings.clone()); - return settings; + match serde_json::from_str::(&content) { + Ok(settings) => return settings, + Err(_) => { + eprintln!("settings.json corrupt; recreating with stable defaults"); + let settings = AppSettings::default(); + save_settings_disk(&settings); + return settings; + } } } - // One-time migration from legacy .txt files if settings.json does not exist let mut settings = AppSettings::default(); if let Ok(text) = std::fs::read_to_string(FPS_STORE_PATH) { @@ -100,26 +200,93 @@ fn load_or_init_settings() -> AppSettings { let _ = std::fs::remove_file(STICK_ZONES_STORE_PATH); } - // Persist new settings.json save_settings_disk(&settings); + settings +} + +fn load_or_init_settings() -> AppSettings { + { + let guard = CACHED_SETTINGS.lock().unwrap(); + if let Some(ref settings) = *guard { + return settings.clone(); + } + } + + let settings = read_or_migrate_settings(); + let mut guard = CACHED_SETTINGS.lock().unwrap(); + if let Some(ref cached) = *guard { + return cached.clone(); + } *guard = Some(settings.clone()); settings } fn save_settings_disk(settings: &AppSettings) { - if std::fs::create_dir_all(STORE_DIR).is_ok() { - if let Ok(json) = serde_json::to_string_pretty(settings) { - let _ = std::fs::write(SETTINGS_JSON_PATH, json); + if std::fs::create_dir_all(STORE_DIR).is_err() { + return; + } + let Ok(json) = serde_json::to_string_pretty(settings) else { + return; + }; + let tmp_path = format!("{SETTINGS_JSON_PATH}.tmp"); + if std::fs::write(&tmp_path, &json).is_ok() { + if std::fs::rename(&tmp_path, SETTINGS_JSON_PATH).is_ok() { + return; } + let _ = std::fs::remove_file(&tmp_path); } + let _ = std::fs::write(SETTINGS_JSON_PATH, json); } fn update_settings(f: F) { - let mut guard = CACHED_SETTINGS.lock().unwrap(); - let mut settings = guard.clone().unwrap_or_else(load_or_init_settings); + let mut settings = { + let guard = CACHED_SETTINGS.lock().unwrap(); + guard.clone() + } + .unwrap_or_else(read_or_migrate_settings); f(&mut settings); save_settings_disk(&settings); - *guard = Some(settings); + *CACHED_SETTINGS.lock().unwrap() = Some(settings); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorDepth { + ThirtyTwoBit, + #[default] + SixteenBit, +} + +impl ColorDepth { + pub const ALL: [ColorDepth; 2] = [Self::ThirtyTwoBit, Self::SixteenBit]; + + fn key(self) -> &'static str { + match self { + Self::ThirtyTwoBit => "32", + Self::SixteenBit => "16", + } + } + + pub fn label_key(self) -> &'static str { + match self { + Self::ThirtyTwoBit => "settings-color-depth-32", + Self::SixteenBit => "settings-color-depth-16", + } + } + + fn from_key(key: &str) -> Self { + match key { + "16" => Self::SixteenBit, + _ => Self::ThirtyTwoBit, + } + } +} + +pub fn color_depth() -> ColorDepth { + with_cached_settings(|s| ColorDepth::from_key(&s.color_depth)) +} + +pub fn set_color_depth(depth: ColorDepth) { + update_settings(|s| s.color_depth = depth.key().to_owned()); } /// Frame rate to request from GFN. @@ -149,8 +316,11 @@ impl StreamFps { } pub fn fps() -> StreamFps { - let s = load_or_init_settings(); - StreamFps::from_value(s.fps) + with_cached_settings(|s| StreamFps::from_value(s.fps)) +} + +pub fn fps_value() -> u32 { + fps().value() } pub fn set_fps(fps: StreamFps) { @@ -187,21 +357,31 @@ impl TriggerIntensity { } pub fn trigger_intensity() -> TriggerIntensity { - let s = load_or_init_settings(); - TriggerIntensity::from_value(s.trigger_intensity) + if let Some(value) = active_profile().and_then(|profile| profile.trigger_intensity) { + return TriggerIntensity::from_value(value); + } + with_cached_settings(|s| TriggerIntensity::from_value(s.trigger_intensity)) } pub fn set_trigger_intensity(intensity: TriggerIntensity) { - update_settings(|s| s.trigger_intensity = intensity.value()); + update_control_setting(|s, app_id| match app_id.and_then(|id| s.game_profiles.get_mut(id)) { + Some(profile) => profile.trigger_intensity = Some(intensity.value()), + None => s.trigger_intensity = intensity.value(), + }); } pub fn stick_zones() -> StickZones { - let s = load_or_init_settings(); - StickZones::from_text(&s.stick_zones) + if let Some(text) = active_profile().and_then(|profile| profile.stick_zones) { + return StickZones::from_text(&text); + } + with_cached_settings(|s| StickZones::from_text(&s.stick_zones)) } pub fn set_stick_zones(zones: StickZones) { - update_settings(|s| s.stick_zones = zones.as_text().to_owned()); + update_control_setting(|s, app_id| match app_id.and_then(|id| s.game_profiles.get_mut(id)) { + Some(profile) => profile.stick_zones = Some(zones.as_text().to_owned()), + None => s.stick_zones = zones.as_text().to_owned(), + }); } /// How much the decoded stream is amplified, in percent of unity gain. @@ -360,10 +540,156 @@ impl RearTouchMode { } pub fn rear_touch_mode() -> RearTouchMode { + if let Some(text) = active_profile().and_then(|profile| profile.rear_touch_mode) { + return RearTouchMode::from_text(&text); + } let s = load_or_init_settings(); RearTouchMode::from_text(&s.rear_touch_mode) } pub fn set_rear_touch_mode(mode: RearTouchMode) { - update_settings(|s| s.rear_touch_mode = mode.as_text().to_owned()); + update_control_setting(|s, app_id| match app_id.and_then(|id| s.game_profiles.get_mut(id)) { + Some(profile) => profile.rear_touch_mode = Some(mode.as_text().to_owned()), + None => s.rear_touch_mode = mode.as_text().to_owned(), + }); +} + +pub fn region() -> String { + let s = load_or_init_settings(); + crate::gfn::regions::normalize_base_url(&s.region).unwrap_or_default() +} + +pub fn set_region(base_url: &str) { + let normalized = crate::gfn::regions::normalize_base_url(base_url).unwrap_or_default(); + update_settings(|s| s.region = normalized); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GameLanguage { + #[default] + EnUs, + EnGb, + EsEs, + EsMx, + PtBr, + FrFr, + DeDe, + ItIt, + NlNl, + PlPl, + CsCz, + HuHu, + RuRu, + UkUa, + TrTr, + SvSe, + NbNo, + DaDk, + FiFi, + JaJp, + KoKr, + ZhCn, + ZhTw, + ThTh, +} + +impl GameLanguage { + pub const ALL: [GameLanguage; 24] = [ + Self::EnUs, + Self::EnGb, + Self::EsEs, + Self::EsMx, + Self::PtBr, + Self::FrFr, + Self::DeDe, + Self::ItIt, + Self::NlNl, + Self::PlPl, + Self::CsCz, + Self::HuHu, + Self::RuRu, + Self::UkUa, + Self::TrTr, + Self::SvSe, + Self::NbNo, + Self::DaDk, + Self::FiFi, + Self::JaJp, + Self::KoKr, + Self::ZhCn, + Self::ZhTw, + Self::ThTh, + ]; + + fn info(self) -> (&'static str, &'static str) { + match self { + Self::EnUs => ("en_US", "English (US)"), + Self::EnGb => ("en_GB", "English (UK)"), + Self::EsEs => ("es_ES", "Español (España)"), + Self::EsMx => ("es_MX", "Español (México)"), + Self::PtBr => ("pt_BR", "Português (Brasil)"), + Self::FrFr => ("fr_FR", "Français"), + Self::DeDe => ("de_DE", "Deutsch"), + Self::ItIt => ("it_IT", "Italiano"), + Self::NlNl => ("nl_NL", "Nederlands"), + Self::PlPl => ("pl_PL", "Polski"), + Self::CsCz => ("cs_CZ", "Čeština"), + Self::HuHu => ("hu_HU", "Magyar"), + Self::RuRu => ("ru_RU", "Russian (RU)"), + Self::UkUa => ("uk_UA", "Ukrainian (UA)"), + Self::TrTr => ("tr_TR", "Türkçe"), + Self::SvSe => ("sv_SE", "Svenska"), + Self::NbNo => ("nb_NO", "Norsk"), + Self::DaDk => ("da_DK", "Dansk"), + Self::FiFi => ("fi_FI", "Suomi"), + Self::JaJp => ("ja_JP", "日本語"), + Self::KoKr => ("ko_KR", "한국어"), + Self::ZhCn => ("zh_CN", "简体中文"), + Self::ZhTw => ("zh_TW", "繁體中文"), + Self::ThTh => ("th_TH", "Thai (TH)"), + } + } + + pub fn code(self) -> &'static str { + self.info().0 + } + + pub fn label(self) -> &'static str { + self.info().1 + } + + fn from_code(code: &str) -> Self { + let code = code.trim(); + Self::ALL + .into_iter() + .find(|candidate| candidate.code() == code) + .unwrap_or_default() + } +} + +pub fn game_language() -> GameLanguage { + let s = load_or_init_settings(); + GameLanguage::from_code(&s.game_language) +} + +pub fn set_game_language(language: GameLanguage) { + update_settings(|s| s.game_language = language.code().to_owned()); +} + +pub fn session_timer_enabled() -> bool { + let s = load_or_init_settings(); + s.session_timer_enabled +} + +pub fn set_session_timer_enabled(enabled: bool) { + update_settings(|s| s.session_timer_enabled = enabled); +} + +pub fn trigger_swap_enabled() -> bool { + let s = load_or_init_settings(); + s.trigger_swap_enabled +} + +pub fn set_trigger_swap_enabled(enabled: bool) { + update_settings(|s| s.trigger_swap_enabled = enabled); } diff --git a/src/i18n.rs b/src/i18n.rs index 81cf7ef..5546375 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -4,6 +4,7 @@ use crate::locale::Locale; use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue}; use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use unic_langid::LanguageIdentifier; type Bundle = FluentBundle; @@ -17,26 +18,43 @@ impl I18n { Self { locale } } + pub fn locale(&self) -> Locale { + self.locale + } + /// Resolves `id` in the current locale, falling back to `en-US`, then to `id` itself. - pub fn text(&self, id: &'static str) -> String { + pub fn text(&self, id: &'static str) -> Rc { thread_local! { - static CACHE: RefCell> = + static CACHE: RefCell>> = RefCell::new(HashMap::new()); } CACHE.with(|cell| { if let Some(cached) = cell.borrow().get(&(self.locale, id)) { - return cached.clone(); + return Rc::clone(cached); } - let resolved = self.text_with_args(id, None); + let resolved: Rc = self.text_with_args(id, None).into(); cell.borrow_mut() - .insert((self.locale, id), resolved.clone()); + .insert((self.locale, id), Rc::clone(&resolved)); resolved }) } /// Like [`I18n::text`], with Fluent arguments interpolated into the message. - pub fn text_with<'a>(&self, id: &'static str, args: FluentArgs<'a>) -> String { - self.text_with_args(id, Some(&args)) + pub fn text_with<'a>(&self, id: &'static str, args: FluentArgs<'a>) -> Rc { + let fingerprint = args_fingerprint(&args); + thread_local! { + static CACHE: RefCell>> = + RefCell::new(HashMap::new()); + } + CACHE.with(|cell| { + let key = (self.locale, id, fingerprint); + if let Some(cached) = cell.borrow().get(&key) { + return Rc::clone(cached); + } + let resolved: Rc = self.text_with_args(id, Some(&args)).into(); + cell.borrow_mut().insert(key, Rc::clone(&resolved)); + resolved + }) } fn text_with_args(&self, id: &'static str, args: Option<&FluentArgs<'_>>) -> String { @@ -51,6 +69,21 @@ pub fn arg_string(value: impl Into) -> FluentValue<'static> { FluentValue::String(value.into().into()) } +fn args_fingerprint(args: &FluentArgs<'_>) -> String { + let mut out = String::new(); + for (key, value) in args.iter() { + out.push_str(key); + out.push('='); + match value { + FluentValue::String(s) => out.push_str(s), + FluentValue::Number(n) => out.push_str(&n.as_string()), + other => out.push_str(&format!("{other:?}")), + } + out.push('\n'); + } + out +} + fn format_message( bundle: &Bundle, id: &'static str, diff --git a/src/i18n/en-US.ftl b/src/i18n/en-US.ftl index 89600a3..b50ca80 100644 --- a/src/i18n/en-US.ftl +++ b/src/i18n/en-US.ftl @@ -51,6 +51,8 @@ session-server-busy = NVIDIA's servers are busy session-server-busy-retry = Retrying... (attempt { $attempt }) session-app-patching = Updating the game session-app-patching-detail = NVIDIA is installing a game update on your cloud rig. This can take several minutes; the stream starts on its own when it finishes. +session-ad-playing = Watching an ad to keep your place in line +session-ad-progress = { $percent }% complete session-cancel-button = Cancel session session-exit-hint = Tap "Cancel session" or press (O) to confirm exit session-now-loading = Now loading @@ -111,6 +113,9 @@ settings-fps-60 = 60 fps - smoother motion settings-fps-30 = 30 fps - sharper picture settings-trigger-heading = Rear-panel L2/R2 pressure settings-audio-boost-heading = Volume boost +settings-color-depth-heading = Colour depth +settings-color-depth-32 = 32-bit +settings-color-depth-16 = 16-bit session-keyboard-show = Keyboard session-keyboard-hide = Hide keyboard key-esc = Esc @@ -138,6 +143,41 @@ settings-stick-zones-visible = On + show settings-rear-touch-mode-heading = Rear touch panel settings-rear-touch-quadrant = 4 zones (L2/R2 + L3/R3) settings-rear-touch-halves = 2 zones (L2/R2) +settings-region-heading = Server region +settings-region-auto = Automatic +settings-region-loading = Loading server locations... +settings-region-measuring = Measuring latency to each region... +settings-region-test = Test latency +settings-region-retry = Retry +settings-region-none = NVIDIA listed no server locations for this account. +settings-region-failed = Could not load server locations. Automatic still works. +settings-region-note-auto = Server region: automatic +settings-region-note-pinned = Server region: { $region } +settings-region-best = BEST +settings-tab-stream = Stream +settings-tab-controls = Controls +settings-tab-app = App +settings-tab-account = Account +settings-group-streaming = STREAMING +settings-group-controls = CONTROLS +settings-group-app = APP +settings-group-account = ACCOUNT +settings-region-desc = The GeForce NOW zone this device streams from. +settings-fps-desc = Higher is smoother; lower can look sharper on a slow link. +settings-audio-boost-desc = Amplifies the decoded stream above unity gain. +settings-color-depth-desc = 32-bit removes banding in skies and dark scenes; 16-bit is lighter on memory. Applies on the next launch. +settings-trigger-desc = How hard a rear-panel touch presses L2/R2. +settings-rear-touch-desc = How the rear panel is split between L2/R2 and L3/R3. +settings-stick-zones-desc = L3/R3, from the bottom corners of the front screen. +settings-language-desc = Language this app's own menus and messages are shown in. +settings-game-language-heading = Game language +settings-game-language-desc = Language for the game's own menus, subtitles and audio, where the game supports it. +settings-game-profile-heading = Settings for this game only +settings-game-profile-desc = Keeps this game's control settings separate from the global ones. +settings-session-timer-heading = Session timer +settings-session-timer-desc = Shows elapsed time during a stream. +settings-trigger-swap-heading = Swap L1/R1 with L2/R2 +settings-trigger-swap-desc = Sends L2/R2 when you press L1/R1 and vice versa, for games that put their main action on a trigger. controls-hint-sticks = The bottom corners of the screen are L3 and R3. error-session-busy-title = A session is already open error-session-busy-body = GeForce NOW still has a session running for this account, and it is not one this app can close. Fastest fix: open play.geforcenow.com and start a game there to take it over. Otherwise sign out of GeForce NOW on your other devices, or wait about 8 minutes for it to time out. @@ -287,3 +327,19 @@ error-gfn-certificate-rejected-title = Certificate Error error-gfn-certificate-rejected-body = Server certificate was rejected. error-gfn-unknown-title = Session could not start error-gfn-unknown-body = GeForce NOW refused the launch: { $detail } + +# Pre-launch server picker +server-picker-heading = Select server +server-picker-queue-loading = Loading queue data... +server-picker-auto-badge = BEST +server-picker-closest-badge = CLOSEST +server-picker-launch = Launch +server-picker-cancel = Cancel +server-picker-powered-by = Queue data powered by PrintedWaste +server-picker-hint = Up/Down to choose · X to launch · O to cancel + +# Power / link health +status-battery-low = Battery low ({ $percent }%) - the session will stop before it runs out. +status-battery-critical = Battery critical - session stopped so it could be released cleanly. +status-session-suspended = Session stopped because the console went to sleep. +status-bitrate-lowered = Connection struggling - lowered the ceiling to { $mbps } Mbps. diff --git a/src/i18n/es-ES.ftl b/src/i18n/es-ES.ftl index ee2664b..a28ff95 100644 --- a/src/i18n/es-ES.ftl +++ b/src/i18n/es-ES.ftl @@ -51,6 +51,8 @@ session-server-busy = Los servidores de NVIDIA están saturados session-server-busy-retry = Reintentando... (intento { $attempt }) session-app-patching = Actualizando el juego session-app-patching-detail = NVIDIA está instalando una actualización del juego en tu equipo en la nube. Puede tardar varios minutos; el stream empieza solo cuando termine. +session-ad-playing = Viendo un anuncio para mantener tu turno en la cola +session-ad-progress = { $percent }% completado session-cancel-button = Cancelar sesión session-exit-hint = Toca "Cancelar sesión" o pulsa (O) para confirmar la salida session-now-loading = Cargando @@ -111,6 +113,9 @@ settings-fps-60 = 60 fps - movimiento más fluido settings-fps-30 = 30 fps - imagen más nítida settings-trigger-heading = Presión de L2/R2 en el panel trasero settings-audio-boost-heading = Amplificacion de volumen +settings-color-depth-heading = Profundidad de color +settings-color-depth-32 = 32 bits +settings-color-depth-16 = 16 bits session-keyboard-show = Teclado session-keyboard-hide = Ocultar teclado key-esc = Esc @@ -138,6 +143,41 @@ settings-stick-zones-visible = Si + ver settings-rear-touch-mode-heading = Panel trasero settings-rear-touch-quadrant = 4 zonas (L2/R2 + L3/R3) settings-rear-touch-halves = 2 zonas (L2/R2) +settings-region-heading = Región del servidor +settings-region-auto = Automática +settings-region-loading = Cargando ubicaciones de servidor... +settings-region-measuring = Midiendo la latencia de cada región... +settings-region-test = Probar latencia +settings-region-retry = Reintentar +settings-region-none = NVIDIA no ha listado ubicaciones de servidor para esta cuenta. +settings-region-failed = No se pudieron cargar las ubicaciones. La opción automática sigue funcionando. +settings-region-note-auto = Región del servidor: automática +settings-region-note-pinned = Región del servidor: { $region } +settings-region-best = MEJOR +settings-tab-stream = Streaming +settings-tab-controls = Controles +settings-tab-app = App +settings-tab-account = Cuenta +settings-group-streaming = STREAMING +settings-group-controls = CONTROLES +settings-group-app = APP +settings-group-account = CUENTA +settings-region-desc = La zona de GeForce NOW desde la que transmite este dispositivo. +settings-fps-desc = Más alto es más fluido; más bajo puede verse más nítido en una conexión lenta. +settings-audio-boost-desc = Amplifica el audio del stream por encima de la ganancia unitaria. +settings-color-depth-desc = 32 bits elimina el bandeado en cielos y escenas oscuras; 16 bits usa menos memoria. Se aplica en el siguiente lanzamiento. +settings-trigger-desc = Con qué fuerza responde L2/R2 al tocar el panel trasero. +settings-rear-touch-desc = Cómo se reparte el panel trasero entre L2/R2 y L3/R3. +settings-stick-zones-desc = L3/R3, desde las esquinas inferiores de la pantalla frontal. +settings-language-desc = Idioma en el que se muestran los menús y mensajes de esta app. +settings-game-language-heading = Idioma del juego +settings-game-language-desc = Idioma para los menús, subtítulos y audio del propio juego, donde sea compatible. +settings-game-profile-heading = Ajustes solo para este juego +settings-game-profile-desc = Mantiene los controles de este juego separados de los globales. +settings-session-timer-heading = Temporizador de sesión +settings-session-timer-desc = Muestra el tiempo transcurrido durante un stream. +settings-trigger-swap-heading = Invertir L1/R1 con L2/R2 +settings-trigger-swap-desc = Envía L2/R2 al presionar L1/R1 y viceversa, para juegos que ponen su acción principal en un gatillo. controls-hint-sticks = Las esquinas de abajo de la pantalla son L3 y R3. error-session-busy-title = Ya hay una sesion abierta error-session-busy-body = GeForce NOW sigue con una sesion activa en esta cuenta, y no es una que esta app pueda cerrar. Lo mas rapido: abre play.geforcenow.com y lanza un juego alli para tomar el control. Si no, cierra sesion de GeForce NOW en tus otros dispositivos, o espera unos 8 minutos a que caduque. @@ -287,3 +327,19 @@ error-gfn-certificate-rejected-title = Certificado rechazado error-gfn-certificate-rejected-body = Se rechazó el certificado del servidor. error-gfn-unknown-title = No se pudo iniciar la sesión error-gfn-unknown-body = GeForce NOW rechazó el lanzamiento: { $detail } + +# Selector de servidor previo al lanzamiento +server-picker-heading = Seleccionar servidor +server-picker-queue-loading = Cargando datos de cola... +server-picker-auto-badge = MEJOR +server-picker-closest-badge = MÁS CERCA +server-picker-launch = Lanzar +server-picker-cancel = Cancelar +server-picker-powered-by = Datos de cola por PrintedWaste +server-picker-hint = Arriba/Abajo para elegir · X para lanzar · O para cancelar + +# Energía / salud del enlace +status-battery-low = Batería baja ({ $percent }%): la sesión se detendrá antes de agotarse. +status-battery-critical = Batería crítica: sesión detenida para poder liberarla correctamente. +status-session-suspended = Sesión detenida porque la consola entró en reposo. +status-bitrate-lowered = Conexión con problemas: techo bajado a { $mbps } Mbps. diff --git a/src/ime.rs b/src/ime.rs deleted file mode 100644 index 00e73a0..0000000 --- a/src/ime.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! In-game keyboard, built on the Vita's inline IME (`sceImeOpen`). -//! -//! The inline IME does not report key presses - it reports *edits to a text buffer*. So key -//! presses have to be inferred, using the technique vita-moonlight arrived at -//! (`src/keyboardsystem.c`): keep a buffer of filler characters with the caret parked in the -//! middle, and read each edit's shape to work out which key caused it. -//! -//! Parking the caret at index 1 of a 3-cell filler buffer makes every possible edit distinguishable: -//! -//! | IME event | caret | key it must have been | -//! |------------------|-------|-----------------------| -//! | `UPDATE_TEXT` | 1 | the character now in the buffer | -//! | `UPDATE_TEXT` | 0 | Backspace (it ate the filler to the left) | -//! | `UPDATE_CARET` | 0 | Left arrow | -//! | `UPDATE_CARET` | 2 | Right arrow | -//! | `PRESS_ENTER` | - | Enter | -//! -//! After every key the buffer and caret are reset, so the next keystroke starts from the same -//! known state. Without that reset the second keypress would be read against a buffer that the -//! first one had already moved, and the inference falls apart. -//! -//! Two libime rules shape the structure here, and breaking either one takes the firmware down with -//! C2-12828-1 rather than returning an error: -//! -//! - `sceImeOpen`, `sceImeUpdate`, `sceImeSetText`, `sceImeSetCaret` and `sceImeClose` all belong -//! to one thread. Everything below runs on the shell loop, which is where `open` and `update` are -//! called from. -//! - The event handler must not call back into libime. So the reset is not done in the handler; it -//! raises [`RECENTER_PENDING`] and [`update`] performs it, which is the same split moonlight uses -//! with its `forzar_centro` flag. - -use crate::gfn::input_protocol::{ - KEY_BACKSPACE, KEY_ENTER, KeyStroke, key_for_char, -}; -use std::sync::Mutex; -use vitasdk_sys::*; - -/// Virtual-key codes for the arrows, which `key_for_char` has no character to map from. -const KEY_LEFT: KeyStroke = KeyStroke::new(0x25, 0x4B); -const KEY_RIGHT: KeyStroke = KeyStroke::new(0x27, 0x4D); - -/// What `sceImeParamInit` would stamp into `sdkVersion`. It is a `static inline` in -/// `psp2/libime.h`, so there is no symbol to link against and the value is inlined here instead. -/// libime rejects a version it does not recognise. -const PSP2_SDK_VERSION: SceUInt32 = 0x0357_0011; - -/// Filler cell. Not a printable character, so a real keystroke is always distinguishable from the -/// padding around it. -const FILLER: SceWChar16 = 1; -/// Three filler cells plus a terminator. Three is the minimum that leaves a cell on each side of -/// the parked caret, which is what makes left and right movement tell themselves apart. -const BUFFER_LEN: usize = 4; -/// The buffers are over-allocated past `BUFFER_LEN`: libime writes up to `maxTextLength` cells and -/// is not documented as counting the terminator, so the slack keeps a miscount off the next static. -const BUFFER_CAPACITY: usize = 8; -/// Where the caret is re-parked after every keystroke. -const CARET_HOME: u32 = 1; - -/// Keystrokes detected since the last drain. The IME hands us events on its own callback, so this -/// is the only way across to the shell loop. -static PENDING_KEYS: Mutex> = Mutex::new(Vec::new()); - -#[repr(align(64))] -struct TextBuf([SceWChar16; BUFFER_CAPACITY]); - -/// What the IME is seeded with. Kept separate from [`INPUT_BUFFER`] because libime holds both -/// pointers for the lifetime of the session, and pointing them at one buffer has it reading the -/// text it is concurrently writing. -static mut INITIAL_TEXT: TextBuf = TextBuf([FILLER, FILLER, FILLER, 0, 0, 0, 0, 0]); -/// Where the IME writes the edited text, and what the handler reads back. -static mut INPUT_BUFFER: TextBuf = TextBuf([FILLER, FILLER, FILLER, 0, 0, 0, 0, 0]); - -/// Scratch space the IME requires us to own for as long as it is open. -#[repr(align(64))] -struct WorkBuf([u8; SCE_IME_WORK_BUFFER_SIZE as usize]); -static mut WORK_BUFFER: WorkBuf = WorkBuf([0; SCE_IME_WORK_BUFFER_SIZE as usize]); - -/// Whether the IME is currently open, so `update` knows to pump it and `open` stays idempotent. -static OPEN: Mutex = Mutex::new(false); -/// Raised by the handler once it has read a keystroke; [`update`] does the actual reset on the -/// owning thread. See the module docs for why the handler cannot do it itself. -static RECENTER_PENDING: Mutex = Mutex::new(false); -/// The IME emits a spurious delete event immediately after opening. Swallowing it stops the -/// keyboard from sending a phantom Backspace to the game every time it is summoned. -static IGNORE_FIRST_DELETE: Mutex = Mutex::new(false); - -fn queue(key: KeyStroke) { - if let Ok(mut pending) = PENDING_KEYS.lock() { - pending.push(key); - } -} - -/// Asks [`update`] to restore the filler buffer and re-park the caret, so the next edit is measured -/// from a known starting point. -fn request_recenter() { - if let Ok(mut pending) = RECENTER_PENDING.lock() { - *pending = true; - } -} - -/// Restores the filler buffer and re-parks the caret. -/// -/// # Safety -/// Must run on the thread that called `sceImeOpen`, while the IME is open. -unsafe fn recenter() { - unsafe { - for buffer in [&raw mut INITIAL_TEXT.0, &raw mut INPUT_BUFFER.0] { - (*buffer)[0] = FILLER; - (*buffer)[1] = FILLER; - (*buffer)[2] = FILLER; - (*buffer)[3] = 0; - } - - // Text before caret: the caret index is only meaningful against the text libime has just - // been handed. - sceImeSetText((&raw const INITIAL_TEXT.0).cast(), BUFFER_LEN as u32); - - let mut caret: SceImeCaret = core::mem::zeroed(); - caret.index = CARET_HOME; - sceImeSetCaret(&caret); - } -} - -/// The character the user just typed, or `None` if the buffer holds only filler. -/// -/// # Safety -/// Only called from the IME handler, where `INPUT_BUFFER` is not being written by anyone else. -unsafe fn typed_character() -> Option { - unsafe { - let buffer = &raw const INPUT_BUFFER.0; - (0..BUFFER_LEN) - .map(|index| (*buffer)[index]) - .find(|cell| *cell != 0 && *cell != FILLER) - .and_then(|cell| char::from_u32(u32::from(cell))) - } -} - -/// Called by the IME for every edit the user makes. -/// -/// Must not call into libime - see the module docs. -unsafe extern "C" fn on_ime_event(_arg: *mut core::ffi::c_void, event: *const SceImeEventData) { - let Some(event) = (unsafe { event.as_ref() }) else { - return; - }; - // SAFETY: `caretIndex` is the active union member for the events this reads; `rect` and - // `text` belong to CHANGE_SIZE and the preedit path, which are not handled here. - let caret = unsafe { event.param.caretIndex }; - - match event.id { - SCE_IME_EVENT_UPDATE_TEXT => { - let character = unsafe { typed_character() }; - - // Opening the keyboard fires a delete with nothing in the buffer. Sending Backspace - // for it would silently eat a character in whatever the game had focused. - if character.is_none() - && caret == 0 - && IGNORE_FIRST_DELETE - .lock() - .map(|mut flag| std::mem::replace(&mut *flag, false)) - .unwrap_or(false) - { - request_recenter(); - return; - } - - match character { - // A real character landed where the caret was parked. - Some(character) => { - if let Some(key) = key_for_char(character) { - queue(key); - } - // Anything `key_for_char` rejects has no key on a US layout, so it is dropped - // rather than sent as some arbitrary wrong key. - } - // Nothing new, and the caret moved left: the filler to the left was deleted. - None if caret == 0 => queue(KEY_BACKSPACE), - None => {} - } - request_recenter(); - } - SCE_IME_EVENT_UPDATE_CARET => { - // The caret started at CARET_HOME, so which side it landed on is the arrow pressed. - match caret { - 0 => queue(KEY_LEFT), - 2 => queue(KEY_RIGHT), - _ => {} - } - request_recenter(); - } - SCE_IME_EVENT_PRESS_ENTER => { - queue(KEY_ENTER); - request_recenter(); - } - SCE_IME_EVENT_PRESS_CLOSE => { - // Closing is the one libime call the handler is allowed to make, and the next - // `sceImeUpdate` reporting failure is how `update` learns the session is gone. - unsafe { sceImeClose() }; - } - _ => {} - } -} - -/// Whether the in-game keyboard is currently showing. -pub fn is_open() -> bool { - OPEN.lock().map(|open| *open).unwrap_or(false) -} - -/// Shows the in-game keyboard. Does nothing if it is already up. -/// -/// Must be called from the same thread as [`update`] - see the module docs. -pub fn open() -> bool { - match OPEN.lock() { - Ok(open) if *open => return true, - Ok(_) => {} - Err(_) => return false, - } - - if let Ok(mut flag) = IGNORE_FIRST_DELETE.lock() { - *flag = true; - } - if let Ok(mut pending) = RECENTER_PENDING.lock() { - *pending = false; - } - - // SAFETY: the IME is not open, so nothing else is touching the buffers, and they are statics - - // libime borrows them until `sceImeClose`. - let result = unsafe { - // libime lives in a loadable module. Calling into its stubs before this lands is a jump - // through an unresolved import, which is the crash rather than an error code. - sceSysmoduleLoadModule(SCE_SYSMODULE_IME); - - for buffer in [&raw mut INITIAL_TEXT.0, &raw mut INPUT_BUFFER.0] { - (*buffer)[0] = FILLER; - (*buffer)[1] = FILLER; - (*buffer)[2] = FILLER; - (*buffer)[3] = 0; - } - - // Equivalent to `sceImeParamInit`, which is header-only. - let mut param: SceImeParam = core::mem::zeroed(); - param.sdkVersion = PSP2_SDK_VERSION; - param.supportedLanguages = SCE_IME_LANGUAGE_ENGLISH.into(); - param.languagesForced = SCE_TRUE as SceBool; - param.type_ = SCE_IME_TYPE_DEFAULT; - param.option = SCE_IME_OPTION_NO_ASSISTANCE; - param.work = (&raw mut WORK_BUFFER.0).cast(); - param.arg = core::ptr::null_mut(); - param.handler = Some(on_ime_event); - param.filter = None; - param.initialText = (&raw mut INITIAL_TEXT.0).cast(); - param.maxTextLength = BUFFER_LEN as u32; - param.inputTextBuffer = (&raw mut INPUT_BUFFER.0).cast(); - param.enterLabel = SCE_IME_ENTER_LABEL_DEFAULT as SceUChar8; - - let result = sceImeOpen(¶m); - if result >= 0 { - // Only safe once the session exists, and it must happen before the first update so the - // caret starts parked where the inference expects it. - recenter(); - } - result - }; - - if result < 0 { - eprintln!("Could not open the in-game keyboard: 0x{result:08X}"); - return false; - } - if let Ok(mut open) = OPEN.lock() { - *open = true; - } - true -} - -/// Hides the in-game keyboard. -pub fn close() { - let was_open = match OPEN.lock() { - Ok(mut open) => std::mem::replace(&mut *open, false), - Err(_) => return, - }; - if was_open { - unsafe { sceImeClose() }; - } - if let Ok(mut pending) = PENDING_KEYS.lock() { - pending.clear(); - } -} - -/// Pumps the IME so it can deliver events. Must be called every frame while the keyboard is up, -/// from the thread that called [`open`]. -pub fn update() { - if !is_open() { - return; - } - - let recenter_now = RECENTER_PENDING - .lock() - .map(|mut pending| std::mem::replace(&mut *pending, false)) - .unwrap_or(false); - - // SAFETY: the session is open and this is its owning thread. - let status = unsafe { - if recenter_now { - recenter(); - } - sceImeUpdate() - }; - - // The user dismissed the keyboard, so the session is already gone. `close` would call - // `sceImeClose` on it a second time. - if status < 0 { - if let Ok(mut open) = OPEN.lock() { - *open = false; - } - if let Ok(mut pending) = PENDING_KEYS.lock() { - pending.clear(); - } - } -} - -/// Takes the keystrokes detected since the last call. -pub fn take_keys() -> Vec { - PENDING_KEYS - .lock() - .map(|mut pending| std::mem::take(&mut *pending)) - .unwrap_or_default() -} diff --git a/src/input.rs b/src/input.rs index 5b39ac5..ca34a97 100644 --- a/src/input.rs +++ b/src/input.rs @@ -22,6 +22,8 @@ pub enum InputCommand { MoveDown, MoveLeft, MoveRight, + PrevTab, + NextTab, } /// Top-level command enum the shell feeds into `App::handle_command`. @@ -40,10 +42,10 @@ pub enum AppCommand { SetLocale(crate::locale::Locale), /// Emitted by the catalog screen's sort picker. SetSort(crate::app::CatalogSort), - // my games / all games picker next to sort SetFilter(crate::app::CatalogFilter), /// Emitted by the stream-quality section of the account popup. SetStreamFps(crate::gfn::stream_prefs::StreamFps), + ToggleSessionTimer, /// Emitted by the rear-trigger section of the account popup. SetTriggerIntensity(crate::gfn::stream_prefs::TriggerIntensity), /// Closes the first-run controls explainer for good. @@ -54,14 +56,22 @@ pub enum AppCommand { ToggleStreamStats, /// Shows or hides the in-game keyboard. ToggleKeyboard, - /// A key tapped on the streaming overlay's special-key row. SendKey(crate::gfn::input_protocol::KeyStroke), + SendChord { + ctrl: bool, + alt: bool, + key: crate::gfn::input_protocol::KeyStroke, + }, + ToggleKeyShift, + ToggleKeyCtrl, + ToggleKeyAlt, /// Emitted by the stick-zone section of the settings modal. SetStickZones(crate::gfn::stream_prefs::StickZones), /// from the rear-touch layout picker in settings SetRearTouchMode(crate::gfn::stream_prefs::RearTouchMode), /// Emitted by the volume-boost section of the account popup. SetAudioBoost(crate::gfn::stream_prefs::AudioBoost), + SetColorDepth(crate::gfn::stream_prefs::ColorDepth), /// Emitted when a row in the catalog list is tapped/clicked. SelectGame(usize), /// Toggles the streaming toolbar between expanded and collapsed. @@ -74,6 +84,21 @@ pub enum AppCommand { ToggleMouseTrackpad, /// bumps the bitrate mid session, kbps SetMaxBitrate(u32), + SetRegion(String), + LoadRegions, + TestRegionLatency, + OpenSettings, + CloseSettings, + SetSettingsTab(crate::app::settings_menu::SettingsTab), + ExpandSettingsRow(Option), + ChooseSettingsOption(usize, usize), + ToggleGameProfile, + ToggleTriggerSwap, + SetGameLanguage(crate::gfn::stream_prefs::GameLanguage), + CloseServerPicker, + FocusServerPicker(usize), + LaunchOnServer(String), + LoadQueueStats, } impl From for AppCommand { @@ -120,6 +145,14 @@ pub fn map_controller_button_event(event: &Event) -> Option { Event::ControllerButtonDown { button: Button::A, .. } => Some(InputCommand::Confirm.into()), + Event::ControllerButtonDown { + button: Button::LeftShoulder, + .. + } => Some(InputCommand::PrevTab.into()), + Event::ControllerButtonDown { + button: Button::RightShoulder, + .. + } => Some(InputCommand::NextTab.into()), _ => None, } } @@ -651,31 +684,57 @@ pub fn gamepad_snapshot( set(Button::Back, BACK); set(Button::LeftStick, LEFT_THUMB); set(Button::RightStick, RIGHT_THUMB); - set(Button::LeftShoulder, LEFT_SHOULDER); - set(Button::RightShoulder, RIGHT_SHOULDER); set(Button::A, A); set(Button::B, B); set(Button::X, X); set(Button::Y, Y); - // L3/R3 can come from either front corners or rear quadrants, whichever fires - if stick_zones.left_stick_click() || rear_touch.left_stick_click() { + let axis = |axis: Axis| controller.axis(axis); + let trigger = |value: i16| (value.max(0) / 129).min(255) as u8; + + let swap_triggers = crate::gfn::stream_prefs::trigger_swap_enabled(); + let phys_l1 = controller.button(Button::LeftShoulder); + let phys_r1 = controller.button(Button::RightShoulder); + let phys_l2 = trigger(axis(Axis::TriggerLeft)).max(rear_touch.left_trigger()); + let phys_r2 = trigger(axis(Axis::TriggerRight)).max(rear_touch.right_trigger()); + let (left_shoulder_down, right_shoulder_down, left_trigger_val, right_trigger_val) = + if swap_triggers { + ( + phys_l2 > 0, + phys_r2 > 0, + if phys_l1 { 255 } else { 0 }, + if phys_r1 { 255 } else { 0 }, + ) + } else { + (phys_l1, phys_r1, phys_l2, phys_r2) + }; + if left_shoulder_down { + buttons |= LEFT_SHOULDER; + } + if right_shoulder_down { + buttons |= RIGHT_SHOULDER; + } + + if controller.button(Button::LeftStick) + || stick_zones.left_stick_click() + || rear_touch.left_stick_click() + { buttons |= LEFT_THUMB; } - if stick_zones.right_stick_click() || rear_touch.right_stick_click() { + if controller.button(Button::RightStick) + || stick_zones.right_stick_click() + || rear_touch.right_stick_click() + { buttons |= RIGHT_THUMB; } - let axis = |axis: Axis| controller.axis(axis); - let trigger = |value: i16| (value.max(0) / 129).min(255) as u8; - crate::gfn::input_protocol::GamepadInput { controller_id: 0, buttons, // Whichever source is pressing harder wins, so an attached DualShock on a Vita TV still // works while the rear panel covers the handheld. - left_trigger: trigger(axis(Axis::TriggerLeft)).max(rear_touch.left_trigger()), - right_trigger: trigger(axis(Axis::TriggerRight)).max(rear_touch.right_trigger()), + left_trigger: left_trigger_val, + right_trigger: right_trigger_val, left_stick_x: axis(Axis::LeftX), left_stick_y: axis(Axis::LeftY).saturating_neg(), right_stick_x: axis(Axis::RightX), diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..c6e3fd0 --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,122 @@ + +use anyhow::Result; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::SystemTime; + +static LOG_FILE: Mutex> = Mutex::new(None); + +pub fn logs_dir() -> PathBuf { + if cfg!(target_os = "vita") { + PathBuf::from("ux0:/data/opennow/logs") + } else { + PathBuf::from("opennow/logs") + } +} + +pub fn frame_stats_path() -> PathBuf { + if cfg!(target_os = "vita") { + PathBuf::from("ux0:data/opennow/frame_stats.log") + } else { + PathBuf::from("opennow/frame_stats.log") + } +} + +pub fn reset_frame_stats_log() { + let path = frame_stats_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&path, ""); +} + +pub fn write_frame_stats(message: &str) { + write_log("FRAME", message); + let path = frame_stats_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(file, "{message}"); + } +} + +pub fn init() -> Result<()> { + let dir = logs_dir(); + fs::create_dir_all(&dir)?; + + let latest_path = dir.join("opennow_latest.log"); + let previous_path = dir.join("opennow_previous.log"); + + if latest_path.exists() { + let _ = fs::rename(&latest_path, &previous_path); + } + + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&latest_path)?; + + if let Ok(mut guard) = LOG_FILE.lock() { + *guard = Some(file); + } + + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let msg = format!("[FATAL PANIC] {info}\n"); + eprintln!("{msg}"); + write_log("FATAL", &msg); + default_hook(info); + })); + + write_log("INFO", "OpenNOW-vita logger initialized"); + Ok(()) +} + +pub fn write_log(level: &str, message: &str) { + let timestamp = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(d) => d.as_secs(), + Err(_) => 0, + }; + + let line = format!("[{timestamp}] [{level}] {message}\n"); + eprint!("{line}"); + + if let Ok(mut guard) = LOG_FILE.lock() { + if let Some(ref mut file) = *guard { + let _ = file.write_all(line.as_bytes()); + let _ = file.flush(); + } + } +} + +#[macro_export] +macro_rules! log_info { + ($($arg:tt)*) => { + $crate::logger::write_log("INFO", &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { + $crate::logger::write_log("WARN", &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_error { + ($($arg:tt)*) => { + $crate::logger::write_log("ERROR", &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_stream { + ($($arg:tt)*) => { + $crate::logger::write_log("STREAM", &format!($($arg)*)) + }; +} diff --git a/src/main.rs b/src/main.rs index 9cbdd38..cffdf5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,9 @@ mod locale; mod power; mod app; mod gfn; -mod ime; mod input; mod jobs; +mod logger; mod safe_memory; mod shell; mod streaming; @@ -28,6 +28,11 @@ pub static SCE_LIBC_HEAP_SIZE: u32 = 40 * 1024 * 1024; pub static NEWLIB_HEAP_SIZE_USER: u32 = 192 * 1024 * 1024; fn main() -> anyhow::Result<()> { + if let Err(e) = logger::init() { + eprintln!("Failed to initialize logger: {e}"); + } + log_info!("OpenNOW-vita starting up"); + let _performance = power::PerformanceMode::engage(); thread_affinity::pin_current_thread(thread_affinity::VitaCore::Render, "shell"); let _app_util = safe_memory::AppUtil::initialize()?; diff --git a/src/power.rs b/src/power.rs index 68057e2..f1d5a3c 100644 --- a/src/power.rs +++ b/src/power.rs @@ -50,8 +50,6 @@ impl PerformanceMode { gpu_xbar: PERFORMANCE_GPU_XBAR_MHZ, }); - // Tells the power manager the radio is in continuous use, which is exactly true for a - // streaming client and affects its idle/suspend policy. let result = unsafe { vitasdk_sys::scePowerSetUsingWireless(1) }; if result < 0 { eprintln!("scePowerSetUsingWireless failed: {result:#x}"); @@ -78,11 +76,60 @@ impl Drop for PerformanceMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BatteryStatus { + pub percent: u8, + pub charging: bool, + pub low: bool, +} + +impl BatteryStatus { + pub const WARN_PERCENT: u8 = 20; + pub const CRITICAL_PERCENT: u8 = 8; + + pub fn should_warn(self) -> bool { + !self.charging && (self.low || self.percent <= Self::WARN_PERCENT) + } + + pub fn is_critical(self) -> bool { + !self.charging && self.percent <= Self::CRITICAL_PERCENT + } +} + +#[cfg(target_os = "vita")] +pub fn battery_status() -> Option { + let percent = unsafe { vitasdk_sys::scePowerGetBatteryLifePercent() }; + if percent < 0 { + return None; + } + Some(BatteryStatus { + percent: percent.clamp(0, 100) as u8, + charging: unsafe { vitasdk_sys::scePowerIsBatteryCharging() } > 0 + || unsafe { vitasdk_sys::scePowerIsPowerOnline() } > 0, + low: unsafe { vitasdk_sys::scePowerIsLowBattery() } > 0, + }) +} + +#[cfg(not(target_os = "vita"))] +pub fn battery_status() -> Option { + None +} + +#[cfg(target_os = "vita")] +pub fn suspend_required() -> bool { + unsafe { vitasdk_sys::scePowerIsSuspendRequired() > 0 } +} + +#[cfg(not(target_os = "vita"))] +pub fn suspend_required() -> bool { + false +} + /// Restoring reads the previous values back rather than hardcoding a default, so launching from a /// shell that already changed clocks puts them back where they actually were. #[cfg(target_os = "vita")] fn set_clocks(clocks: Clocks) { - let mut apply = |name: &str, mhz: i32, setter: unsafe extern "C" fn(i32) -> i32| { + let apply = |name: &str, mhz: i32, setter: unsafe extern "C" fn(i32) -> i32| { let result = unsafe { setter(mhz) }; if result < 0 { eprintln!("{name}({mhz}) failed: {result:#x}"); diff --git a/src/shell/egui_painter.rs b/src/shell/egui_painter.rs index 49efe2b..d3ed1d5 100644 --- a/src/shell/egui_painter.rs +++ b/src/shell/egui_painter.rs @@ -1,18 +1,32 @@ -// Adapted verbatim from green-vita (MPL-2.0, https://github.com/Day-OS/green-vita), -// src/shell/egui_painter.rs - generic egui-over-SDL2 renderer, no Xbox/streaming-specific -// logic. See THIRD_PARTY_NOTICES.md. -use anyhow::{Context, Result}; -use std::collections::{HashMap, HashSet, VecDeque}; +use anyhow::Result; +use sdl2::pixels::PixelFormatEnum; +use sdl2::rect::Rect; +use sdl2::render::BlendMode; +use std::collections::{HashMap, HashSet}; -const MAX_NEW_COLOR_TEXTURES_PER_FRAME: usize = 1; +pub const MAX_ICON_SIDE: u32 = 64; + +const RETRIES_PER_FRAME: usize = 2; +const MAX_UPLOAD_ATTEMPTS: u32 = 8; +const BACKOFF_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); +const NEW_TEXTURES_PER_FRAME: usize = 1; +const ICON_FREE_POOL_CAP: usize = 16; +const ICON_FREE_POOL_WARM_LOW: usize = 6; +const MAX_PENDING_UPLOADS: usize = 12; + +fn is_font_texture(id: egui::TextureId) -> bool { + id == egui::TextureId::default() +} #[derive(Default)] pub struct SdlEguiPainter { textures: HashMap, - pending_textures: HashMap, - pending_order: VecDeque, + pending: HashMap, + icon_free_pool: Vec, vertices: Vec, + indices: Vec, + scratch: Vec, } struct SdlEguiTexture { @@ -20,6 +34,23 @@ struct SdlEguiTexture { uv_scale: egui::Vec2, } +struct PendingUpload { + size: [usize; 2], + pos: Option<[usize; 2]>, + pixels: Vec, + attempts: u32, + next_retry_at: std::time::Instant, +} + +#[derive(Default, Clone, Copy)] +pub struct PaintStats { + pub texture_apply_secs: f64, + pub geometry_secs: f64, + pub draw_calls: u32, + pub textures_uploaded: u32, + pub vertices_drawn: u32, +} + impl SdlEguiPainter { pub fn paint( &mut self, @@ -28,50 +59,109 @@ impl SdlEguiPainter { pixels_per_point: f32, primitives: &[egui::ClippedPrimitive], textures_delta: &egui::TexturesDelta, - ) -> Result<()> { - self.apply_textures(canvas, primitives, textures_delta)?; + ) -> Result { + let texture_apply_started_at = std::time::Instant::now(); + let textures_uploaded = self.apply_textures(canvas, primitives, textures_delta); + let texture_apply_secs = texture_apply_started_at.elapsed().as_secs_f64(); + let geometry_started_at = std::time::Instant::now(); + let mut draw_calls = 0u32; + let mut vertices_drawn = 0u32; + let mut current_clip: Option = None; + let mut current_texture_id: Option = None; for clipped_primitive in primitives { let Some(clip_rect) = Self::sdl_clip_rect(clipped_primitive.clip_rect, screen_size, pixels_per_point) else { continue; }; - canvas.set_clip_rect(clip_rect); - let egui::epaint::Primitive::Mesh(mesh) = &clipped_primitive.primitive else { continue; }; if mesh.indices.is_empty() || mesh.vertices.is_empty() { continue; } - - self.vertices.clear(); - let Some(texture) = self.textures.get(&mesh.texture_id) else { - continue; + let uv_scale = match self.textures.get(&mesh.texture_id) { + Some(t) => t.uv_scale, + None if mesh.texture_id != egui::TextureId::default() => continue, + None => egui::vec2(1.0, 1.0), }; - let uv_scale = texture.uv_scale; + let same_batch = + current_clip == Some(clip_rect) && current_texture_id == Some(mesh.texture_id); + if !same_batch { + self.flush_batch(canvas, current_texture_id, &mut draw_calls, &mut vertices_drawn); + canvas.set_clip_rect(clip_rect); + current_clip = Some(clip_rect); + current_texture_id = Some(mesh.texture_id); + } + let base_index = self.vertices.len() as u32; self.vertices.extend( mesh.vertices .iter() .map(|vertex| Self::sdl_vertex(vertex, pixels_per_point, uv_scale)), ); - - canvas - .render_geometry(&self.vertices, Some(&texture.texture), &mesh.indices) - .map_err(anyhow::Error::msg) - .context("failed to render egui geometry through SDL")?; + self.indices + .extend(mesh.indices.iter().map(|&i| (base_index + i) as i32)); } + self.flush_batch(canvas, current_texture_id, &mut draw_calls, &mut vertices_drawn); + let geometry_secs = geometry_started_at.elapsed().as_secs_f64(); canvas.set_clip_rect(None); for texture_id in &textures_delta.free { - self.textures.remove(texture_id); - self.pending_textures.remove(texture_id); + self.pending.remove(texture_id); + let Some(freed) = self.textures.remove(texture_id) else { + continue; + }; + let query = freed.texture.query(); + if query.width == MAX_ICON_SIDE + && query.height == MAX_ICON_SIDE + && self.icon_free_pool.len() < ICON_FREE_POOL_CAP + { + self.icon_free_pool.push(freed.texture); + } else { + unsafe { freed.texture.destroy() }; + } } - self.pending_order - .retain(|texture_id| self.pending_textures.contains_key(texture_id)); + Ok(PaintStats { + texture_apply_secs, + geometry_secs, + draw_calls, + textures_uploaded, + vertices_drawn, + }) + } + + fn is_new_creation(&self, texture_id: egui::TextureId, pos: Option<[usize; 2]>) -> bool { + pos.is_none() || !self.textures.contains_key(&texture_id) + } - Ok(()) + fn is_icon_class_size(size: [usize; 2]) -> bool { + size[0] as u32 <= MAX_ICON_SIDE && size[1] as u32 <= MAX_ICON_SIDE + } + + fn flush_batch( + &mut self, + canvas: &mut sdl2::render::Canvas, + texture_id: Option, + draw_calls: &mut u32, + vertices_drawn: &mut u32, + ) { + if self.indices.is_empty() || self.vertices.is_empty() { + self.vertices.clear(); + self.indices.clear(); + return; + } + let texture_ref = texture_id + .and_then(|id| self.textures.get(&id)) + .map(|t| &t.texture); + if let Err(err) = canvas.render_geometry(&self.vertices, texture_ref, &self.indices) { + eprintln!("skipped a draw call: {err}"); + } else { + *draw_calls += 1; + *vertices_drawn += self.vertices.len() as u32; + } + self.vertices.clear(); + self.indices.clear(); } fn apply_textures( @@ -79,22 +169,7 @@ impl SdlEguiPainter { canvas: &mut sdl2::render::Canvas, primitives: &[egui::ClippedPrimitive], textures_delta: &egui::TexturesDelta, - ) -> Result<()> { - for (texture_id, delta) in &textures_delta.set { - let is_new_color_texture = delta.pos.is_none() - && !self.textures.contains_key(texture_id) - && matches!(delta.image, egui::ImageData::Color(_)); - if is_new_color_texture { - if !self.pending_textures.contains_key(texture_id) { - self.pending_order.push_back(*texture_id); - } - self.pending_textures.insert(*texture_id, delta.clone()); - continue; - } - - Self::upload_texture(canvas, &mut self.textures, *texture_id, delta)?; - } - + ) -> u32 { let visible_texture_ids: HashSet<_> = primitives .iter() .filter_map(|primitive| match &primitive.primitive { @@ -103,111 +178,346 @@ impl SdlEguiPainter { }) .collect(); - for _ in 0..MAX_NEW_COLOR_TEXTURES_PER_FRAME { - let Some(index) = self - .pending_order + let mut uploaded = 0u32; + let mut new_creations = 0usize; + + if let Some(font_id) = self + .pending + .keys() + .copied() + .find(|id| is_font_texture(*id)) + { + let now = std::time::Instant::now(); + if let Some(upload) = self.pending.remove(&font_id) { + if upload.next_retry_at <= now { + self.upload( + canvas, + font_id, + upload.size, + upload.pos, + &upload.pixels, + upload.attempts, + ); + new_creations += 1; + uploaded += 1; + } else { + self.pending.insert(font_id, upload); + } + } + } + + if !self.pending.is_empty() { + let now = std::time::Instant::now(); + let retry_budget = RETRIES_PER_FRAME.min(NEW_TEXTURES_PER_FRAME) + 1; + let mut retry: Vec = self + .pending .iter() - .enumerate() - .filter(|(_, texture_id)| visible_texture_ids.contains(texture_id)) - .max_by_key(|(_, texture_id)| { - self.pending_textures - .get(texture_id) - .map(|delta| delta.image.width() * delta.image.height()) - .unwrap_or(0) + .filter(|(id, upload)| { + !is_font_texture(**id) + && upload.next_retry_at <= now + && visible_texture_ids.contains(id) }) - .map(|(index, _)| index) - else { - break; - }; - let texture_id = self - .pending_order - .remove(index) - .expect("pending texture index disappeared"); - let Some(delta) = self.pending_textures.remove(&texture_id) else { + .map(|(id, _)| *id) + .take(retry_budget) + .collect(); + if retry.len() < retry_budget { + let remaining = retry_budget - retry.len(); + retry.extend( + self.pending + .iter() + .filter(|(id, upload)| { + !is_font_texture(**id) + && upload.next_retry_at <= now + && !visible_texture_ids.contains(id) + }) + .map(|(id, _)| *id) + .take(remaining), + ); + } + for texture_id in retry { + let upload = self + .pending + .remove(&texture_id) + .expect("key came from the map"); + if Self::is_icon_class_size(upload.size) { + if !self.upload_icon(canvas, texture_id, upload.size, &upload.pixels) { + new_creations += 1; + } + } else { + self.upload( + canvas, + texture_id, + upload.size, + upload.pos, + &upload.pixels, + upload.attempts, + ); + new_creations += 1; + } + uploaded += 1; + } + } + + let mut scratch = std::mem::take(&mut self.scratch); + let mut deltas: Vec<_> = textures_delta.set.iter().collect(); + deltas.sort_by_key(|(texture_id, _)| { + ( + !is_font_texture(*texture_id), + !visible_texture_ids.contains(texture_id), + ) + }); + + for (texture_id, delta) in deltas { + scratch.clear(); + Self::fill_sdl_rgba(&delta.image, &mut scratch); + let is_new = self.is_new_creation(*texture_id, delta.pos); + let font = is_font_texture(*texture_id); + if is_new && !font && Self::is_icon_class_size(delta.image.size()) { + let would_create = self.icon_free_pool.is_empty(); + if would_create && new_creations >= NEW_TEXTURES_PER_FRAME { + self.enqueue_pending( + *texture_id, + PendingUpload { + size: delta.image.size(), + pos: None, + pixels: scratch.clone(), + attempts: 0, + next_retry_at: std::time::Instant::now(), + }, + ); + continue; + } + if !self.upload_icon(canvas, *texture_id, delta.image.size(), &scratch) { + new_creations += 1; + } + uploaded += 1; continue; - }; - Self::upload_texture(canvas, &mut self.textures, texture_id, &delta)?; + } + if is_new && !font && new_creations >= NEW_TEXTURES_PER_FRAME { + self.enqueue_pending( + *texture_id, + PendingUpload { + size: delta.image.size(), + pos: delta.pos, + pixels: scratch.clone(), + attempts: 0, + next_retry_at: std::time::Instant::now(), + }, + ); + continue; + } + if is_new { + new_creations += 1; + } + self.upload( + canvas, + *texture_id, + delta.image.size(), + delta.pos, + &scratch, + 0, + ); + uploaded += 1; } + self.scratch = scratch; - Ok(()) + if new_creations <= NEW_TEXTURES_PER_FRAME + && self.icon_free_pool.len() < ICON_FREE_POOL_WARM_LOW + && let Ok(mut texture) = canvas.create_texture_streaming( + PixelFormatEnum::RGBA32, + MAX_ICON_SIDE, + MAX_ICON_SIDE, + ) + { + texture.set_blend_mode(BlendMode::Blend); + self.icon_free_pool.push(texture); + } + uploaded } - fn upload_texture( + fn upload_icon( + &mut self, canvas: &mut sdl2::render::Canvas, - textures: &mut HashMap, texture_id: egui::TextureId, - delta: &egui::epaint::ImageDelta, - ) -> Result<()> { - use sdl2::pixels::PixelFormatEnum; - use sdl2::rect::Rect; - use sdl2::render::BlendMode; - - let [width, height] = delta.image.size(); - let pixels = Self::image_to_sdl_rgba(&delta.image); - - if delta.pos.is_none() || !textures.contains_key(&texture_id) { - let pot_width = width.next_power_of_two(); - let pot_height = height.next_power_of_two(); - let mut texture = canvas - .create_texture_streaming( - PixelFormatEnum::RGBA32, - pot_width as u32, - pot_height as u32, - ) - .map_err(anyhow::Error::msg) - .context("failed to create SDL egui texture")?; + size: [usize; 2], + pixels: &[u8], + ) -> bool { + if let Some(texture) = self.icon_free_pool.pop() { + self.finish_icon_upload(texture, texture_id, size, pixels); + return true; + } + match canvas.create_texture_streaming(PixelFormatEnum::RGBA32, MAX_ICON_SIDE, MAX_ICON_SIDE) + { + Ok(mut texture) => { + texture.set_blend_mode(BlendMode::Blend); + self.finish_icon_upload(texture, texture_id, size, pixels); + } + Err(err) => { + eprintln!( + "no room for a {MAX_ICON_SIDE}x{MAX_ICON_SIDE} icon texture, will retry: {err}" + ); + self.defer_or_give_up(texture_id, size, None, pixels, 0); + } + } + false + } + + fn finish_icon_upload( + &mut self, + mut texture: sdl2::render::Texture, + texture_id: egui::TextureId, + size: [usize; 2], + pixels: &[u8], + ) { + let [width, height] = size; + if let Err(err) = texture.update( + Rect::new(0, 0, width as u32, height as u32), + pixels, + width * 4, + ) { + eprintln!("couldn't patch a pooled icon texture, will retry: {err}"); + if self.icon_free_pool.len() < ICON_FREE_POOL_CAP { + self.icon_free_pool.push(texture); + } else { + unsafe { texture.destroy() }; + } + self.defer_or_give_up(texture_id, size, None, pixels, 0); + return; + } + let cap = MAX_ICON_SIDE as f32; + let uv_scale = egui::vec2(width as f32 / cap, height as f32 / cap); + if let Some(previous) = self + .textures + .insert(texture_id, SdlEguiTexture { texture, uv_scale }) + { + unsafe { previous.texture.destroy() }; + } + } + + fn make_pending_room(&mut self, keep: egui::TextureId) { + while self.pending.len() >= MAX_PENDING_UPLOADS { + let victim = self + .pending + .iter() + .filter(|(id, _)| **id != keep && !is_font_texture(**id)) + .max_by_key(|(_, upload)| upload.attempts) + .map(|(id, _)| *id); + let Some(victim) = victim else { break }; + self.pending.remove(&victim); + } + } + + fn enqueue_pending(&mut self, texture_id: egui::TextureId, upload: PendingUpload) { + self.make_pending_room(texture_id); + self.pending.insert(texture_id, upload); + } + + fn defer_or_give_up( + &mut self, + texture_id: egui::TextureId, + size: [usize; 2], + pos: Option<[usize; 2]>, + pixels: &[u8], + attempts: u32, + ) { + let attempts = attempts + 1; + if attempts >= MAX_UPLOAD_ATTEMPTS { + eprintln!( + "giving up on a {}x{} texture after {attempts} attempts", + size[0], size[1] + ); + self.pending.remove(&texture_id); + return; + } + self.enqueue_pending( + texture_id, + PendingUpload { + size, + pos, + pixels: pixels.to_vec(), + attempts, + next_retry_at: std::time::Instant::now() + BACKOFF_RETRY_INTERVAL, + }, + ); + } + + fn upload( + &mut self, + canvas: &mut sdl2::render::Canvas, + texture_id: egui::TextureId, + size: [usize; 2], + pos: Option<[usize; 2]>, + pixels: &[u8], + attempts: u32, + ) { + let [width, height] = size; + if pos.is_none() || !self.textures.contains_key(&texture_id) { + let texture = + canvas.create_texture_streaming(PixelFormatEnum::RGBA32, width as u32, height as u32); + let mut texture = match texture { + Ok(texture) => texture, + Err(err) => { + eprintln!("no room for a {width}x{height} texture, will retry: {err}"); + self.defer_or_give_up(texture_id, size, pos, pixels, attempts); + return; + } + }; texture.set_blend_mode(BlendMode::Blend); - texture - .update( - Rect::new(0, 0, width as u32, height as u32), - &pixels, - width * 4, - ) - .map_err(anyhow::Error::msg) - .context("failed to upload SDL egui texture")?; - textures.insert( + if let Err(err) = texture.update( + Rect::new(0, 0, width as u32, height as u32), + pixels, + width * 4, + ) { + eprintln!("couldn't upload a texture, will retry: {err}"); + unsafe { texture.destroy() }; + self.defer_or_give_up(texture_id, size, pos, pixels, attempts); + return; + } + if let Some(previous) = self.textures.insert( texture_id, SdlEguiTexture { texture, - uv_scale: egui::vec2( - width as f32 / pot_width as f32, - height as f32 / pot_height as f32, - ), + uv_scale: egui::vec2(1.0, 1.0), }, - ); - return Ok(()); + ) { + unsafe { previous.texture.destroy() }; + } + return; + } + let Some([x, y]) = pos else { + eprintln!("partial texture update with no position, skipped"); + return; + }; + let Some(existing) = self.textures.get_mut(&texture_id) else { + eprintln!("partial update for a texture that no longer exists, skipped"); + return; + }; + if let Err(err) = existing.texture.update( + Rect::new(x as i32, y as i32, width as u32, height as u32), + pixels, + width * 4, + ) { + eprintln!("couldn't patch a texture: {err}"); } - - let [x, y] = delta.pos.expect("partial texture update has a position"); - textures - .get_mut(&texture_id) - .context("missing SDL texture for egui partial update")? - .texture - .update( - Rect::new(x as i32, y as i32, width as u32, height as u32), - &pixels, - width * 4, - ) - .map_err(anyhow::Error::msg) - .context("failed to upload SDL egui texture patch")?; - Ok(()) } - fn image_to_sdl_rgba(image: &egui::ImageData) -> Vec { - let mut pixels = Vec::with_capacity(image.width() * image.height() * 4); + fn fill_sdl_rgba(image: &egui::ImageData, out: &mut Vec) { match image { egui::ImageData::Color(image) => { - for pixel in &image.pixels { - pixels.extend_from_slice(&pixel.to_srgba_unmultiplied()); - } + let bytes: &[u8] = unsafe { + std::slice::from_raw_parts( + image.pixels.as_ptr() as *const u8, + image.pixels.len() * 4, + ) + }; + out.extend_from_slice(bytes); } egui::ImageData::Font(image) => { for pixel in image.srgba_pixels(None) { - pixels.extend_from_slice(&pixel.to_srgba_unmultiplied()); + out.extend_from_slice(&pixel.to_srgba_unmultiplied()); } } } - pixels } fn sdl_vertex( @@ -215,14 +525,17 @@ impl SdlEguiPainter { pixels_per_point: f32, uv_scale: egui::Vec2, ) -> sdl2::render::Vertex { - let [r, g, b, a] = vertex.color.to_srgba_unmultiplied(); + let [r, g, b, a] = vertex.color.to_array(); sdl2::render::Vertex { position: sdl2::rect::FPoint::new( vertex.pos.x * pixels_per_point, vertex.pos.y * pixels_per_point, ), color: sdl2::pixels::Color::RGBA(r, g, b, a), - tex_coord: sdl2::rect::FPoint::new(vertex.uv.x * uv_scale.x, vertex.uv.y * uv_scale.y), + tex_coord: sdl2::rect::FPoint::new( + (vertex.uv.x * uv_scale.x).clamp(0.0, 1.0), + (vertex.uv.y * uv_scale.y).clamp(0.0, 1.0), + ), } } diff --git a/src/shell/mod.rs b/src/shell/mod.rs index adb0e72..4fdeea1 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -14,33 +14,29 @@ use crate::input::{ use crate::streaming::audio::AudioRenderer; use anyhow::{Context, Result}; use std::time::{Duration, Instant}; -use surface::{HEIGHT, VitaSurface, WIDTH}; -use tokio::time::sleep; +use surface::{FramePaintStats, HEIGHT, VitaSurface, WIDTH}; /// Scales `pixels_per_point` up so the UI reads legibly on the Vita's small screen. const UI_SCALE: f32 = 1.3; -const DIRECTION_REPEAT_INITIAL_DELAY: Duration = Duration::from_millis(350); -const DIRECTION_REPEAT_INTERVAL: Duration = Duration::from_millis(90); +const DIRECTION_REPEAT_INITIAL_DELAY: Duration = Duration::from_millis(200); +const DIRECTION_REPEAT_INTERVAL: Duration = Duration::from_millis(70); pub(crate) const TARGET_FRAME_TIME: Duration = Duration::from_millis(16); -const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(4); +const FRAME_STATS_INTERVAL: Duration = Duration::from_secs(2); +const SLOW_FRAME_THRESHOLD: Duration = Duration::from_millis(20); +const LONG_GAP_THRESHOLD: Duration = Duration::from_millis(25); -/// Where the render loop's time goes, for the on-screen readout. -/// -/// The loop only sleeps when it comes in under `TARGET_FRAME_TIME`; when it overruns it runs flat -/// out, which pegs a core. Knowing *which* phase overran is the difference between fixing it and -/// guessing at it. pub(crate) mod render_stats { use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; pub(crate) static FRAME_US: AtomicU32 = AtomicU32::new(0); pub(crate) static UI_US: AtomicU32 = AtomicU32::new(0); pub(crate) static PAINT_US: AtomicU32 = AtomicU32::new(0); - /// Frames that missed the deadline, so the loop never got to sleep. + pub(crate) static PRESENT_US: AtomicU32 = AtomicU32::new(0); + pub(crate) static DRAW_CALLS: AtomicU32 = AtomicU32::new(0); pub(crate) static OVER_BUDGET: AtomicU64 = AtomicU64::new(0); - /// Exponential smoothing, so the readout is legible instead of flickering every frame. pub(crate) fn record(slot: &AtomicU32, sample_us: u32) { let previous = slot.load(Ordering::Relaxed); let smoothed = if previous == 0 { @@ -53,15 +49,157 @@ pub(crate) mod render_stats { pub(crate) fn line() -> String { format!( - "cpu frame:{:.1}ms ui:{:.1}ms paint:{:.1}ms over:{}", + "cpu frame:{:.1}ms ui:{:.1}ms paint:{:.1}ms present:{:.1}ms draws:{} over:{}", FRAME_US.load(Ordering::Relaxed) as f32 / 1000.0, UI_US.load(Ordering::Relaxed) as f32 / 1000.0, PAINT_US.load(Ordering::Relaxed) as f32 / 1000.0, + PRESENT_US.load(Ordering::Relaxed) as f32 / 1000.0, + DRAW_CALLS.load(Ordering::Relaxed), OVER_BUDGET.load(Ordering::Relaxed), ) } } +#[derive(Default)] +struct FrameStats { + window_started_at: Option, + frames: u32, + tick: Duration, + build_ui: Duration, + tessellate: Duration, + texture_apply: Duration, + geometry: Duration, + present: Duration, + draw_calls: u64, + textures_uploaded: u64, + vertices_drawn: u64, + iterations: u32, + last_painted_at: Option, + max_gap: Duration, + long_gaps: u32, + pending_log: Vec, +} + +impl FrameStats { + fn note_iteration(&mut self) { + self.iterations += 1; + self.window_started_at.get_or_insert_with(Instant::now); + } + + fn record( + &mut self, + tick: Duration, + build_ui: Duration, + tessellate: Duration, + paint: FramePaintStats, + ) { + let now = Instant::now(); + let texture_apply = Duration::from_secs_f64(paint.texture_apply_secs); + let geometry = Duration::from_secs_f64(paint.geometry_secs); + let present = Duration::from_secs_f64(paint.present_secs); + self.frames += 1; + self.tick += tick; + self.build_ui += build_ui; + self.tessellate += tessellate; + self.texture_apply += texture_apply; + self.geometry += geometry; + self.present += present; + self.draw_calls += paint.draw_calls as u64; + self.textures_uploaded += paint.textures_uploaded as u64; + self.vertices_drawn += paint.vertices_drawn as u64; + let paint_total = texture_apply + geometry + present; + let total = tick + build_ui + tessellate + paint_total; + if let Some(previous) = self.last_painted_at { + let gap = now.duration_since(previous); + self.max_gap = self.max_gap.max(gap); + if gap > LONG_GAP_THRESHOLD { + self.long_gaps += 1; + self.pending_log.push(format!( + "long gap: {:.1}ms since last painted frame (work={:.1}ms elsewhere={:.1}ms)", + gap.as_secs_f64() * 1000.0, + total.as_secs_f64() * 1000.0, + gap.saturating_sub(total).as_secs_f64() * 1000.0, + )); + } + } + self.last_painted_at = Some(now); + if total > SLOW_FRAME_THRESHOLD { + self.pending_log.push(format!( + "slow frame: tick={:.1}ms build_ui={:.1}ms tessellate={:.1}ms paint={:.1}ms \ + (texture_apply={:.1}ms×{} geometry={:.1}ms×{}draws/{}verts present={:.1}ms) total={:.1}ms", + tick.as_secs_f64() * 1000.0, + build_ui.as_secs_f64() * 1000.0, + tessellate.as_secs_f64() * 1000.0, + paint_total.as_secs_f64() * 1000.0, + texture_apply.as_secs_f64() * 1000.0, + paint.textures_uploaded, + geometry.as_secs_f64() * 1000.0, + paint.draw_calls, + paint.vertices_drawn, + present.as_secs_f64() * 1000.0, + total.as_secs_f64() * 1000.0, + )); + } + + let ui_us = (build_ui + tessellate).as_micros() as u32; + let paint_us = (texture_apply + geometry).as_micros() as u32; + let present_us = present.as_micros() as u32; + let frame_us = total.as_micros() as u32; + render_stats::record(&render_stats::UI_US, ui_us); + render_stats::record(&render_stats::PAINT_US, paint_us); + render_stats::record(&render_stats::PRESENT_US, present_us); + render_stats::record(&render_stats::FRAME_US, frame_us); + render_stats::DRAW_CALLS.store(paint.draw_calls, std::sync::atomic::Ordering::Relaxed); + if tick + build_ui + tessellate + texture_apply + geometry >= TARGET_FRAME_TIME { + render_stats::OVER_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn maybe_flush(&mut self) { + let Some(window_started_at) = self.window_started_at else { + return; + }; + let elapsed = window_started_at.elapsed(); + if elapsed < FRAME_STATS_INTERVAL { + return; + } + let seconds = elapsed.as_secs_f64(); + let frames = self.frames.max(1) as f64; + self.pending_log.push(format!( + "frame stats ({:.1}s): {} painted ({:.1} fps) · {} iterations · \ + worst frame gap {:.0}ms, {} over {}ms", + seconds, + self.frames, + self.frames as f64 / seconds, + self.iterations, + self.max_gap.as_secs_f64() * 1000.0, + self.long_gaps, + LONG_GAP_THRESHOLD.as_millis(), + )); + if self.frames > 0 { + self.pending_log.push(format!( + " avg per painted frame: tick={:.2}ms build_ui={:.2}ms tessellate={:.2}ms \ + texture_apply={:.2}ms ({:.1} uploads) geometry={:.2}ms ({:.1} draws, {:.0} verts) present={:.2}ms", + self.tick.as_secs_f64() * 1000.0 / frames, + self.build_ui.as_secs_f64() * 1000.0 / frames, + self.tessellate.as_secs_f64() * 1000.0 / frames, + self.texture_apply.as_secs_f64() * 1000.0 / frames, + self.textures_uploaded as f64 / frames, + self.geometry.as_secs_f64() * 1000.0 / frames, + self.draw_calls as f64 / frames, + self.vertices_drawn as f64 / frames, + self.present.as_secs_f64() * 1000.0 / frames, + )); + } + crate::logger::write_frame_stats(&self.pending_log.join("\n")); + let last_painted_at = self.last_painted_at; + *self = FrameStats { + last_painted_at, + ..FrameStats::default() + }; + } +} + pub async fn run(mut app: App) -> Result<()> { let sdl = sdl2::init().map_err(anyhow::Error::msg)?; let video = sdl.video().map_err(anyhow::Error::msg)?; @@ -74,6 +212,7 @@ pub async fn run(mut app: App) -> Result<()> { let _audio_renderer = AudioRenderer::new(&audio).context("failed to set up audio renderer")?; let egui_ctx = egui::Context::default(); + crate::app::fonts::configure(&egui_ctx); crate::app::ui::apply_theme(&egui_ctx); let start_time = Instant::now(); let mut pointer_pos = egui::Pos2::ZERO; @@ -87,34 +226,32 @@ pub async fn run(mut app: App) -> Result<()> { let mut rear_touch = RearTouchTriggers::default(); let mut stick_zones = crate::input::FrontStickZones::default(); let mut was_streaming = false; - // Reactive repainting outside a session: the catalog is static between interactions, so - // re-running and re-tessellating egui 60 times a second burns a whole core to redraw an - // identical screen. Streaming always repaints - the video changes every frame. - let mut last_painted_at = Instant::now(); - let mut needs_repaint = true; + let mut frame_stats = FrameStats::default(); + crate::logger::reset_frame_stats_log(); + crate::logger::write_frame_stats("=== OpenNOW-vita frame stats — new session ==="); loop { let loop_started_at = Instant::now(); + frame_stats.note_iteration(); + frame_stats.maybe_flush(); let mut egui_events = Vec::new(); let mut direct_commands = Vec::new(); let mut stream_mouse_events = Vec::new(); - // While a session is live the touchscreen belongs to the game rather than to the client - // UI - except for the "Stop session" button, whose rect the UI publishes each frame. - // Without that carve-out the button is on screen but unreachable, leaving no way out of a - // running game. - // Any client-owned modal has to take the touchscreen back, or its buttons are drawn over - // the game but every tap goes to the game's mouse instead - which is exactly how the - // controls hint ended up with a "Got it" that did nothing. - let touch_drives_stream = matches!(app.state, AppState::Streaming { .. }) - && !app.confirm_exit - && !app.show_controls_hint - && !app.show_controls_modal; - let stream_ui_rects: Vec = crate::app::ui::stream_ui_rects(&egui_ctx) + let touch_drives_stream = + matches!(app.state, AppState::Streaming { .. }) && !app.ui_owns_touch(); + let screen_points = (WIDTH as f32 / UI_SCALE, HEIGHT as f32 / UI_SCALE); + let mut stream_ui_rects: Vec = crate::app::ui::stream_ui_rects(&egui_ctx) .into_iter() // Fingertips are wider than a button's hit box. .map(|rect| rect.expand(8.0)) .collect(); - let screen_points = (WIDTH as f32 / UI_SCALE, HEIGHT as f32 / UI_SCALE); + if app.keyboard_open { + let screen = egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(screen_points.0, screen_points.1), + ); + stream_ui_rects.push(crate::app::ui::keyboard_panel_rect(screen)); + } // Touch deltas are normalized 0..1, so they scale by the streamed frame's own size to // land as host pixels: a drag across the whole panel moves the cursor across the whole // remote screen. @@ -147,7 +284,8 @@ pub async fn run(mut app: App) -> Result<()> { if let sdl2::event::Event::FingerDown { touch_id, x, y, .. } = event && touch_id == crate::input::FRONT_TOUCH_DEVICE_ID { - touch_owned_by_stick_zone = !touch_owned_by_ui + touch_owned_by_stick_zone = touch_drives_stream + && !touch_owned_by_ui && crate::gfn::stream_prefs::stick_zones().is_active() && crate::input::is_in_stick_zone(x, y); crate::input::stick_zone_stats::record_touch_owned(touch_owned_by_stick_zone); @@ -207,11 +345,12 @@ pub async fn run(mut app: App) -> Result<()> { None => held_direction = None, } - let had_direct_commands = !direct_commands.is_empty(); for command in direct_commands { app.handle_command(command).await?; } + let tick_started_at = Instant::now(); app.tick().await?; + let tick_elapsed = tick_started_at.elapsed(); let show_video = { let streaming_peer = match &app.state { @@ -222,12 +361,13 @@ pub async fn run(mut app: App) -> Result<()> { // memory card and this runs 60 times a second. if streaming_peer.is_some() != was_streaming { was_streaming = streaming_peer.is_some(); + if was_streaming { + rear_touch.reload_intensity(); + stick_zones.reload_enabled(); + } } - if was_streaming { - rear_touch.reload_intensity(); - stick_zones.reload_enabled(); - } - surface.sync_video_frame(streaming_peer)?; + let latest_video = streaming_peer.and_then(|peer| peer.video_frame()); + surface.sync_video_frame(streaming_peer, latest_video.as_ref())?; if let (Some(peer), Some(active_controller)) = (streaming_peer, controller.as_ref()) { peer.send_gamepad(gamepad_snapshot(active_controller, &rear_touch, &stick_zones)); crate::input::stick_zone_stats::record_clicks( @@ -242,7 +382,7 @@ pub async fn run(mut app: App) -> Result<()> { } // Audio no longer passes through here at all: the peer thread hands packets to the // decode worker as they arrive, so playback is not paced by the video frame rate. - streaming_peer.is_some_and(|peer| peer.video_frame().is_some()) + latest_video.is_some() }; let search_requested = matches!( @@ -264,7 +404,6 @@ pub async fn run(mut app: App) -> Result<()> { text_input_active = false; } - let had_egui_events = !egui_events.is_empty(); let raw_input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, @@ -285,35 +424,12 @@ pub async fn run(mut app: App) -> Result<()> { ..Default::default() }; - // App state changes behind egui's back (a finished job, a new status line), so a floor - // keeps the screen honest without polling at frame rate. 100 ms is imperceptible on a menu - // and still ~6x less work than repainting every frame. - const IDLE_REPAINT_FLOOR: Duration = Duration::from_millis(100); - let repaint_now = show_video - || needs_repaint - || had_egui_events - || had_direct_commands - || last_painted_at.elapsed() >= IDLE_REPAINT_FLOOR; - if !repaint_now { - let frame_deadline = loop_started_at + TARGET_FRAME_TIME; - while Instant::now() < frame_deadline { - let remaining = frame_deadline.saturating_duration_since(Instant::now()); - sleep(remaining.min(INPUT_POLL_INTERVAL)).await; - } - continue; - } - - let ui_started_at = Instant::now(); + let build_ui_started_at = Instant::now(); let mut ui_commands = Vec::new(); let full_output = egui_ctx.run(raw_input, |ctx| { ui_commands = build_ui(ctx, &app); }); - // egui asks for another frame while anything is animating - a spinner, a fade, a hover. - needs_repaint = full_output - .viewport_output - .get(&egui::ViewportId::ROOT) - .is_some_and(|viewport| viewport.repaint_delay.is_zero()); - last_painted_at = Instant::now(); + let build_ui_elapsed = build_ui_started_at.elapsed(); for command in ui_commands { if command == crate::input::AppCommand::RightClick { @@ -330,51 +446,22 @@ pub async fn run(mut app: App) -> Result<()> { app.handle_command(command).await?; } + let tessellate_started_at = Instant::now(); let clipped_primitives = egui_ctx.tessellate(full_output.shapes, full_output.pixels_per_point); - render_stats::record( - &render_stats::UI_US, - ui_started_at.elapsed().as_micros() as u32, - ); + let tessellate_elapsed = tessellate_started_at.elapsed(); - let paint_started_at = Instant::now(); surface.draw_scene(show_video)?; - surface.paint_egui( + let paint_stats = surface.paint_egui( full_output.pixels_per_point, &clipped_primitives, &full_output.textures_delta, )?; - render_stats::record( - &render_stats::PAINT_US, - paint_started_at.elapsed().as_micros() as u32, - ); - render_stats::record( - &render_stats::FRAME_US, - loop_started_at.elapsed().as_micros() as u32, - ); - + frame_stats.record(tick_elapsed, build_ui_elapsed, tessellate_elapsed, paint_stats); let frame_deadline = loop_started_at + TARGET_FRAME_TIME; - if Instant::now() >= frame_deadline { - render_stats::OVER_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - if Instant::now() < frame_deadline { - while Instant::now() < frame_deadline { - let remaining = frame_deadline.saturating_duration_since(Instant::now()); - sleep(remaining.min(INPUT_POLL_INTERVAL)).await; - if Instant::now() >= frame_deadline { - break; - } - event_pump.pump_events(); - if let (AppState::Streaming { peer, .. }, Some(active_controller)) = - (&app.state, controller.as_ref()) - { - peer.send_gamepad(gamepad_snapshot(active_controller, &rear_touch, &stick_zones)); - crate::input::stick_zone_stats::record_clicks( - stick_zones.left_stick_click(), - stick_zones.right_stick_click(), - ); - } - } + let remaining = frame_deadline.saturating_duration_since(Instant::now()); + if !remaining.is_zero() { + tokio::time::sleep(remaining).await; } else { tokio::task::yield_now().await; } diff --git a/src/shell/surface.rs b/src/shell/surface.rs index 906d99d..d20ba09 100644 --- a/src/shell/surface.rs +++ b/src/shell/surface.rs @@ -2,7 +2,7 @@ // https://github.com/Day-OS/green-vita) src/shell/surface.rs. See THIRD_PARTY_NOTICES.md. use crate::gfn::peer::PeerEngine; -use crate::shell::egui_painter::SdlEguiPainter; +use crate::shell::egui_painter::{PaintStats, SdlEguiPainter}; use crate::streaming::video::{ DirectVideoOutput, VIDEO_TEXTURE_COUNT, VideoPixelFormat, VideoTextureTarget, }; @@ -13,6 +13,16 @@ use sdl2::video::Window; use std::sync::Arc; use std::sync::atomic::Ordering; +#[derive(Default, Clone, Copy)] +pub struct FramePaintStats { + pub texture_apply_secs: f64, + pub geometry_secs: f64, + pub present_secs: f64, + pub draw_calls: u32, + pub textures_uploaded: u32, + pub vertices_drawn: u32, +} + pub const WIDTH: u32 = 960; pub const HEIGHT: u32 = 544; @@ -35,13 +45,6 @@ pub struct VitaSurface { impl VitaSurface { pub fn new(video: &sdl2::VideoSubsystem) -> Result { - // Must be set before any texture is created: SDL captures the scale mode at creation time, - // and SDL's Vita backend maps it straight to a gxm texture filter - // (`SCE_GXM_TEXTURE_FILTER_LINEAR` vs `POINT`). - // - // Without it SDL defaults to nearest, so the 1280x720 stream was being downscaled to the - // Vita's 960x544 by dropping pixels - free on the GPU either way, but nearest shimmers on - // anything that moves. Linear costs nothing: the texture unit filters in hardware. sdl2::hint::set("SDL_RENDER_SCALE_QUALITY", "1"); let window = video @@ -80,7 +83,11 @@ impl VitaSurface { /// Registers/releases the direct video textures as streaming starts/stops and flips to the /// most recently published frame. - pub fn sync_video_frame(&mut self, streaming: Option<&PeerEngine>) -> Result<()> { + pub fn sync_video_frame( + &mut self, + streaming: Option<&PeerEngine>, + latest_video: Option<&(u64, crate::streaming::video::DecodedFrame)>, + ) -> Result<()> { let Some(streaming) = streaming else { self.detach_direct_video_output(); return Ok(()); @@ -94,10 +101,10 @@ impl VitaSurface { } self.ensure_direct_video_output(streaming)?; - let Some((frame_id, frame)) = streaming.video_frame() else { + let Some((frame_id, frame)) = latest_video else { return Ok(()); }; - if frame_id == self.last_frame_id { + if *frame_id == self.last_frame_id { return Ok(()); } let index = frame.texture_index; @@ -108,7 +115,7 @@ impl VitaSurface { output.mark_displayed(index, frame.generation); } self.displayed_video_texture = Some(index); - self.last_frame_id = frame_id; + self.last_frame_id = *frame_id; Ok(()) } @@ -132,8 +139,8 @@ impl VitaSurface { let (width, height) = (output.width, output.height); let force_iyuv = self.force_iyuv; let mut format = VideoPixelFormat::Bgr565; - let mut create_targets = |pixel_format: PixelFormatEnum| -> Result<[Texture; VIDEO_TEXTURE_COUNT]> { - let mut create_one = || { + let create_targets = |pixel_format: PixelFormatEnum| -> Result<[Texture; VIDEO_TEXTURE_COUNT]> { + let create_one = || { self.canvas .create_texture_streaming(pixel_format, width, height) .map_err(anyhow::Error::msg) @@ -141,17 +148,32 @@ impl VitaSurface { }; Ok([create_one()?, create_one()?, create_one()?]) }; + let want_32_bit = crate::gfn::stream_prefs::color_depth() + == crate::gfn::stream_prefs::ColorDepth::ThirtyTwoBit; let mut textures = if force_iyuv { format = VideoPixelFormat::Iyuv; create_targets(PixelFormatEnum::IYUV)? } else { - match create_targets(PixelFormatEnum::BGR565) { - Ok(textures) => textures, - Err(error) => { - eprintln!("BGR565 video textures unavailable ({error:#}); using IYUV"); - format = VideoPixelFormat::Iyuv; - create_targets(PixelFormatEnum::IYUV)? + let thirty_two = want_32_bit + .then(|| create_targets(PixelFormatEnum::ABGR8888)) + .transpose() + .unwrap_or_else(|error| { + eprintln!("ABGR8888 video textures unavailable ({error:#}); using BGR565"); + None + }); + match thirty_two { + Some(textures) => { + format = VideoPixelFormat::Rgba8888; + textures } + None => match create_targets(PixelFormatEnum::BGR565) { + Ok(textures) => textures, + Err(error) => { + eprintln!("BGR565 video textures unavailable ({error:#}); using IYUV"); + format = VideoPixelFormat::Iyuv; + create_targets(PixelFormatEnum::IYUV)? + } + }, } }; let record_targets = @@ -237,16 +259,31 @@ impl VitaSurface { pixels_per_point: f32, primitives: &[egui::ClippedPrimitive], textures_delta: &egui::TexturesDelta, - ) -> Result<()> { - self.egui_painter.paint( + ) -> Result { + let PaintStats { + texture_apply_secs, + geometry_secs, + draw_calls, + textures_uploaded, + vertices_drawn, + } = self.egui_painter.paint( &mut self.canvas, [WIDTH, HEIGHT], pixels_per_point, primitives, textures_delta, )?; + let present_started_at = std::time::Instant::now(); self.canvas.present(); - Ok(()) + let present_secs = present_started_at.elapsed().as_secs_f64(); + Ok(FramePaintStats { + texture_apply_secs, + geometry_secs, + present_secs, + draw_calls, + textures_uploaded, + vertices_drawn, + }) } fn fit_rect(src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> sdl2::rect::Rect { diff --git a/src/streaming/video/decoder.rs b/src/streaming/video/decoder.rs index 9dd3cdb..382f4e1 100644 --- a/src/streaming/video/decoder.rs +++ b/src/streaming/video/decoder.rs @@ -1,7 +1,4 @@ // Adapted from green-vita (MPL-2.0, https://github.com/Day-OS/green-vita) -// src/streaming/video/decoder.rs - PS Vita hardware H.264 decoder (`sceVideodec`/`sceAvcdec`) -// writing RGB565 straight into the SDL texture registered by the shell. -// See THIRD_PARTY_NOTICES.md. use super::{DecoderConfig, VideoPixelFormat, VideoTextureTarget}; #[cfg(not(target_os = "vita"))] @@ -10,6 +7,7 @@ use anyhow::Result; #[cfg(target_os = "vita")] mod vita { use super::super::memory::{CdramBlock, release_reserved_decoder_cdram}; + use super::super::AU_PTS_STEP; use super::{DecoderConfig, VideoPixelFormat, VideoTextureTarget}; use anyhow::{Result, bail}; use std::os::raw::c_void; @@ -17,8 +15,120 @@ mod vita { use super::super::AVCDEC_NUM_REF_FRAMES; + const INTERNAL_CODEC_CONFIG: i32 = 2; + const AVCDEC_MODE_EXTENDED: i32 = 0x80; + const CODEC_MEMORY_ALIGNMENT: u32 = 1024 * 1024; + const CODEC_VADDR_ALIGNMENT: u32 = 256 * 1024; + + #[link(name = "SceAvcodec_stub", kind = "static")] + unsafe extern "C" { + fn sceVideodecSetConfigInternal(codec_type: SceVideodecType, config: i32) -> i32; + fn sceAvcdecSetDecodeMode(codec_type: SceVideodecType, mode: i32) -> i32; + fn sceVideodecQueryMemSizeInternal( + codec_type: SceVideodecType, + query: *mut SceVideodecQueryInitInfo, + size: *mut u32, + ) -> i32; + fn sceVideodecInitLibraryWithUnmapMemInternal( + codec_type: SceVideodecType, + control: *mut SceVideodecCtrl, + query: *mut SceVideodecQueryInitInfo, + ) -> i32; + fn sceAvcdecQueryDecoderMemSizeInternal( + codec_type: SceVideodecType, + query: *mut SceAvcdecQueryDecoderInfo, + decoder_info: *mut SceAvcdecDecoderInfo, + ) -> i32; + fn sceAvcdecCreateDecoderInternal( + codec_type: SceVideodecType, + decoder: *mut SceAvcdecCtrl, + query: *mut SceAvcdecQueryDecoderInfo, + ) -> i32; + fn sceAvcdecDecodeAuInternal( + decoder: *mut SceAvcdecCtrl, + au: *mut SceAvcdecAu, + picture_state: *mut i32, + ) -> i32; + fn sceAvcdecDecodeGetPictureWithWorkPictureInternal( + decoder: *mut SceAvcdecCtrl, + pictures: *mut SceAvcdecArrayPicture, + work_pictures: *mut SceAvcdecArrayPicture, + picture_state: *mut i32, + ) -> i32; + } + + #[link(name = "SceCodecEngine_stub", kind = "static")] + unsafe extern "C" { + fn sceCodecEngineOpenUnmapMemBlock(ptr: *mut c_void, size: u32) -> SceUID; + fn sceCodecEngineCloseUnmapMemBlock(uid: SceUID) -> i32; + fn sceCodecEngineAllocMemoryFromUnmapMemBlock( + uid: SceUID, + size: u32, + alignment: u32, + ) -> SceUIntVAddr; + fn sceCodecEngineFreeMemoryFromUnmapMemBlock(uid: SceUID, address: SceUIntVAddr) -> i32; + } + + #[repr(C)] + struct SceVideodecCtrl { + reserved: [u8; 24], + vaddr: SceUIntVAddr, + size: u32, + } + + struct CodecEngineMemory { + _block: CdramBlock, + unmap_uid: SceUID, + vaddr: SceUIntVAddr, + } + + impl CodecEngineMemory { + unsafe fn allocate(size: u32) -> Result { + let block = CdramBlock::allocate_with_alignments( + "opennow_avcdec_codec", + size, + CODEC_MEMORY_ALIGNMENT, + CODEC_MEMORY_ALIGNMENT, + )?; + let block_size = block.capacity(); + let vaddr_size = size.div_ceil(CODEC_VADDR_ALIGNMENT) * CODEC_VADDR_ALIGNMENT; + let unmap_uid = unsafe { sceCodecEngineOpenUnmapMemBlock(block.ptr.cast(), block_size) }; + if unmap_uid <= 0 { + bail!("sceCodecEngineOpenUnmapMemBlock failed: {unmap_uid:#x}"); + } + + let vaddr = unsafe { + sceCodecEngineAllocMemoryFromUnmapMemBlock( + unmap_uid, + vaddr_size, + CODEC_VADDR_ALIGNMENT, + ) + }; + if vaddr == 0 { + unsafe { sceCodecEngineCloseUnmapMemBlock(unmap_uid) }; + bail!("sceCodecEngineAllocMemoryFromUnmapMemBlock failed"); + } + + Ok(Self { + _block: block, + unmap_uid, + vaddr, + }) + } + } + + impl Drop for CodecEngineMemory { + fn drop(&mut self) { + unsafe { + sceCodecEngineFreeMemoryFromUnmapMemBlock(self.unmap_uid, self.vaddr); + sceCodecEngineCloseUnmapMemBlock(self.unmap_uid); + } + } + } + struct AvcdecLibrary { module_loaded: bool, + _codec_memory: CodecEngineMemory, } impl AvcdecLibrary { @@ -40,24 +150,70 @@ mod vita { } }; - let init_info = SceVideodecQueryInitInfoHwAvcdec { + let mut init_info: SceVideodecQueryInitInfo = unsafe { std::mem::zeroed() }; + init_info.hwAvc = SceVideodecQueryInitInfoHwAvcdec { size: size_of::() as u32, horizontal: width, vertical: height, numOfRefFrames: AVCDEC_NUM_REF_FRAMES, numOfStreams: 1, }; - let ret = unsafe { sceVideodecInitLibrary(SCE_VIDEODEC_TYPE_HW_AVCDEC, &init_info) }; + + let config_ret = unsafe { + sceVideodecSetConfigInternal(SCE_VIDEODEC_TYPE_HW_AVCDEC, INTERNAL_CODEC_CONFIG) + }; + if config_ret < 0 { + bail!("sceVideodecSetConfigInternal failed: {config_ret:#x}"); + } + let mode_ret = + unsafe { sceAvcdecSetDecodeMode(SCE_VIDEODEC_TYPE_HW_AVCDEC, AVCDEC_MODE_EXTENDED) }; + if mode_ret < 0 { + bail!("sceAvcdecSetDecodeMode failed: {mode_ret:#x}"); + } + + let mut codec_size = 0; + let query_ret = unsafe { + sceVideodecQueryMemSizeInternal( + SCE_VIDEODEC_TYPE_HW_AVCDEC, + &mut init_info, + &mut codec_size, + ) + }; + if query_ret < 0 || codec_size == 0 { + bail!( + "sceVideodecQueryMemSizeInternal failed: {query_ret:#x}, size={codec_size}" + ); + } + + release_reserved_decoder_cdram(); + let codec_memory = unsafe { CodecEngineMemory::allocate(codec_size)? }; + let vaddr_size = codec_size.div_ceil(CODEC_VADDR_ALIGNMENT) * CODEC_VADDR_ALIGNMENT; + let mut control = SceVideodecCtrl { + reserved: [0; 24], + vaddr: codec_memory.vaddr, + size: vaddr_size, + }; + + let ret = unsafe { + sceVideodecInitLibraryWithUnmapMemInternal( + SCE_VIDEODEC_TYPE_HW_AVCDEC, + &mut control, + &mut init_info, + ) + }; if ret < 0 { if module_loaded { unsafe { sceSysmoduleUnloadModule(SCE_SYSMODULE_AVCDEC); } } - bail!("sceVideodecInitLibrary failed: {ret:#x}"); + bail!("sceVideodecInitLibraryWithUnmapMemInternal failed: {ret:#x}"); } - Ok(Self { module_loaded }) + Ok(Self { + module_loaded, + _codec_memory: codec_memory, + }) } } @@ -72,12 +228,14 @@ mod vita { } } - struct AvcdecDecoder(SceAvcdecCtrl); + struct AvcdecDecoder { + ctrl: SceAvcdecCtrl, + } impl Drop for AvcdecDecoder { fn drop(&mut self) { unsafe { - sceAvcdecDeleteDecoder(&mut self.0); + sceAvcdecDeleteDecoder(&mut self.ctrl); } } } @@ -88,6 +246,8 @@ mod vita { _library: AvcdecLibrary, width: u32, height: u32, + decoder_timeout: i32, + next_au_seq: u64, } impl HwVideoDecoder { @@ -96,23 +256,27 @@ mod vita { let library = AvcdecLibrary::initialize(config.decode_width, config.decode_height)?; - let query = SceAvcdecQueryDecoderInfo { + let mut query = SceAvcdecQueryDecoderInfo { horizontal: config.decode_width, vertical: config.decode_height, numOfRefFrames: AVCDEC_NUM_REF_FRAMES, }; let mut decoder_info = SceAvcdecDecoderInfo { frameMemSize: 0 }; - let ret = sceAvcdecQueryDecoderMemSize( + let ret = sceAvcdecQueryDecoderMemSizeInternal( SCE_VIDEODEC_TYPE_HW_AVCDEC, - &query, + &mut query, &mut decoder_info, ); if ret < 0 { - bail!("sceAvcdecQueryDecoderMemSize failed: {ret:#x}"); + bail!("sceAvcdecQueryDecoderMemSizeInternal failed: {ret:#x}"); } release_reserved_decoder_cdram(); - let frame_memory = - CdramBlock::allocate("opennow_hw_video_frame", decoder_info.frameMemSize)?; + let frame_memory = CdramBlock::allocate_with_alignments( + "opennow_hw_video_frame", + decoder_info.frameMemSize, + CODEC_MEMORY_ALIGNMENT, + 256 * 1024, + )?; let mut decoder_control = SceAvcdecCtrl { handle: 0, frameBuf: SceAvcdecBuf { @@ -120,50 +284,71 @@ mod vita { size: decoder_info.frameMemSize, }, }; - let ret = sceAvcdecCreateDecoder( + let ret = sceAvcdecCreateDecoderInternal( SCE_VIDEODEC_TYPE_HW_AVCDEC, &mut decoder_control, - &query, + &mut query, ); if ret < 0 { - bail!("sceAvcdecCreateDecoder failed: {ret:#x}"); + bail!("sceAvcdecCreateDecoderInternal failed: {ret:#x}"); } - let decoder = AvcdecDecoder(decoder_control); Ok(Self { - decoder, + decoder: AvcdecDecoder { + ctrl: decoder_control, + }, _frame_memory: frame_memory, _library: library, width: config.output_width, height: config.output_height, + decoder_timeout: 0, + next_au_seq: 0, }) } } - /// Decodes one Access Unit into `direct_target` using the pixel format the render thread - /// registered. - pub fn decode( - &mut self, - access_unit: &[u8], - direct_target: VideoTextureTarget, - format: VideoPixelFormat, - ) -> Result { + pub fn submitted_sequence(&self) -> u64 { + self.next_au_seq + } + + pub fn submit_access_unit(&mut self, access_unit: &[u8]) -> Result<()> { unsafe { - let au = SceAvcdecAu { + self.next_au_seq = self.next_au_seq.saturating_add(1); + let input_pts = self + .next_au_seq + .saturating_mul(AU_PTS_STEP); + let mut au = SceAvcdecAu { pts: SceVideodecTimeStamp { - upper: 0xFFFFFFFF, - lower: 0xFFFFFFFF, + upper: (input_pts >> 32) as u32, + lower: input_pts as u32, }, dts: SceVideodecTimeStamp { - upper: 0xFFFFFFFF, - lower: 0xFFFFFFFF, + upper: (input_pts >> 32) as u32, + lower: input_pts as u32, }, es: SceAvcdecBuf { pBuf: access_unit.as_ptr() as *mut c_void, size: access_unit.len() as u32, }, }; + let ret = sceAvcdecDecodeAuInternal( + &mut self.decoder.ctrl, + &mut au, + &mut self.decoder_timeout, + ); + if ret < 0 { + bail!("sceAvcdecDecodeAuInternal failed: {ret:#x}"); + } + Ok(()) + } + } + pub fn get_picture( + &mut self, + direct_target: VideoTextureTarget, + format: VideoPixelFormat, + ) -> Result> { + unsafe { let output_ptr = direct_target.ptr as *mut u8; let output_capacity = direct_target.capacity; let (pixel_type, output_pitch, required_capacity) = match format { @@ -177,6 +362,11 @@ mod vita { direct_target.pitch, self.width.saturating_mul(self.height) * 3 / 2, ), + VideoPixelFormat::Rgba8888 => ( + SCE_AVCDEC_PIXELFORMAT_RGBA8888 as u32, + direct_target.pitch / 4, + (direct_target.pitch / 4).saturating_mul(self.height) * 4, + ), }; if output_pitch < self.width { bail!( @@ -206,12 +396,12 @@ mod vita { opt: SceAvcdecFrameOption { rgba: SceAvcdecFrameOptionRGBA { alpha: 0xff, - cscCoefficient: 1, // 1 = ITU-R BT.709 for GFN HD video + cscCoefficient: 1, // ITU-R BT.709 for GFN HD video reserved: [0; 14], }, }, pPicture: match format { - VideoPixelFormat::Bgr565 => { + VideoPixelFormat::Bgr565 | VideoPixelFormat::Rgba8888 => { [output_ptr.cast(), std::ptr::null_mut()] } VideoPixelFormat::Iyuv => [ @@ -230,31 +420,40 @@ mod vita { numOfElm: 1, pPicture: &mut picture_ptr, }; + let mut work_picture = SceAvcdecArrayPicture { + numOfOutput: 0, + numOfElm: 0, + pPicture: std::ptr::null_mut(), + }; - let ret = sceAvcdecDecode(&self.decoder.0, &au, &mut array_picture); + let ret = sceAvcdecDecodeGetPictureWithWorkPictureInternal( + &mut self.decoder.ctrl, + &mut array_picture, + &mut work_picture, + &mut self.decoder_timeout, + ); if ret < 0 { - bail!("sceAvcdecDecode failed: {ret:#x}"); + bail!( + "sceAvcdecDecodeGetPictureWithWorkPictureInternal failed: {ret:#x}" + ); } if array_picture.numOfOutput == 0 { - return Ok(false); + return Ok(None); } - Ok(true) + let returned_pts = + ((picture.info.pts.upper as u64) << 32) | picture.info.pts.lower as u64; + Ok(Some(returned_pts)) } } } - // SAFETY: the CDRAM blocks and decoder handle have no thread affinity in the underlying - // SCE API - this is only ever moved once (into `VideoDecodeWorker`'s thread), never - // accessed concurrently. unsafe impl Send for HwVideoDecoder {} } #[cfg(target_os = "vita")] pub use vita::HwVideoDecoder; -/// Host-build stand-in so `cargo check` for non-Vita targets still type-checks; the real binary -/// only ever ships for the Vita. #[cfg(not(target_os = "vita"))] pub struct HwVideoDecoder; @@ -264,12 +463,19 @@ impl HwVideoDecoder { anyhow::bail!("hardware H.264 decoder is only available on the PS Vita target") } - pub fn decode( + pub fn submitted_sequence(&self) -> u64 { + 0 + } + + pub fn submit_access_unit(&mut self, _access_unit: &[u8]) -> Result<()> { + anyhow::bail!("hardware H.264 decoder is only available on the PS Vita target") + } + + pub fn get_picture( &mut self, - _access_unit: &[u8], _direct_target: VideoTextureTarget, _format: VideoPixelFormat, - ) -> Result { + ) -> Result> { anyhow::bail!("hardware H.264 decoder is only available on the PS Vita target") } } diff --git a/src/streaming/video/memory.rs b/src/streaming/video/memory.rs index 9cc623f..60fa4a7 100644 --- a/src/streaming/video/memory.rs +++ b/src/streaming/video/memory.rs @@ -79,16 +79,26 @@ fn free_memory_summary() -> String { pub(super) struct CdramBlock { uid: SceUID, pub(super) ptr: *mut u8, + capacity: u32, } impl CdramBlock { pub(super) fn allocate(name: &str, size: u32) -> Result { + Self::allocate_with_alignments(name, size, BLOCK_ALIGNMENT, BLOCK_ALIGNMENT) + } + + pub(super) fn allocate_with_alignments( + name: &str, + size: u32, + address_alignment: u32, + size_alignment: u32, + ) -> Result { let c_name = CString::new(name).expect("static name has no interior NUL"); - let capacity = size.div_ceil(BLOCK_ALIGNMENT) * BLOCK_ALIGNMENT; + let capacity = size.div_ceil(size_alignment) * size_alignment; let mut options = SceKernelAllocMemBlockOpt { size: size_of::() as u32, attr: SCE_KERNEL_ALLOC_MEMBLOCK_ATTR_HAS_ALIGNMENT, - alignment: BLOCK_ALIGNMENT, + alignment: address_alignment, uidBaseBlock: 0, strBaseBlockName: std::ptr::null(), flags: 0, @@ -121,8 +131,13 @@ impl CdramBlock { Ok(Self { uid, ptr: base.cast(), + capacity, }) } + + pub(super) fn capacity(&self) -> u32 { + self.capacity + } } impl Drop for CdramBlock { diff --git a/src/streaming/video/mod.rs b/src/streaming/video/mod.rs index f1ee4d2..47a0af5 100644 --- a/src/streaming/video/mod.rs +++ b/src/streaming/video/mod.rs @@ -17,7 +17,10 @@ use std::sync::{Condvar, Mutex, MutexGuard}; use std::time::Duration; /// How many reference frames the Vita's hardware decoder is initialized to retain. -pub const AVCDEC_NUM_REF_FRAMES: u32 = 4; +pub const AVCDEC_NUM_REF_FRAMES: u32 = 1; + +#[allow(dead_code)] // used by Vita Internal decoder path (`cfg(target_os = "vita")`) +pub const AU_PTS_STEP: u64 = 1500; /// Pixel format negotiated between the render thread (which knows what SDL texture formats the /// platform supports) and the decoder (which asks sceAvcdec for matching output). @@ -25,6 +28,7 @@ pub const AVCDEC_NUM_REF_FRAMES: u32 = 4; pub enum VideoPixelFormat { Bgr565, Iyuv, + Rgba8888, } const MAX_PENDING_TEXTURE_WAIT: Duration = Duration::from_millis(34); @@ -74,7 +78,6 @@ pub struct DirectVideoOutput { state: Mutex, frame_displayed: Condvar, pub decoder_ready: AtomicBool, - /// 0 = not yet registered, 1 = Bgr565, 2 = Iyuv. pixel_format: AtomicU8, /// Set by the decode thread when Bgr565 keeps decoding "successfully" but the output is /// suspiciously blank - Vita3K's HLE AVCDEC silently zeroes RGB565 output instead of erroring @@ -119,6 +122,7 @@ impl DirectVideoOutput { let value = match format { VideoPixelFormat::Bgr565 => 1, VideoPixelFormat::Iyuv => 2, + VideoPixelFormat::Rgba8888 => 3, }; self.pixel_format.store(value, Ordering::Release); } @@ -127,6 +131,7 @@ impl DirectVideoOutput { match self.pixel_format.load(Ordering::Acquire) { 1 => Some(VideoPixelFormat::Bgr565), 2 => Some(VideoPixelFormat::Iyuv), + 3 => Some(VideoPixelFormat::Rgba8888), _ => None, } } @@ -163,6 +168,7 @@ impl DirectVideoOutput { } /// Blocks (bounded by `MAX_PENDING_TEXTURE_WAIT`) until a texture is free to write into. + #[allow(dead_code)] pub fn lock_decode_target( &self, stalls: &AtomicU64, @@ -196,6 +202,19 @@ impl DirectVideoOutput { published: false, }) } + + pub fn try_lock_decode_target(&self) -> Option> { + let mut state = self.state.lock().ok()?; + let targets = state.targets?; + let index = state.free_slot()?; + state.writing = Some(index); + Some(DirectVideoTargetGuard { + state, + target: targets[index], + index, + published: false, + }) + } } pub struct DirectVideoTargetGuard<'a> { @@ -237,24 +256,15 @@ pub struct VideoMetrics { pub submitted: AtomicU64, /// Access units rejected because the queue was already full - i.e. pub queue_full: AtomicU64, - /// Calls into `HwVideoDecoder::decode`, and their cumulative wall time. pub decode_calls: AtomicU64, pub decode_us: AtomicU64, /// Decoder consumed the access unit but produced no picture (needs more data). pub no_frame: AtomicU64, /// Decode returned an error (or panicked); each one forces a decoder rebuild. pub decode_errors: AtomicU64, - /// Hardware decoder created from scratch (`sceVideodecInitLibrary` + CDRAM). pub decoder_rebuilds: AtomicU64, - /// `lock_decode_target` gave up waiting for the render thread to free a texture, so the - /// previous undisplayed frame was overwritten. pub target_stalls: AtomicU64, - /// Cumulative time the decode thread spent inside `lock_decode_target` waiting for the render - /// thread to hand a texture back. /// - /// `decode_us` deliberately excludes this, which makes the two easy to confuse: a `dec:` of - /// 1 ms alongside a saturated queue reads as "the decoder is idle" when it actually means - /// "the decoder is parked". Counted separately so the readout can tell those apart. pub target_wait_us: AtomicU64, pub target_wait_calls: AtomicU64, } diff --git a/src/streaming/video/worker.rs b/src/streaming/video/worker.rs index 2287dee..4fd8adb 100644 --- a/src/streaming/video/worker.rs +++ b/src/streaming/video/worker.rs @@ -1,8 +1,5 @@ // Adapted from green-vita (MPL-2.0, https://github.com/Day-OS/green-vita) // src/streaming/video/worker.rs - dedicated decode thread pulling H.264 access units from a -// bounded queue and publishing decoded frames through DirectVideoOutput. Metrics and the -// adaptive queue sizing were dropped in this port; results publish straight into the -// `(frame id, DecodedFrame)` slot the shell polls. See THIRD_PARTY_NOTICES.md. use super::decoder::HwVideoDecoder; use super::{ @@ -10,26 +7,26 @@ use super::{ VideoTextureTarget, }; use anyhow::{Context, Result}; -use crossbeam_channel::{Receiver, Sender, TrySendError, bounded, select_biased, unbounded}; +use crossbeam_channel::{ + Receiver, Sender, TryRecvError, TrySendError, after, bounded, never, select, unbounded, +}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; -// capped at 2 for lower input lag, was 4 but that felt laggy -const MAX_PENDING_ACCESS_UNITS: usize = 2; +const AU_QUEUE_CAP: usize = 6; +const AU_QUEUE_FLOOR: usize = 1; +const PICTURE_POLL_INTERVAL: Duration = Duration::from_millis(1); const BLANK_FRAME_FALLBACK_STREAK: u32 = 30; const BLANK_FRAME_SAMPLE_BYTES: usize = 512; -/// Best-effort check for "the decoder said it produced a frame, but the buffer is all zero" - see -/// `BLANK_FRAME_FALLBACK_STREAK`. fn output_looks_blank(target: VideoTextureTarget) -> bool { let len = (target.capacity as usize).min(BLANK_FRAME_SAMPLE_BYTES); if len == 0 { return false; } - // SAFETY: `target` was just written into by `HwVideoDecoder::decode` on this same thread; - // `len` is bounded by the texture's own reported capacity. let sample = unsafe { std::slice::from_raw_parts(target.ptr as *const u8, len) }; sample.iter().all(|&byte| byte == 0) } @@ -44,11 +41,21 @@ enum DecoderCommand { Stop, } +#[derive(Default)] +struct DecoderOutputState { + latest_published_pts: u64, + blank_streak: u32, + blank_check_active: bool, + picture_pending: bool, +} + pub struct VideoDecodeWorker { access_units: Sender, + drop_oldest: Receiver, commands: Sender, generation: Arc, metrics: Arc, + preferred_fps: u32, } impl VideoDecodeWorker { @@ -58,10 +65,12 @@ impl VideoDecodeWorker { direct_output: Arc, latest_frame: Arc>>, ) -> Result { + let preferred_fps = crate::gfn::stream_prefs::fps_value(); let decoder = HwVideoDecoder::new(config).context("failed to create hardware H264 decoder")?; direct_output.decoder_ready.store(true, Ordering::Release); - let (access_units, worker_access_units) = bounded(MAX_PENDING_ACCESS_UNITS); + let (access_units, worker_access_units) = bounded(AU_QUEUE_CAP); + let drop_oldest = worker_access_units.clone(); let (commands, worker_commands) = unbounded(); let generation = Arc::new(AtomicU64::new(0)); let worker_generation = Arc::clone(&generation); @@ -91,9 +100,11 @@ impl VideoDecodeWorker { Ok(Self { access_units, + drop_oldest, commands, generation, metrics, + preferred_fps, }) } @@ -102,21 +113,51 @@ impl VideoDecodeWorker { Arc::clone(&self.metrics) } - /// Queues one Annex-B access unit; drops it (returning `false`) when the decoder is falling - /// behind, which is preferable to buffering latency. - pub fn submit_access_unit(&self, data: Vec) -> bool { + pub fn submit_access_unit( + &self, + data: Vec, + source_frame_duration_us: Option, + ) -> bool { + let source_fps = source_frame_duration_us + .filter(|duration| *duration > 0) + .map(|duration| 1_000_000 / duration) + .unwrap_or(u64::from(self.preferred_fps)); + let extra_capacity = source_fps + .saturating_sub(30) + .min(25) + .saturating_mul((AU_QUEUE_CAP - AU_QUEUE_FLOOR) as u64) + .saturating_add(12) + / 25; + let pending_limit = AU_QUEUE_FLOOR + extra_capacity as usize; + let access_unit = QueuedAccessUnit { data, generation: self.generation.load(Ordering::Acquire), }; + + if self.access_units.len() >= pending_limit { + self.metrics.queue_full.fetch_add(1, Ordering::Relaxed); + match self.drop_oldest.try_recv() { + Ok(_) | Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => return false, + } + } + match self.access_units.try_send(access_unit) { Ok(()) => { self.metrics.submitted.fetch_add(1, Ordering::Relaxed); true } - Err(TrySendError::Full(_)) => { + Err(TrySendError::Full(access_unit)) => { self.metrics.queue_full.fetch_add(1, Ordering::Relaxed); - false + let _ = self.drop_oldest.try_recv(); + match self.access_units.try_send(access_unit) { + Ok(()) => { + self.metrics.submitted.fetch_add(1, Ordering::Relaxed); + true + } + Err(_) => false, + } } Err(TrySendError::Disconnected(_)) => false, } @@ -128,8 +169,6 @@ impl VideoDecodeWorker { let _ = self.commands.send(DecoderCommand::Reset); } - /// Discards everything currently queued but keeps the hardware decoder alive - the correct - /// response to packet damage. pub fn begin_resync(&self) { self.generation.fetch_add(1, Ordering::AcqRel); } @@ -154,20 +193,33 @@ fn run_decode_loop( ) { let mut decoder = Some(initial_decoder); let mut frame_id: u64 = 0; - let mut blank_streak: u32 = 0; + let mut output_state = DecoderOutputState { + blank_check_active: true, + ..DecoderOutputState::default() + }; loop { - select_biased! { + let picture_poll = if output_state.picture_pending { + after(PICTURE_POLL_INTERVAL) + } else { + never() + }; + + select! { recv(commands) -> command => match command { Ok(DecoderCommand::Reset) => { decoder = None; + output_state = DecoderOutputState { + blank_check_active: true, + ..DecoderOutputState::default() + }; continue; } Ok(DecoderCommand::Stop) | Err(_) => break, }, recv(access_units) -> access_unit => { let Ok(access_unit) = access_unit else { break }; - decode_queued_access_unit( + submit_queued_access_unit( &mut decoder, config, &generation, @@ -175,7 +227,17 @@ fn run_decode_loop( &mut frame_id, access_unit, &direct_output, - &mut blank_streak, + &mut output_state, + &metrics, + ); + }, + recv(picture_poll) -> _ => { + drain_picture( + &mut decoder, + &latest_frame, + &mut frame_id, + &direct_output, + &mut output_state, &metrics, ); } @@ -184,7 +246,7 @@ fn run_decode_loop( } #[allow(clippy::too_many_arguments)] -fn decode_queued_access_unit( +fn submit_queued_access_unit( decoder: &mut Option, config: DecoderConfig, generation: &AtomicU64, @@ -192,7 +254,7 @@ fn decode_queued_access_unit( frame_id: &mut u64, access_unit: QueuedAccessUnit, direct_output: &DirectVideoOutput, - blank_streak: &mut u32, + output_state: &mut DecoderOutputState, metrics: &VideoMetrics, ) { if access_unit.generation != generation.load(Ordering::Acquire) { @@ -210,53 +272,114 @@ fn decode_queued_access_unit( } } - let Some(pixel_format) = direct_output.pixel_format() else { - return; - }; - // Timed around the whole call rather than inside it, so plain mutex contention against the - // render thread's `mark_displayed` counts too - that is the other half of the same stall. - let wait_started_at = std::time::Instant::now(); - let direct_target = direct_output.lock_decode_target(&metrics.target_stalls); - metrics.target_wait_us.fetch_add( - wait_started_at.elapsed().as_micros() as u64, - Ordering::Relaxed, - ); - metrics.target_wait_calls.fetch_add(1, Ordering::Relaxed); - let Some(direct_target) = direct_target else { - return; - }; - let decode_started_at = std::time::Instant::now(); - let decode_result = catch_unwind(AssertUnwindSafe(|| { + let submit_result = catch_unwind(AssertUnwindSafe(|| { decoder .as_mut() .expect("decoder recreated above") - .decode(&access_unit.data, direct_target.target(), pixel_format) + .submit_access_unit(&access_unit.data) })); metrics.decode_calls.fetch_add(1, Ordering::Relaxed); - metrics - .decode_us - .fetch_add(decode_started_at.elapsed().as_micros() as u64, Ordering::Relaxed); + + match submit_result { + Ok(Ok(())) => { + output_state.picture_pending = true; + } + Ok(Err(error)) => { + eprintln!("H264 AU submit error, recreating decoder: {error:#}"); + metrics.decode_errors.fetch_add(1, Ordering::Relaxed); + *decoder = None; + output_state.picture_pending = false; + return; + } + Err(_) => { + eprintln!("H264 decoder panicked on submit; recreating on next frame"); + metrics.decode_errors.fetch_add(1, Ordering::Relaxed); + *decoder = None; + output_state.picture_pending = false; + return; + } + } + if access_unit.generation != generation.load(Ordering::Acquire) { return; } - match decode_result { - Ok(Ok(true)) => { - if pixel_format == VideoPixelFormat::Bgr565 { + drain_picture( + decoder, + latest_frame, + frame_id, + direct_output, + output_state, + metrics, + ); +} + +fn drain_picture( + decoder: &mut Option, + latest_frame: &Mutex>, + frame_id: &mut u64, + direct_output: &DirectVideoOutput, + output_state: &mut DecoderOutputState, + metrics: &VideoMetrics, +) { + let Some(decoder_instance) = decoder.as_mut() else { + output_state.picture_pending = false; + return; + }; + if decoder_instance.submitted_sequence() == 0 { + output_state.picture_pending = false; + return; + } + + let Some(pixel_format) = direct_output.pixel_format() else { + return; + }; + + let Some(direct_target) = direct_output.try_lock_decode_target() else { + metrics.target_stalls.fetch_add(1, Ordering::Relaxed); + output_state.picture_pending = true; + return; + }; + metrics.target_wait_calls.fetch_add(1, Ordering::Relaxed); + + let picture_result = catch_unwind(AssertUnwindSafe(|| { + decoder_instance.get_picture(direct_target.target(), pixel_format) + })); + + match picture_result { + Ok(Ok(Some(returned_pts))) => { + if output_state.blank_check_active + && matches!( + pixel_format, + VideoPixelFormat::Bgr565 | VideoPixelFormat::Rgba8888 + ) + { if output_looks_blank(direct_target.target()) { - *blank_streak += 1; - if *blank_streak >= BLANK_FRAME_FALLBACK_STREAK { + output_state.blank_streak += 1; + if output_state.blank_streak >= BLANK_FRAME_FALLBACK_STREAK { eprintln!( - "Bgr565 decoded {BLANK_FRAME_FALLBACK_STREAK} frames in a row with \ - blank output (Vita3K-style HLE gap); requesting Iyuv fallback" + "{pixel_format:?} decoded {BLANK_FRAME_FALLBACK_STREAK} frames in a \ + row with blank output (Vita3K-style HLE gap); requesting Iyuv fallback" ); direct_output.request_format_fallback(); - *blank_streak = 0; + output_state.blank_streak = 0; + output_state.blank_check_active = false; } } else { - *blank_streak = 0; + output_state.blank_streak = 0; + output_state.blank_check_active = false; } } + + if returned_pts <= output_state.latest_published_pts + && output_state.latest_published_pts != 0 + { + drop(direct_target); + metrics.no_frame.fetch_add(1, Ordering::Relaxed); + output_state.picture_pending = true; + return; + } + output_state.latest_published_pts = returned_pts; let (texture_index, generation) = direct_target.publish(); *frame_id += 1; if let Ok(mut slot) = latest_frame.lock() { @@ -268,19 +391,23 @@ fn decode_queued_access_unit( }, )); } + output_state.picture_pending = true; } - Ok(Ok(false)) => { + Ok(Ok(None)) => { metrics.no_frame.fetch_add(1, Ordering::Relaxed); + output_state.picture_pending = false; } Ok(Err(error)) => { - eprintln!("H264 decode error, recreating decoder: {error:#}"); + eprintln!("H264 get_picture error, recreating decoder: {error:#}"); metrics.decode_errors.fetch_add(1, Ordering::Relaxed); *decoder = None; + output_state.picture_pending = false; } Err(_) => { - eprintln!("H264 decoder panicked; recreating decoder on next frame"); + eprintln!("H264 decoder panicked on get_picture; recreating on next frame"); metrics.decode_errors.fetch_add(1, Ordering::Relaxed); *decoder = None; + output_state.picture_pending = false; } } }