diff --git a/Cargo.lock b/Cargo.lock index aa18df8d..c6f6696f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2777,6 +2777,7 @@ version = "0.8.2" dependencies = [ "anyhow", "ash", + "pf-client-core", "pf-presenter", "sdl3", "skia-safe", @@ -3012,6 +3013,7 @@ dependencies = [ "pf-console-ui", "pf-presenter", "punktfunk-core", + "serde_json", "tracing", "tracing-subscriber", ] diff --git a/clients/linux-session/Cargo.toml b/clients/linux-session/Cargo.toml index 017e4a7e..d82337ca 100644 --- a/clients/linux-session/Cargo.toml +++ b/clients/linux-session/Cargo.toml @@ -17,7 +17,7 @@ default = ["ui"] # The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it # (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming, # stats on stdout only. -ui = ["dep:pf-console-ui"] +ui = ["dep:pf-console-ui", "dep:serde_json"] # Same Linux gating as the rest of the client stack; elsewhere this is a stub binary. [target.'cfg(target_os = "linux")'.dependencies] @@ -25,6 +25,8 @@ pf-presenter = { path = "../../crates/pf-presenter" } pf-console-ui = { path = "../../crates/pf-console-ui", optional = true } pf-client-core = { path = "../../crates/pf-client-core" } punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } +# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON. +serde_json = { version = "1", optional = true } anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/clients/linux-session/README.md b/clients/linux-session/README.md index 12a89673..51769077 100644 --- a/clients/linux-session/README.md +++ b/clients/linux-session/README.md @@ -7,8 +7,15 @@ presenter of the Linux client re-architecture (punktfunk-planning: ``` punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats] +punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] ``` +`--browse` opens the console game library (the Skia coverflow over the animated aurora) +instead of connecting: A launches the focused title as a stream in the same window, +session end returns to the library, B quits (Gaming Mode returns). Paired hosts only — +pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=` +feeds canned entries with no host (portrait paths starting with `/` load from disk). + Reads the same identity / known-hosts / settings stores as the desktop client (`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). diff --git a/clients/linux-session/src/browse.rs b/clients/linux-session/src/browse.rs new file mode 100644 index 00000000..ac3efd27 --- /dev/null +++ b/clients/linux-session/src/browse.rs @@ -0,0 +1,184 @@ +//! `--browse host[:port]` — the console game library (phase 4b of the plan): the Skia +//! coverflow idles in the session window, A launches the focused title as a stream in +//! the SAME window (no gamescope window handoff — the whole point of one process), the +//! session's end returns to the library, B quits to Gaming Mode. +//! +//! The host must already be paired (the stored pin fetches the library and connects +//! silently; no ceremony can run under gamescope) — an unpaired target renders the +//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=` feeds canned entries with no +//! host (portrait paths starting with `/` load from disk), the GPU-only dev path. + +use crate::session_main::{arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params}; +use pf_client_core::{library, trust}; +use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay}; +use pf_presenter::overlay::OverlayAction; +use pf_presenter::ActionOutcome; +use std::collections::VecDeque; + +pub fn run(target: &str) -> u8 { + let (addr, port) = parse_host_port(target); + let known = trust::KnownHosts::load(); + let k = known + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port); + let host_label = k.map_or_else(|| addr.clone(), |h| h.name.clone()); + let paired = k.is_some_and(|h| h.paired); + let pin = k.and_then(|h| trust::parse_hex32(&h.fp_hex)); + let mgmt = arg_value("--mgmt") + .and_then(|p| p.parse().ok()) + .unwrap_or(library::DEFAULT_MGMT_PORT); + + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return crate::session_main::EXIT_CONNECT_FAILED; + } + }; + let settings = trust::Settings::load(); + + let (overlay, shared) = match SkiaOverlay::with_library(host_label.clone()) { + Ok(v) => v, + Err(e) => { + eprintln!("console UI: {e:#}"); + return crate::session_main::EXIT_PRESENTER_FAILED; + } + }; + + // The library fetch — paired hosts only (the fake-library hook exists precisely for + // host-less/pairing-less UI work). + let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some(); + if paired || fake { + spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin); + } else { + shared.set_phase(LibraryPhase::PairFirst); + } + + let opts = pf_presenter::SessionOpts { + window_title: format!("Punktfunk · {host_label}"), + fullscreen: fullscreen_mode(), + print_stats: settings.show_stats || arg_flag("--stats"), + json_status: false, // browse has no shell parent reading stdout + on_connected: Some(Box::new(|fingerprint: [u8; 32]| { + trust::touch_last_used(&trust::hex(&fingerprint)); + })), + overlay: Some(Box::new(overlay)), + }; + + let result = pf_presenter::run_browse(opts, |action, gamepad, native, force_software| { + match action { + OverlayAction::Launch { id, title } => { + // The carousel only renders for a paired host, so the pin exists; the + // guard keeps a logic slip from turning into a pinless connect. + let Some(pin) = pin else { + tracing::warn!("launch without a stored pin — refusing"); + return ActionOutcome::Handled; + }; + tracing::info!(%id, %title, "launching from the library"); + ActionOutcome::Start(session_params( + &settings, + addr.clone(), + port, + pin, + identity.clone(), + Some(id), + gamepad, + native, + force_software, + )) + } + OverlayAction::Retry => { + spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin); + ActionOutcome::Handled + } + OverlayAction::Quit => ActionOutcome::Quit, + } + }); + + match result { + Ok(()) => 0, + Err(e) => { + eprintln!("browse: {e:#}"); + crate::session_main::EXIT_PRESENTER_FAILED + } + } +} + +/// Fetch the library off the main thread, then stream poster art into the shared model +/// as results land (the GTK launcher's `load` + `load_art`, minus the main-loop hops — +/// the renderer drains `push_art` per frame). +fn spawn_fetch( + shared: LibraryShared, + addr: String, + mgmt: u16, + identity: (String, String), + pin: Option<[u8; 32]>, +) { + shared.set_phase(LibraryPhase::Loading); + std::thread::Builder::new() + .name("punktfunk-library".into()) + .spawn(move || { + if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") { + load_fake(&shared, &path); + return; + } + match library::fetch_games(&addr, mgmt, &identity, pin) { + Ok(games) => { + let base = library::base_url(&addr, mgmt); + let jobs: VecDeque<(String, Vec)> = games + .iter() + .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) + .filter(|(_, candidates)| !candidates.is_empty()) + .collect(); + shared.set_games( + games + .iter() + .map(|g| LibraryGame { + id: g.id.clone(), + title: g.title.clone(), + store: g.store.clone(), + }) + .collect(), + ); + if !jobs.is_empty() { + let rx = library::spawn_art_fetch(base, identity, pin, jobs); + while let Ok((id, bytes)) = rx.recv_blocking() { + shared.push_art(id, bytes); + } + } + } + Err(e) => shared.set_phase(LibraryPhase::Error { + title: "Couldn't load the library".into(), + body: e.to_string(), + can_retry: true, + }), + } + }) + .ok(); +} + +/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk. +fn load_fake(shared: &LibraryShared, path: &str) { + let games: Vec = std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for g in &games { + if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) { + if let Ok(bytes) = std::fs::read(p) { + shared.push_art(g.id.clone(), bytes); + } + } + } + shared.set_games( + games + .iter() + .map(|g| LibraryGame { + id: g.id.clone(), + title: g.title.clone(), + store: g.store.clone(), + }) + .collect(), + ); +} diff --git a/clients/linux-session/src/main.rs b/clients/linux-session/src/main.rs index be8e8980..2d7e5a98 100644 --- a/clients/linux-session/src/main.rs +++ b/clients/linux-session/src/main.rs @@ -12,11 +12,17 @@ //! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end, //! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed. +#[cfg(all(target_os = "linux", feature = "ui"))] +mod browse; + #[cfg(target_os = "linux")] mod session_main { + use pf_client_core::gamepad::GamepadService; use pf_client_core::session::SessionParams; use pf_client_core::trust; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; + use std::sync::atomic::AtomicBool; + use std::sync::Arc; use std::time::Duration; pub const EXIT_CONNECT_FAILED: u8 = 2; @@ -24,24 +30,32 @@ mod session_main { pub const EXIT_PRESENTER_FAILED: u8 = 4; /// The value following `flag` in argv, if present (`--flag value`). - fn arg_value(flag: &str) -> Option { + pub(crate) fn arg_value(flag: &str) -> Option { std::env::args() .skip_while(|a| a != flag) .nth(1) .filter(|v| !v.starts_with("--")) } - fn arg_flag(flag: &str) -> bool { + pub(crate) fn arg_flag(flag: &str) -> bool { std::env::args().any(|a| a == flag) } + /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a + /// manual launch under Gaming Mode does the right thing too. + pub(crate) fn fullscreen_mode() -> bool { + arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + } + /// `host[:port]`, port defaulting to the native 9777. - fn parse_host_port(target: &str) -> (String, u16) { + pub(crate) fn parse_host_port(target: &str) -> (String, u16) { match target.rsplit_once(':') { Some((a, p)) => match p.parse() { Ok(port) => (a.to_string(), port), Err(_) => { - eprintln!("--connect: unparsable port in '{target}', using default 9777"); + eprintln!("unparsable port in '{target}', using default 9777"); (a.to_string(), 9777) } }, @@ -49,6 +63,65 @@ mod session_main { } } + /// One session's pump parameters from the Settings store — shared by `--connect` + /// and every `--browse` launch. Explicit settings, `0` fields resolved to the + /// window's display (the GTK client reads the monitor under its window — same + /// contract). + #[allow(clippy::too_many_arguments)] + pub(crate) fn session_params( + settings: &trust::Settings, + addr: String, + port: u16, + pin: [u8; 32], + identity: (String, String), + launch: Option, + gamepad: &GamepadService, + native: Mode, + force_software: Arc, + ) -> SessionParams { + let mode = Mode { + width: if settings.width == 0 { + native.width + } else { + settings.width + }, + height: if settings.width == 0 { + native.height + } else { + settings.height + }, + refresh_hz: if settings.refresh_hz == 0 { + native.refresh_hz.max(30) + } else { + settings.refresh_hz + }, + }; + SessionParams { + host: addr, + port, + mode, + compositor: CompositorPref::from_name(&settings.compositor) + .unwrap_or(CompositorPref::Auto), + gamepad: match GamepadPref::from_name(&settings.gamepad) { + Some(GamepadPref::Auto) | None => gamepad.auto_pref(), + Some(explicit) => explicit, + }, + bitrate_kbps: settings.bitrate_kbps, + audio_channels: settings.audio_channels, + preferred_codec: settings.preferred_codec(), + mic_enabled: settings.mic_enabled, + // The Settings preference (auto → VAAPI where it exists; the presenter + // demotes to software on boxes whose Vulkan can't import the dmabufs). + // PUNKTFUNK_DECODER still overrides inside the decoder for bisects. + decoder: settings.decoder.clone(), + launch, + pin: Some(pin), + identity, + connect_timeout: Duration::from_secs(15), + force_software, + } + } + /// One JSON status line on stdout (the shell parses these; strings hand-escaped via /// the minimal rules a reason string can need). fn json_line(key: &str, msg: &str, trust_rejected: Option) { @@ -91,11 +164,26 @@ mod session_main { } } + if let Some(target) = arg_value("--browse") { + #[cfg(feature = "ui")] + return crate::browse::run(&target); + #[cfg(not(feature = "ui"))] + { + let _ = target; + eprintln!( + "--browse needs the console UI — this is the minimal build \ + (rebuild without --no-default-features)" + ); + return EXIT_PRESENTER_FAILED; + } + } let Some(target) = arg_value("--connect") else { eprintln!( "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\ + \x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]\n\ \n\ - Streams from a paired punktfunk host in a Vulkan window. Pair first via the\n\ + Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\ + console game library instead (paired hosts only). Pair first via the\n\ desktop client or `punktfunk-client --pair --connect host[:port]` —\n\ this binary never connects to a host it has no pinned fingerprint for." ); @@ -161,41 +249,17 @@ mod session_main { }; let outcome = pf_presenter::run_session(opts, move |gamepad, native, force_software| { - // Explicit settings, `0` fields resolved to the window's display (the GTK - // client reads the monitor under its window — same contract). - let mode = Mode { - width: if settings.width == 0 { native.width } else { settings.width }, - height: if settings.width == 0 { native.height } else { settings.height }, - refresh_hz: if settings.refresh_hz == 0 { - native.refresh_hz.max(30) - } else { - settings.refresh_hz - }, - }; - SessionParams { - host: addr, + session_params( + &settings, + addr, port, - mode, - compositor: CompositorPref::from_name(&settings.compositor) - .unwrap_or(CompositorPref::Auto), - gamepad: match GamepadPref::from_name(&settings.gamepad) { - Some(GamepadPref::Auto) | None => gamepad.auto_pref(), - Some(explicit) => explicit, - }, - bitrate_kbps: settings.bitrate_kbps, - audio_channels: settings.audio_channels, - preferred_codec: settings.preferred_codec(), - mic_enabled: settings.mic_enabled, - // The Settings preference (auto → VAAPI where it exists; the presenter - // demotes to software on boxes whose Vulkan can't import the dmabufs). - // PUNKTFUNK_DECODER still overrides inside the decoder for bisects. - decoder: settings.decoder.clone(), - launch, - pin: Some(pin), + pin, identity, - connect_timeout: Duration::from_secs(15), + launch, + gamepad, + native, force_software, - } + ) }); match outcome { diff --git a/crates/pf-console-ui/Cargo.toml b/crates/pf-console-ui/Cargo.toml index a1ba6da0..b52bdb78 100644 --- a/crates/pf-console-ui/Cargo.toml +++ b/crates/pf-console-ui/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [target.'cfg(target_os = "linux")'.dependencies] pf-presenter = { path = "../pf-presenter" } +# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library). +pf-client-core = { path = "../pf-client-core" } # Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for # the typography the console library needs (~15 MB stripped, prebuilt binaries exist for diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index e461ad29..89726c54 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -8,8 +8,14 @@ //! purpose, it proves the whole shared-device pipeline. The gamepad library moves in //! next. +#[cfg(target_os = "linux")] +pub mod library; +#[cfg(target_os = "linux")] +mod library_ui; #[cfg(target_os = "linux")] mod skia_overlay; +#[cfg(target_os = "linux")] +pub use library::{LibraryGame, LibraryPhase, LibraryShared}; #[cfg(target_os = "linux")] pub use skia_overlay::SkiaOverlay; diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs new file mode 100644 index 00000000..49b0b361 --- /dev/null +++ b/crates/pf-console-ui/src/library.rs @@ -0,0 +1,473 @@ +//! The console library's model and math — everything about the coverflow that isn't +//! Skia: the shared binary↔overlay state (games, phase, incoming art bytes), the +//! spring-driven motion and cursor arithmetic (ported verbatim from the GTK launcher, +//! tests included), and the geometry constants. Rendering lives in `skia_overlay`. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +// --- Geometry (the GTK launcher's constants — Apple coverflow parity) -------------------- + +/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar +/// fit a Deck's 1280×800 with air. Scaled uniformly for other window sizes. +pub const POSTER_W: f64 = 220.0; +pub const POSTER_H: f64 = 330.0; +/// Center of the focused card to the center of its first neighbor. +pub const FOCUS_GAP: f64 = 230.0; +/// Center-to-center distance between successive SIDE cards — much tighter than their +/// projected width, so the side stacks overlap like the classic coverflow shelf. +pub const SIDE_SPACING: f64 = 104.0; +/// Cards farther than this from the eased position aren't drawn at all. +pub const VISIBLE_RANGE: f64 = 5.5; +/// Neighbors recede to this scale… +pub const RECEDE_SCALE: f64 = 0.24; +/// …and swing this many degrees about their own vertical axis under perspective, side +/// cards facing the corridor (their inner edge recedes behind the focus). +pub const ROTATE_DEG: f64 = 38.0; +/// Perspective depth for the tilt, px (CSS `perspective()` semantics). +pub const PERSPECTIVE: f64 = 800.0; +/// The darkening veil's max opacity (side cards stay opaque — they overlap). +pub const RECEDE_DIM: f64 = 0.30; +/// Boundary recoil: a refused move deflects the strip this many px against the push. +pub const BUMP_PX: f64 = 16.0; +/// L1/R1 jump distance. +pub const JUMP: i32 = 5; + +// The motion is spring-driven (semi-implicit Euler), not eased — velocity carries across +// retargets, so holding a direction glides and a release settles like a detent. +/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot. +pub const SPRING_K: f64 = 200.0; +pub const SPRING_C: f64 = 24.0; +/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble. +pub const BUMP_K: f64 = 600.0; +pub const BUMP_C: f64 = 27.0; + +/// One semi-implicit-Euler step of a damped spring toward `target`. +fn spring_step(pos: f64, vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) { + let vel = vel + (k * (target - pos) - c * vel) * dt; + (pos + vel * dt, vel) +} + +/// Advance a damped spring by a whole frame, integrating in ≤ 8 ms substeps — a stalled +/// frame stays far inside the integrator's stability bound, so the motion feels +/// identical at any frame rate. +pub fn spring_advance( + mut pos: f64, + mut vel: f64, + target: f64, + k: f64, + c: f64, + dt: f64, +) -> (f64, f64) { + let n = (dt / 0.008).ceil().max(1.0) as usize; + let h = dt / n as f64; + for _ in 0..n { + (pos, vel) = spring_step(pos, vel, target, k, c, h); + } + (pos, vel) +} + +/// Pure cursor arithmetic for a move/jump: `clamp` lands jumps on the ends, a plain +/// step refuses to leave them. +#[derive(Debug, PartialEq, Eq)] +pub enum StepResult { + Moved(i32), + Boundary, +} + +pub fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResult { + if len == 0 { + return StepResult::Boundary; + } + let max = len as i32 - 1; + let target = if clamp { + (cursor + delta).clamp(0, max) + } else { + cursor + delta + }; + if target == cursor || target < 0 || target > max { + StepResult::Boundary + } else { + StepResult::Moved(target) + } +} + +// --- 4×4 matrix (row-major) — the coverflow card transform ------------------------------ + +/// `T(cx,cy) · P(depth) · Ry(angle) · S(s) · T(-w/2,-h/2)`: card-local (0..w, 0..h) → +/// screen, rotated about the card's own vertical center axis under perspective — the +/// GSK transform chain from the GTK launcher, as one row-major matrix for +/// `Canvas::concat_44`. +#[allow(clippy::too_many_arguments)] +pub fn card_matrix( + cx: f64, + cy: f64, + angle_deg: f64, + scale: f64, + w: f64, + h: f64, + depth: f64, +) -> [f32; 16] { + let t1 = translate(cx, cy); + let p = perspective(depth); + let r = rotate_y(angle_deg.to_radians()); + let s = scale_xy(scale); + let t2 = translate(-w / 2.0, -h / 2.0); + let m = mat_mul(&mat_mul(&mat_mul(&mat_mul(&t1, &p), &r), &s), &t2); + core::array::from_fn(|i| m[i] as f32) +} + +fn translate(x: f64, y: f64) -> [f64; 16] { + let mut m = identity(); + m[3] = x; + m[7] = y; + m +} + +fn perspective(d: f64) -> [f64; 16] { + let mut m = identity(); + m[14] = -1.0 / d; // row 3, col 2 — w' = 1 − z/d (CSS convention) + m +} + +fn rotate_y(rad: f64) -> [f64; 16] { + let (s, c) = rad.sin_cos(); + let mut m = identity(); + m[0] = c; + m[2] = s; + m[8] = -s; + m[10] = c; + m +} + +fn scale_xy(s: f64) -> [f64; 16] { + let mut m = identity(); + m[0] = s; + m[5] = s; + m +} + +fn identity() -> [f64; 16] { + let mut m = [0.0; 16]; + m[0] = 1.0; + m[5] = 1.0; + m[10] = 1.0; + m[15] = 1.0; + m +} + +fn mat_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] { + let mut out = [0.0; 16]; + for r in 0..4 { + for c in 0..4 { + out[r * 4 + c] = (0..4).map(|k| a[r * 4 + k] * b[k * 4 + c]).sum(); + } + } + out +} + +// --- Aurora (the Swift `GamepadScreenBackground` blob table, verbatim) -------------------- + +/// One drifting color blob: base position + drift ellipse in unit coordinates, angular +/// speeds in rad/s, a breathing radius (fraction of the larger dimension), layer opacity. +pub struct Blob { + pub rgb: (f64, f64, f64), + pub center: (f64, f64), + pub drift: (f64, f64), + pub speed: (f64, f64), + pub phase: (f64, f64), + pub radius: f64, + pub breathe: (f64, f64), + pub opacity: f64, +} + +/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so +/// the field shifts within one temperature instead of strobing through the rainbow. +pub const BLOBS: [Blob; 4] = [ + Blob { + rgb: (0.53, 0.47, 0.96), + center: (0.30, 0.24), + drift: (0.16, 0.10), + speed: (0.111, 0.083), + phase: (0.0, 1.9), + radius: 0.52, + breathe: (0.07, 0.061), + opacity: 0.52, + }, + Blob { + rgb: (0.24, 0.20, 0.72), + center: (0.78, 0.66), + drift: (0.13, 0.14), + speed: (0.071, 0.096), + phase: (2.4, 0.7), + radius: 0.58, + breathe: (0.08, 0.049), + opacity: 0.55, + }, + Blob { + rgb: (0.62, 0.30, 0.80), + center: (0.16, 0.82), + drift: (0.12, 0.09), + speed: (0.089, 0.067), + phase: (4.1, 3.2), + radius: 0.44, + breathe: (0.09, 0.078), + opacity: 0.42, + }, + Blob { + rgb: (0.22, 0.38, 0.86), + center: (0.70, 0.12), + drift: (0.10, 0.08), + speed: (0.059, 0.104), + phase: (1.2, 5.0), + radius: 0.40, + breathe: (0.06, 0.055), + opacity: 0.38, + }, +]; + +/// The aurora as SkSL: the blob table baked into the source (only time + resolution are +/// uniforms), each blob an additive radial falloff to `radius/2` — the Cairo gradient's +/// stops — then the legibility scrim's piecewise-linear vertical darkening. Runs on the +/// GPU at full rate; the 30 Hz CPU-upscale path (and its frozen-on-Deck fallback) is the +/// thing this whole port deletes. +pub fn aurora_sksl() -> String { + let mut src = String::from( + "uniform float2 u_res;\nuniform float u_t;\n\ + half4 main(float2 xy) {\n\ + float side = max(u_res.x, u_res.y);\n\ + float3 acc = float3(0.0);\n\ + float2 c; float r; float a;\n", + ); + for b in &BLOBS { + src.push_str(&format!( + " c = float2(({cx} + {dx} * sin(u_t * {sx} + {px})) * u_res.x, \ + ({cy} + {dy} * cos(u_t * {sy} + {py})) * u_res.y);\n\ + r = side * {radius} * (1.0 + {b0} * sin(u_t * {b1} + {px}));\n\ + a = max(0.0, 1.0 - distance(xy, c) / (r * 0.5));\n\ + acc += float3({r0}, {g0}, {b0c}) * (a * {op});\n", + cx = b.center.0, + cy = b.center.1, + dx = b.drift.0, + dy = b.drift.1, + sx = b.speed.0, + sy = b.speed.1, + px = b.phase.0, + py = b.phase.1, + radius = b.radius, + b0 = b.breathe.0, + b1 = b.breathe.1, + r0 = b.rgb.0, + g0 = b.rgb.1, + b0c = b.rgb.2, + op = b.opacity, + )); + } + // Scrim stops (0, .55) (0.35, .15) (0.65, .20) (1, .60): title (top) and + // detail/hints (bottom) stay on near-black whatever the blobs do behind them. + src.push_str( + " float v = xy.y / u_res.y;\n\ + float s = v < 0.35 ? mix(0.55, 0.15, v / 0.35)\n\ + : v < 0.65 ? mix(0.15, 0.20, (v - 0.35) / 0.30)\n\ + : mix(0.20, 0.60, (v - 0.65) / 0.35);\n\ + return half4(half3(acc * (1.0 - s)), 1.0);\n\ + }\n", + ); + src +} + +// --- The shared binary↔overlay model ------------------------------------------------------ + +#[derive(Clone, PartialEq)] +pub enum LibraryPhase { + Loading, + /// Browse target isn't paired — pairing is the plugin's job, render the advice. + PairFirst, + Error { + title: String, + body: String, + can_retry: bool, + }, + Empty, + /// Games are loaded — the carousel. + Ready, +} + +#[derive(Clone)] +pub struct LibraryGame { + pub id: String, + pub title: String, + pub store: String, +} + +struct Shared { + phase: LibraryPhase, + games: Vec, + /// Fetched poster bytes the renderer hasn't decoded yet (id, encoded image). + art_in: VecDeque<(String, Vec)>, + /// Bumped on phase/games changes so the renderer re-syncs its snapshot. + generation: u64, +} + +/// The binary's write handle / the overlay's read handle — fetch threads push into it, +/// the renderer drains per frame. Cheap locks, no rendering data inside. +#[derive(Clone)] +pub struct LibraryShared(Arc>); + +impl Default for LibraryShared { + fn default() -> Self { + LibraryShared(Arc::new(Mutex::new(Shared { + phase: LibraryPhase::Loading, + games: Vec::new(), + art_in: VecDeque::new(), + generation: 0, + }))) + } +} + +impl LibraryShared { + pub fn set_phase(&self, phase: LibraryPhase) { + let mut s = self.0.lock().unwrap(); + s.phase = phase; + s.generation += 1; + } + + /// Loaded games → the carousel (empty = the empty scene). + pub fn set_games(&self, games: Vec) { + let mut s = self.0.lock().unwrap(); + s.phase = if games.is_empty() { + LibraryPhase::Empty + } else { + LibraryPhase::Ready + }; + s.games = games; + s.generation += 1; + } + + pub fn push_art(&self, id: String, bytes: Vec) { + self.0.lock().unwrap().art_in.push_back((id, bytes)); + } + + /// Renderer side: the generation stamp (re-snapshot on change). + pub(crate) fn generation(&self) -> u64 { + self.0.lock().unwrap().generation + } + + pub(crate) fn snapshot(&self) -> (LibraryPhase, Vec, u64) { + let s = self.0.lock().unwrap(); + (s.phase.clone(), s.games.clone(), s.generation) + } + + pub(crate) fn drain_art(&self) -> Vec<(String, Vec)> { + self.0.lock().unwrap().art_in.drain(..).collect() + } +} + +/// Store id → display label (the GTK `ui_library` table). +pub fn store_label(store: &str) -> &'static str { + match store { + "steam" => "Steam", + "custom" => "Custom", + "heroic" => "Heroic", + "lutris" => "Lutris", + "epic" => "Epic", + "gog" => "GOG", + "xbox" => "Xbox", + _ => "Game", + } +} + +/// Monogram for the placeholder tile: the first letters of the first two words. +pub fn initials(title: &str) -> String { + title + .split_whitespace() + .take(2) + .filter_map(|w| w.chars().next()) + .flat_map(char::to_uppercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The GTK launcher's cursor tests, ported with the math. + #[test] + fn step_refuses_the_ends() { + assert_eq!(step_cursor(0, 5, -1, false), StepResult::Boundary); + assert_eq!(step_cursor(4, 5, 1, false), StepResult::Boundary); + assert_eq!(step_cursor(2, 5, 1, false), StepResult::Moved(3)); + assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary); + } + + #[test] + fn jump_clamps_onto_the_ends() { + assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0)); + assert_eq!(step_cursor(3, 5, JUMP, true), StepResult::Moved(4)); + assert_eq!(step_cursor(0, 5, -JUMP, true), StepResult::Boundary); + } + + /// Springs converge onto the target and stay finite through a stalled frame. + #[test] + fn springs_converge() { + let (mut pos, mut vel) = (0.0, 0.0); + for _ in 0..120 { + (pos, vel) = spring_advance(pos, vel, 3.0, SPRING_K, SPRING_C, 1.0 / 60.0); + } + assert!((pos - 3.0).abs() < 0.01, "{pos}"); + let (p, v) = spring_advance(0.0, 0.0, 1.0, BUMP_K, BUMP_C, 0.05); + assert!(p.is_finite() && v.is_finite() && p > 0.0 && p < 2.0, "{p}/{v}"); + } + + /// The focused card (angle 0, scale 1) maps its center to (cx, cy) exactly. + #[test] + fn card_matrix_centers_the_focused_card() { + let m = card_matrix(640.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE); + // Apply to the card-local center (w/2, h/2, 0, 1). + let (x, y) = (POSTER_W as f32 / 2.0, POSTER_H as f32 / 2.0); + let px = m[0] * x + m[1] * y + m[3]; + let py = m[4] * x + m[5] * y + m[7]; + let pw = m[12] * x + m[13] * y + m[15]; + assert!((px / pw - 640.0).abs() < 0.01, "{}", px / pw); + assert!((py / pw - 400.0).abs() < 0.01, "{}", py / pw); + } + + /// A right-side card's INNER (left) edge recedes: its projected x compresses toward + /// the center relative to the flat card — the coverflow corridor. + #[test] + fn side_card_inner_edge_recedes() { + let flat = card_matrix(900.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE); + let tilted = card_matrix(900.0, 400.0, -ROTATE_DEG, 1.0, POSTER_W, POSTER_H, PERSPECTIVE); + let project = |m: &[f32; 16], x: f32, y: f32| { + let px = m[0] * x + m[1] * y + m[3]; + let pw = m[12] * x + m[13] * y + m[15]; + px / pw + }; + // The inner edge is x=0 in card space. Perspective divide: receding (w < 1 side) + // pushes it AWAY from the vanishing center — the edge reads as farther. + let flat_left = project(&flat, 0.0, POSTER_H as f32 / 2.0); + let tilt_left = project(&tilted, 0.0, POSTER_H as f32 / 2.0); + let flat_right = project(&flat, POSTER_W as f32, POSTER_H as f32 / 2.0); + let tilt_right = project(&tilted, POSTER_W as f32, POSTER_H as f32 / 2.0); + // Tilt narrows the card's projected width (it turned away from the viewer). + assert!((tilt_right - tilt_left) < (flat_right - flat_left) * 0.95); + } + + #[test] + fn initials_take_two_words() { + assert_eq!(initials("Dota 2"), "D2"); + assert_eq!(initials("half-life"), "H"); + } + + /// The generated SkSL parses as far as syntax we control (sanity: balanced braces, + /// all four blobs baked in). + #[test] + fn aurora_sksl_shape() { + let src = aurora_sksl(); + assert_eq!(src.matches("acc +=").count(), 4); + assert_eq!( + src.matches('{').count(), + src.matches('}').count(), + ); + } +} diff --git a/crates/pf-console-ui/src/library_ui.rs b/crates/pf-console-ui/src/library_ui.rs new file mode 100644 index 00000000..67fee39c --- /dev/null +++ b/crates/pf-console-ui/src/library_ui.rs @@ -0,0 +1,610 @@ +//! The console library's Skia side: navigation state, scene selection, and rendering — +//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The +//! aurora runs as an SkSL shader at full rate (the 30 Hz CPU path is gone), the +//! coverflow is `concat_44` perspective with paint order = draw order (the restack hack +//! is gone), and every state renders in-scene (gamescope maps no dialogs). + +use crate::library::{ + aurora_sksl, card_matrix, initials, spring_advance, step_cursor, store_label, + LibraryGame, LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, + JUMP, PERSPECTIVE, POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, + SPRING_C, SPRING_K, VISIBLE_RANGE, +}; +use anyhow::{anyhow, Result}; +use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; +use pf_presenter::overlay::OverlayAction; +use skia_safe::textlayout::{FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle}; +use skia_safe::{ + Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect, + Typeface, M44, +}; +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +pub(crate) struct LibraryUi { + shared: LibraryShared, + host_label: String, + // Synced snapshot of the shared model (re-pulled when the generation bumps). + generation: u64, + phase: LibraryPhase, + games: Vec, + // Navigation: the integer cursor is the authority; the eased position chases it. + cursor: i32, + anim_pos: f64, + anim_vel: f64, + bump: f64, + bump_vel: f64, + last_frame: Option, + t0: Instant, + /// Decoded posters by game id (decode once; Skia uploads lazily on first draw). + art: HashMap, + aurora: RuntimeEffect, + /// A launch is in flight — menu input parks, the hint bar says Connecting…. + connecting: bool, + /// A session is on screen — the library doesn't render (stream chrome does). + pub(crate) in_stream: bool, + /// Transient error strip on the carousel (connect failures land here). + status: Option, + actions: VecDeque, +} + +impl LibraryUi { + pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result { + let aurora = RuntimeEffect::make_for_shader(aurora_sksl(), None) + .map_err(|e| anyhow!("aurora SkSL: {e}"))?; + Ok(LibraryUi { + shared, + host_label, + generation: u64::MAX, // force the first sync + phase: LibraryPhase::Loading, + games: Vec::new(), + cursor: 0, + anim_pos: 0.0, + anim_vel: 0.0, + bump: 0.0, + bump_vel: 0.0, + last_frame: None, + t0: Instant::now(), + art: HashMap::new(), + aurora, + connecting: false, + in_stream: false, + status: None, + actions: VecDeque::new(), + }) + } + + /// Pull the shared model when it changed; decode any newly arrived poster bytes. + pub(crate) fn sync(&mut self) { + if self.shared.generation() != self.generation { + let (phase, games, generation) = self.shared.snapshot(); + let fresh_games = self.games.len() != games.len() + || self + .games + .iter() + .zip(&games) + .any(|(a, b)| a.id != b.id); + self.phase = phase; + self.games = games; + self.generation = generation; + if fresh_games { + // Fresh library: snap the sprung position onto the (reset) cursor. + self.cursor = 0; + self.anim_pos = 0.0; + self.anim_vel = 0.0; + self.bump = 0.0; + self.bump_vel = 0.0; + } + self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0)); + } + for (id, bytes) in self.shared.drain_art() { + match Image::from_encoded(Data::new_copy(&bytes)) { + Some(img) => { + self.art.insert(id, img); + } + None => tracing::debug!(%id, "undecodable poster"), + } + } + } + + /// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too). + pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option { + if self.connecting { + return None; // a connect is in flight — input is parked + } + match &self.phase { + LibraryPhase::Ready => match ev { + MenuEvent::Move(MenuDir::Left) => self.step(-1, false), + MenuEvent::Move(MenuDir::Right) => self.step(1, false), + MenuEvent::JumpBack => self.step(-JUMP, true), + MenuEvent::JumpForward => self.step(JUMP, true), + MenuEvent::Confirm => { + let g = self.games.get(self.cursor as usize)?; + self.actions.push_back(OverlayAction::Launch { + id: g.id.clone(), + title: g.title.clone(), + }); + self.status = None; + self.connecting = true; + Some(MenuPulse::Confirm) + } + MenuEvent::Back => { + self.actions.push_back(OverlayAction::Quit); + None + } + MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None, + }, + LibraryPhase::Error { can_retry, .. } => match ev { + MenuEvent::Confirm if *can_retry => { + self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it + self.actions.push_back(OverlayAction::Retry); + Some(MenuPulse::Confirm) + } + MenuEvent::Back => { + self.actions.push_back(OverlayAction::Quit); + None + } + _ => None, + }, + LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => { + if ev == MenuEvent::Back { + self.actions.push_back(OverlayAction::Quit); + } + None + } + } + } + + /// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully + /// drivable with no pad. Returns true when consumed. + pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool { + use sdl3::keyboard::Scancode as S; + let ev = match sc { + S::Left => MenuEvent::Move(MenuDir::Left), + S::Right => MenuEvent::Move(MenuDir::Right), + S::Up => MenuEvent::Move(MenuDir::Up), + S::Down => MenuEvent::Move(MenuDir::Down), + S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm, + S::Escape | S::Backspace if !repeat => MenuEvent::Back, + S::PageUp if !repeat => MenuEvent::JumpBack, + S::PageDown if !repeat => MenuEvent::JumpForward, + _ => return false, + }; + self.menu(ev); // no pad to pulse + true + } + + pub(crate) fn take_action(&mut self) -> Option { + self.actions.pop_front() + } + + pub(crate) fn set_connecting(&mut self, on: bool) { + self.connecting = on; + if on { + self.status = None; + } + } + + /// A launch that didn't stick (connect failed / session ended with a reason): + /// carousel-visible errors land on the transient strip, anything else becomes the + /// error scene (no retry — the library itself is fine). + pub(crate) fn session_error(&mut self, msg: &str) { + self.connecting = false; + self.in_stream = false; + if self.phase == LibraryPhase::Ready { + self.status = Some(format!("Couldn't connect — {msg}")); + } else { + self.phase = LibraryPhase::Error { + title: "Couldn't connect".into(), + body: msg.to_string(), + can_retry: false, + }; + } + } + + fn step(&mut self, delta: i32, clamp: bool) -> Option { + match step_cursor(self.cursor, self.games.len(), delta, clamp) { + StepResult::Moved(to) => { + self.cursor = to; + Some(MenuPulse::Move) + } + StepResult::Boundary => { + // Recoil against the push; the stiff bump spring wobbles it back. + self.bump = -BUMP_PX * f64::from(delta.signum()); + self.bump_vel = 0.0; + Some(MenuPulse::Boundary) + } + } + } + + /// Render the whole library scene. Always a full repaint — the aurora animates + /// every frame (that's the point of the GPU port). + pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) { + let (wf, hf) = (w as f64, h as f64); + // Uniform scale off the Deck's 800p design height; fonts and geometry follow. + let k = (hf / 800.0).clamp(0.75, 3.0); + + // Springs (Ready only — other scenes have no strip). + let now = Instant::now(); + let dt = self + .last_frame + .replace(now) + .map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05)); + if self.phase == LibraryPhase::Ready { + let target = f64::from(self.cursor); + (self.anim_pos, self.anim_vel) = spring_advance( + self.anim_pos, + self.anim_vel, + target, + SPRING_K, + SPRING_C, + dt, + ); + if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 { + self.anim_pos = target; + self.anim_vel = 0.0; + } + (self.bump, self.bump_vel) = + spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt); + if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 { + self.bump = 0.0; + self.bump_vel = 0.0; + } + } + + self.draw_aurora(canvas, wf, hf); + + match self.phase.clone() { + LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts), + LibraryPhase::Loading => { + self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k); + fonts.centered(canvas, "Loading library…", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 16.0 * k, wf * 0.8); + } + LibraryPhase::PairFirst => { + fonts.centered_bold(canvas, "Not paired with this host", 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 20.0 * k, wf * 0.8); + fonts.centered(canvas, "Pair from the Punktfunk plugin first.", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 12.0 * k, wf * 0.8); + } + LibraryPhase::Empty => { + fonts.centered_bold(canvas, "No games found", 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 20.0 * k, wf * 0.8); + fonts.centered(canvas, "Install Steam titles or add custom entries in the host's web console.", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 12.0 * k, wf * 0.8); + } + LibraryPhase::Error { title, body, .. } => { + fonts.centered_bold(canvas, &title, 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 32.0 * k, wf * 0.8); + fonts.centered(canvas, &body, 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 4.0 * k, (600.0 * k).min(wf * 0.85)); + } + } + + self.draw_chrome(canvas, wf, hf, k, fonts); + } + + fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64) { + let t = self.t0.elapsed().as_secs_f64(); + // Uniform layout: float2 u_res, float u_t (declared order, no padding needed). + let uniforms: [f32; 3] = [w as f32, h as f32, t as f32]; + let data = Data::new_copy(bytemuck_bytes(&uniforms)); + match self.aurora.make_shader(data, &[], None) { + Some(shader) => { + let mut paint = Paint::default(); + paint.set_shader(shader); + canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint); + } + None => { + canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); + } + } + } + + fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) { + let (card_w, card_h) = (POSTER_W * k, POSTER_H * k); + // The strip's vertical center: the space between the top bar and the detail + // block (the GTK vexpand'ed scroller, approximated). + let cy = h * 0.44; + let pos = self.anim_pos; + let bump = self.bump * k; + + // Paint order = draw order: farthest from the (integer) cursor first, so the + // dense side stacks overlap toward the focus. Stable → the equidistant + // neighbors keep a deterministic order. + let mut order: Vec = (0..self.games.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs())); + + for i in order { + let d = i as f64 - pos; + let a = d.abs(); + if a > VISIBLE_RANGE { + continue; + } + let prox = a.min(1.0); + let scale = 1.0 - prox * RECEDE_SCALE; + let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG; + // Piecewise strip: a full FOCUS_GAP around the focus, then the dense side + // stacks (the classic coverflow shelf). + let offset = if a <= 1.0 { + d * FOCUS_GAP * k + } else { + d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k + }; + let cx = w / 2.0 + offset + bump; + let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k); + + let game = &self.games[i]; + canvas.save(); + canvas.concat_44(&M44::row_major(&m)); + let rect = Rect::from_wh(card_w as f32, card_h as f32); + let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32); + canvas.clip_rrect(rr, None, true); + match self.art.get(&game.id) { + Some(img) => { + // Cover-fit: center-crop the source to the card's 2:3. + let (iw, ih) = (img.width() as f32, img.height() as f32); + let card_aspect = rect.width() / rect.height(); + let src = if iw / ih > card_aspect { + let sw = ih * card_aspect; + Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih) + } else { + let sh = iw / card_aspect; + Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh) + }; + canvas.draw_image_rect(img, Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), rect, &Paint::default()); + } + None => { + // Solid face, not glass: the side cards OVERLAP (GTK CSS note). + canvas.draw_rect(rect, &Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None)); + let mono = initials(&game.title); + let font = fonts.sans_bold(38.0 * k); + let tw = font.measure_str(&mono, None).0; + canvas.draw_str( + &mono, + Point::new((card_w as f32 - tw) / 2.0, card_h as f32 / 2.0 + 13.0 * k as f32), + &font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None), + ); + } + } + // Store badge, top-left. + { + let label = store_label(&game.store); + let font = fonts.sans_bold(11.0 * k); + let tw = font.measure_str(label, None).0; + let (px, py) = (8.0 * k as f32, 8.0 * k as f32); + let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32); + canvas.draw_rrect( + RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0), + &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), + ); + canvas.draw_str( + label, + Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32), + &font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None), + ); + } + // The brightness recede: an opaque-black veil, never whole-card alpha. + if prox > 0.0 { + canvas.draw_rect( + rect, + &Paint::new(Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32), None), + ); + } + canvas.restore(); + } + + // Detail block: focused title + store, centered between strip and hints. + if let Some(g) = self.games.get(self.cursor as usize) { + fonts.centered_bold(canvas, &g.title, 27.0 * k, WHITE, w / 2.0, h - 96.0 * k, w * 0.8); + fonts.centered( + canvas, + &store_label(&g.store).to_uppercase(), + 12.0 * k, + Color4f::new(1.0, 1.0, 1.0, 0.5), + w / 2.0, + h - 66.0 * k, + w * 0.5, + ); + } + if let Some(status) = &self.status { + fonts.centered( + canvas, + status, + 13.0 * k, + Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a + w / 2.0, + h - 44.0 * k, + w * 0.85, + ); + } + } + + /// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances. + fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) { + let font = fonts.sans_bold(18.0 * k); + canvas.draw_str( + &self.host_label, + Point::new(24.0 * k as f32, 32.0 * k as f32), + &font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None), + ); + if let Some(chip) = &fonts.chip_text { + let cf = fonts.sans(12.0 * k); + let tw = cf.measure_str(chip, None).0; + let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32); + let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad; + canvas.draw_rrect( + RRect::new_rect_xy( + Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh), + bh / 2.0, + bh / 2.0, + ), + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None), + ); + canvas.draw_str( + chip, + Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32), + &cf, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None), + ); + } + + let hint = if self.connecting { + "Connecting…".to_string() + } else { + match &self.phase { + LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(), + LibraryPhase::Error { can_retry: true, .. } => "A Retry B Quit".to_string(), + _ => "B Quit".to_string(), + } + }; + fonts.left( + canvas, + &hint, + 13.0 * k, + Color4f::new(1.0, 1.0, 1.0, 0.85), + 24.0 * k, + h - 20.0 * k, + ); + } + + /// The loading spinner: a rotating arc off the aurora clock. + fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) { + let t = self.t0.elapsed().as_secs_f64(); + let start = (t * 300.0) % 360.0; + let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None); + paint.set_style(skia_safe::PaintStyle::Stroke); + paint.set_stroke_width(3.0); + paint.set_anti_alias(true); + canvas.draw_arc( + Rect::from_xywh( + (cx - r) as f32, + (cy - r) as f32, + 2.0 * r as f32, + 2.0 * r as f32, + ), + start as f32, + 270.0, + false, + &paint, + ); + } +} + +const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0); +const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7); + +fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] { + unsafe { std::slice::from_raw_parts(v.as_ptr().cast::(), 12) } +} + +/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text +/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't). +pub(crate) struct Fonts { + pub sans: Typeface, + pub collection: FontCollection, + /// The controller chip's current text (fed per frame by the overlay). + pub chip_text: Option, +} + +impl Fonts { + fn sans(&self, size: f64) -> Font { + Font::new(self.sans.clone(), size as f32) + } + + fn sans_bold(&self, size: f64) -> Font { + let mut f = Font::new(self.sans.clone(), size as f32); + f.set_embolden(true); + f + } + + fn paragraph( + &self, + text: &str, + size: f64, + color: Color4f, + bold: bool, + align: TextAlign, + max_w: f64, + ) -> skia_safe::textlayout::Paragraph { + let mut style = ParagraphStyle::new(); + style.set_text_align(align); + let mut ts = TextStyle::new(); + ts.set_font_size(size as f32); + ts.set_color(color.to_color()); + ts.set_font_style(if bold { + FontStyle::bold() + } else { + FontStyle::normal() + }); + style.set_text_style(&ts); + let mut b = ParagraphBuilder::new(&style, self.collection.clone()); + b.add_text(text); + let mut p = b.build(); + p.layout(max_w as f32); + p + } + + /// Centered paragraph with `y` as its top edge. + #[allow(clippy::too_many_arguments)] + fn centered( + &self, + canvas: &Canvas, + text: &str, + size: f64, + color: Color4f, + cx: f64, + y: f64, + max_w: f64, + ) { + let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w); + p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32)); + } + + #[allow(clippy::too_many_arguments)] + fn centered_bold( + &self, + canvas: &Canvas, + text: &str, + size: f64, + color: Color4f, + cx: f64, + y: f64, + max_w: f64, + ) { + let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w); + p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32)); + } + + fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) { + canvas.draw_str( + text, + Point::new(x as f32, y as f32), + &self.sans(size), + &Paint::new(color, None), + ); + } +} + +pub(crate) fn build_fonts() -> Result { + let mgr = skia_safe::FontMgr::new(); + let sans = mgr + .match_family_style("sans-serif", FontStyle::normal()) + .ok_or_else(|| anyhow!("no sans-serif typeface via fontconfig"))?; + let mut collection = FontCollection::new(); + collection.set_default_font_manager(mgr, None); + Ok(Fonts { + sans, + collection, + chip_text: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The generated aurora SkSL must actually compile (Skia's SkSL frontend runs on + /// the CPU — no GPU needed) — the shape test in `library` can't catch type errors. + #[test] + fn aurora_sksl_compiles() { + RuntimeEffect::make_for_shader(aurora_sksl(), None) + .unwrap_or_else(|e| panic!("aurora SkSL rejected:\n{e}")); + } +} diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 5c9b4f29..634c48b5 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -3,10 +3,15 @@ //! still be sampling the previous image while we render the next), damage-driven //! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame). +use crate::library::LibraryShared; +use crate::library_ui::{build_fonts, Fonts, LibraryUi}; use anyhow::{anyhow, Context as _, Result}; use ash::vk as avk; use ash::vk::Handle as _; -use pf_presenter::overlay::{FrameCtx, Overlay, OverlayFrame, SharedDevice}; +use pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use pf_presenter::overlay::{ + FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice, +}; use skia_safe::gpu::vk as skvk; use skia_safe::gpu::{self, DirectContext, SurfaceOrigin}; use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface}; @@ -43,6 +48,9 @@ pub struct SkiaOverlay { current: usize, drawn: Drawn, font: Option, + /// The console library (`--browse`) — `None` for a plain `--connect` session. + library: Option, + fonts: Option, } struct Gpu { @@ -64,8 +72,23 @@ impl SkiaOverlay { current: 0, drawn: Drawn::default(), font: None, + library: None, + fonts: None, } } + + /// The `--browse` overlay: the console library between streams, stream chrome + /// during them. Returns the shared model the binary's fetch threads write into. + pub fn with_library(host_label: String) -> Result<(SkiaOverlay, LibraryShared)> { + let shared = LibraryShared::default(); + let mut o = SkiaOverlay::new(); + o.library = Some(LibraryUi::new(shared.clone(), host_label)?); + Ok((o, shared)) + } + + fn library_visible(&self) -> bool { + self.library.as_ref().is_some_and(|l| !l.in_stream) + } } impl Drop for SkiaOverlay { @@ -125,6 +148,7 @@ impl Overlay for SkiaOverlay { .match_family_style("monospace", skia_safe::FontStyle::normal()) .context("no monospace typeface via fontconfig")?; self.font = Some(Font::new(typeface, 14.0)); + self.fonts = Some(build_fonts()?); self.gpu = Some(Gpu { device: shared.device.clone(), @@ -137,11 +161,102 @@ impl Overlay for SkiaOverlay { Ok(()) } - fn handle_event(&mut self, _event: &sdl3::event::Event) -> bool { - false // the OSD/HUD consume nothing; the console library will + fn handle_event(&mut self, event: &sdl3::event::Event) -> bool { + // The library's keyboard fallback (arrows/Enter/Esc) — only while it's on + // screen, and never for chord-modified keys (those stay the run loop's). + if self.library_visible() { + if let sdl3::event::Event::KeyDown { + scancode: Some(sc), + keymod, + repeat, + .. + } = event + { + use sdl3::keyboard::Mod; + if !keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) + { + return self + .library + .as_mut() + .is_some_and(|l| l.key(*sc, *repeat)); + } + } + } + false + } + + fn handle_menu(&mut self, event: MenuEvent) -> Option { + if self.library_visible() { + self.library.as_mut().and_then(|l| l.menu(event)) + } else { + None + } + } + + fn take_action(&mut self) -> Option { + self.library.as_mut().and_then(|l| l.take_action()) + } + + fn session_phase(&mut self, phase: SessionPhase) { + let Some(lib) = &mut self.library else { return }; + match phase { + SessionPhase::Connecting => lib.set_connecting(true), + SessionPhase::Streaming => { + lib.in_stream = true; + lib.set_connecting(false); + } + SessionPhase::Failed(msg) => lib.session_error(msg), + SessionPhase::Ended(None) => { + lib.in_stream = false; + lib.set_connecting(false); + } + SessionPhase::Ended(Some(reason)) => lib.session_error(reason), + } } fn frame(&mut self, ctx: &FrameCtx) -> Result> { + // The console library: full-screen, opaque, and always dirty (the aurora + // animates every frame — the GPU port's whole point). + if self.library_visible() { + let next = 1 - self.current; + self.ensure_slot(next, ctx.width, ctx.height)?; + let Self { + gpu, + slots, + library, + fonts, + .. + } = self; + let gpu = gpu.as_mut().expect("init ran"); + let slot = slots[next].as_mut().expect("just ensured"); + let lib = library.as_mut().expect("library_visible"); + let fonts = fonts.as_mut().expect("init ran"); + fonts.chip_text = Some( + ctx.pad + .map_or("No controller — keyboard works too".to_string(), str::to_owned), + ); + lib.sync(); + lib.render(slot.surface.canvas(), ctx.width, ctx.height, fonts); + gpu.context.flush_surface_with_texture_state( + &mut slot.surface, + &gpu::FlushInfo::default(), + Some(&skvk::mutable_texture_states::new_vulkan( + skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, + gpu.queue_family_index, + )), + ); + gpu.context.submit(None); + self.current = next; + self.drawn = Drawn::default(); // stream chrome re-renders when it returns + let slot = self.slots[next].as_ref().expect("just rendered"); + return Ok(Some(OverlayFrame { + image: slot.image, + view: slot.view, + width: slot.width, + height: slot.height, + })); + } + if ctx.stats.is_none() && ctx.hint.is_none() { self.drawn = Drawn::default(); // forget content so re-show re-renders return Ok(None); diff --git a/crates/pf-presenter/src/lib.rs b/crates/pf-presenter/src/lib.rs index 97ad448a..960307e7 100644 --- a/crates/pf-presenter/src/lib.rs +++ b/crates/pf-presenter/src/lib.rs @@ -25,4 +25,4 @@ mod run; pub mod vk; #[cfg(target_os = "linux")] -pub use run::{run_session, Outcome, SessionOpts}; +pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts}; diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 83a6e349..e67e9194 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -6,6 +6,7 @@ //! `None` costs the hot path nothing (the quad isn't even recorded). use ash::vk; +use pf_client_core::gamepad::{MenuEvent, MenuPulse}; /// The presenter's device, shared with the overlay so its renderer (Skia's /// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for @@ -31,6 +32,8 @@ pub struct FrameCtx<'a> { pub stats: Option<&'a str>, /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. pub hint: Option<&'a str>, + /// The active gamepad's name (the console library's controller chip). + pub pad: Option<&'a str>, } /// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in @@ -43,17 +46,55 @@ pub struct OverlayFrame { pub height: u32, } +/// An action the overlay raises out of its input handling (browse mode: the console +/// library's A/B/retry). The run loop hands each to the session binary's callback. +pub enum OverlayAction { + /// Launch this library title as a session (`id` rides the Hello). + Launch { id: String, title: String }, + /// Retry whatever failed (the library fetch). + Retry, + /// Quit the launcher (B at the root) — ends the process, Gaming Mode returns. + Quit, +} + +/// Session lifecycle notifications into the overlay (browse mode drives its scenes off +/// these; the OSD/HUD ignore them). +pub enum SessionPhase<'a> { + /// A launch action was accepted — the connect is in flight. + Connecting, + /// Connected; frames are coming. + Streaming, + /// The connect failed (browse mode returns to the library with this message). + Failed(&'a str), + /// The session ran and ended (`Some` = abnormal reason for the status strip). + Ended(Option<&'a str>), +} + /// The console-UI side. Object-safe; the session binary passes /// `Option>` (None = the Skia-free power-user build). pub trait Overlay { /// One-time setup on the presenter's device. fn init(&mut self, shared: &SharedDevice) -> anyhow::Result<()>; - /// Input routing, before capture sees the event. `true` = consumed (a menu is up) — - /// the event must not reach capture/forwarding. The OSD/HUD milestone consumes - /// nothing; the console library will. + /// Input routing, before capture sees the event. `true` = consumed (the library or + /// a menu is up) — the event must not reach capture/forwarding. fn handle_event(&mut self, event: &sdl3::event::Event) -> bool; + /// Gamepad menu-mode navigation (browse mode; the run loop drains the service's + /// menu channel). Returns a haptic pulse to play on the menu pad, if any. + fn handle_menu(&mut self, _event: MenuEvent) -> Option { + None + } + + /// Drain one pending action raised by handled input. Called once per loop + /// iteration; return `None` when idle. + fn take_action(&mut self) -> Option { + None + } + + /// A session lifecycle edge (browse mode scene driving). + fn session_phase(&mut self, _phase: SessionPhase) {} + /// Once per presenter iteration. Damage-driven: re-render (flush + transition to /// SHADER_READ_ONLY) only when the content or size changed, else return the previous /// image. `None` = nothing to composite. The returned image must stay untouched diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index c8537ea8..128f90d7 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -2,16 +2,21 @@ //! window, the Vulkan presenter, input capture, the pumped gamepad service, and the //! shared session pump's event/frame channels. //! +//! Two modes over one loop: **single** (`run_session` — one `--connect` stream, exit on +//! end; the shell↔session contract) and **browse** (`run_browse` — the console library +//! idles between streams; overlay actions launch sessions, session end returns to the +//! library; the app quits only on B/window-close). +//! //! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}` //! line after the first presented frame, `stats: …` lines once per window while enabled //! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so). use crate::input::Capture; -use crate::overlay::{FrameCtx, Overlay, OverlayFrame}; +use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; use crate::vk::{FrameInput, Presenter}; use anyhow::{Context as _, Result}; use pf_client_core::gamepad::GamepadService; -use pf_client_core::session::{self, SessionEvent, SessionParams, Stats}; +use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; use pf_client_core::video::{DecodedFrame, DecodedImage}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::Mode; @@ -34,7 +39,7 @@ pub struct SessionOpts { pub on_connected: Option>, /// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats /// stay stdout-only). An overlay whose `init` fails degrades to `None` with a - /// warning rather than killing the session. + /// warning rather than killing the session. Browse mode requires one. pub overlay: Option>, } @@ -48,15 +53,120 @@ pub enum Outcome { }, } -/// Everything SDL on the main thread, per the plan (§5.1): window + events + gamepads in -/// ONE event loop — the shell-era worker-thread arrangement can't coexist with an SDL -/// video window. `build_params` receives the gamepad service (for `auto_pref`), the -/// window's native display mode (the `0 = native` resolution fallback), and the shared -/// `force_software` flag, and returns the pump parameters. -pub fn run_session(mut opts: SessionOpts, build_params: F) -> Result +/// What the session binary decided about an overlay action (browse mode). +pub enum ActionOutcome { + /// Consumed binary-side (a Retry respawned the fetch, …). + Handled, + /// Start this session (a Launch action; `force_software` from the callback args is + /// wired into these params). + Start(SessionParams), + /// Quit the launcher. + Quit, +} + +/// One `--connect` stream session; returns when it ends (the shell↔session contract). +pub fn run_session(opts: SessionOpts, build_params: F) -> Result where F: FnOnce(&GamepadService, Mode, Arc) -> SessionParams, { + let mut build = Some(build_params); + run_inner( + opts, + ModeCtl::Single(Box::new(move |gp, native, fs| { + (build.take().expect("single build runs once"))(gp, native, fs) + })), + ) + .map(|o| o.expect("single mode always yields an outcome")) +} + +/// Browse mode: the console library idles between streams. `on_action` receives every +/// overlay action (Launch/Retry/Quit) plus what a launch needs to build its params — +/// the gamepad service (`auto_pref`), the native display mode, and a fresh +/// per-session `force_software` flag. +pub fn run_browse(opts: SessionOpts, on_action: F) -> Result<()> +where + F: FnMut(OverlayAction, &GamepadService, Mode, Arc) -> ActionOutcome, +{ + anyhow::ensure!( + opts.overlay.is_some(), + "--browse needs the console UI (a build with the `ui` feature)" + ); + run_inner(opts, ModeCtl::Browse(Box::new(on_action))).map(|_| ()) +} + +/// Params builder for the one single-mode session (called exactly once, post-setup). +type BuildParams<'a> = Box) -> SessionParams + 'a>; +/// The browse-mode action callback (Launch → params, Retry/Quit → outcome). +type OnAction<'a> = + Box) -> ActionOutcome + 'a>; + +/// The two run modes, type-erased so one loop serves both. +enum ModeCtl<'a> { + Single(BuildParams<'a>), + Browse(OnAction<'a>), +} + +/// Everything one stream session accumulates — created at session start, dropped at +/// session end (browse mode cycles through several per process lifetime). +struct StreamState { + handle: SessionHandle, + connector: Option>, + capture: Option, + force_software: Arc, + ready_announced: bool, + mode_line: String, + clock_offset_ns: i64, + hdr: bool, + // Presenter-side 1 s window (design/stats-unification.md): end-to-end + // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. + win_e2e_us: Vec, + win_disp_us: Vec, + win_start: Instant, + presented: PresentedWindow, + // Hardware-path health: a failure streak (or a device with no import support at + // all) demotes the decoder to software via the shared flag — once per session. + dmabuf_demoted: bool, + hw_fails: u32, + /// The OSD's text (multi-line; rebuilt each Stats window). + osd_text: String, +} + +impl StreamState { + fn new(params: SessionParams, force_software: Arc) -> StreamState { + StreamState { + handle: session::start(params), + connector: None, + capture: None, + force_software, + ready_announced: false, + mode_line: String::new(), + clock_offset_ns: 0, + hdr: false, + win_e2e_us: Vec::with_capacity(256), + win_disp_us: Vec::with_capacity(256), + win_start: Instant::now(), + presented: PresentedWindow::default(), + dmabuf_demoted: false, + hw_fails: 0, + osd_text: String::new(), + } + } + + /// Deliberate user exit (chord / window close): release capture, close with + /// QUIT_CLOSE_CODE so the host tears down instead of lingering, stop the pump. + /// The pump then emits `Ended(None)` — the loop's normal end path picks it up. + fn request_quit(&mut self) { + if let Some(cap) = &mut self.capture { + cap.release(); + } + if let Some(c) = &self.connector { + c.disconnect_quit(); + } + self.handle.stop.store(true, Ordering::SeqCst); + } +} + +fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result> { sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); let sdl = sdl3::init().context("SDL init")?; let video = sdl.video().context("SDL video")?; @@ -78,6 +188,9 @@ where let mut overlay = opts.overlay.take(); if let Some(o) = overlay.as_mut() { if let Err(e) = o.init(&presenter.shared_device()) { + if matches!(mode, ModeCtl::Browse(_)) { + return Err(e).context("console UI init (required for --browse)"); + } tracing::warn!(error = %format!("{e:#}"), "console-UI overlay init failed — continuing without it"); overlay = None; @@ -88,6 +201,12 @@ where let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem); let escape_rx = gamepad.escape_events(); let disconnect_rx = gamepad.disconnect_events(); + let menu_rx = gamepad.menu_events(); + if matches!(mode, ModeCtl::Browse(_)) { + // Menu mode for the launcher's lifetime (an attached session supersedes + // translation automatically — the GTK launcher never turned it off either). + gamepad.set_menu_mode(true); + } // The native display mode — the `0 = native` fallback for the requested stream mode // (the GTK client reads the monitor under its window; same idea). @@ -105,45 +224,31 @@ where refresh_hz: 60, }); - let force_software = Arc::new(AtomicBool::new(false)); - let params = build_params(&gamepad, native, force_software.clone()); - let handle = session::start(params); + let mut stream: Option = match &mut mode { + ModeCtl::Single(build) => { + let force_software = Arc::new(AtomicBool::new(false)); + let params = build(&gamepad, native, force_software.clone()); + Some(StreamState::new(params, force_software)) + } + ModeCtl::Browse(_) => None, + }; let mut event_pump = sdl .event_pump() .map_err(|e| anyhow::anyhow!("SDL event pump: {e}"))?; let mouse = sdl.mouse(); - // --- Loop state -------------------------------------------------------------------- - let mut connector: Option> = None; - let mut capture: Option = None; let mut fullscreen = opts.fullscreen; - let mut ready_announced = false; let mut print_stats = opts.print_stats; - let mut mode_line = String::new(); - let mut clock_offset_ns: i64 = 0; - let mut hdr = false; - // Presenter-side 1 s window (design/stats-unification.md): end-to-end - // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. - let mut win_e2e_us: Vec = Vec::with_capacity(256); - let mut win_disp_us: Vec = Vec::with_capacity(256); - let mut win_start = Instant::now(); - let mut presented = PresentedWindow::default(); - // Hardware-path health: a failure streak (or a device with no import support at all) - // demotes the decoder to software via the shared flag — once per session. - let mut dmabuf_demoted = false; - let mut hw_fails = 0u32; - // The OSD's text (multi-line; rebuilt each Stats window) and the console-UI frame - // currently composited. The frame persists across presents within an iteration — - // the overlay only re-renders when `frame()` runs again (ring of two, see §6.1). - let mut osd_text = String::new(); let mut overlay_frame: Option = None; let outcome = 'main: loop { // --- SDL events (input, window, gamepads) --------------------------------------- // Block briefly in SDL's own wait so idle costs nothing; while streaming, frames - // arrive on the channel below and 1 ms bounds the added present latency. - let timeout = Duration::from_millis(if connector.is_some() { 1 } else { 10 }); + // arrive on the channel below and 1 ms bounds the added present latency. In + // browse-idle the per-iteration FIFO present vsync-throttles the loop anyway. + let streaming = stream.as_ref().is_some_and(|s| s.connector.is_some()); + let timeout = Duration::from_millis(if streaming { 1 } else { 5 }); let first = event_pump.wait_event_timeout(timeout); let mut queued: Vec = Vec::new(); if let Some(e) = first { @@ -153,8 +258,8 @@ where queued.push(e); } for event in queued { - // The console UI sees input first: a consumed event (its menu is up) never - // reaches capture/forwarding. The OSD/HUD milestone consumes nothing. + // The console UI sees input first: a consumed event (the library's keyboard + // navigation, a menu) never reaches capture/forwarding. if let Some(o) = overlay.as_mut() { if o.handle_event(&event) { continue; @@ -162,17 +267,15 @@ where } match event { Event::Quit { .. } => { - // Window close / SIGINT: deliberate user exit — QUIT_CLOSE_CODE so - // the host tears down instead of lingering for a reconnect. - if let Some(c) = &connector { - c.disconnect_quit(); + // Window close / SIGINT: deliberate exit, host teardown now. + if let Some(st) = &mut stream { + st.request_quit(); } - handle.stop.store(true, Ordering::SeqCst); - break 'main Outcome::Ended(None); + break 'main Some(Outcome::Ended(None)); } Event::Window { win_event, .. } => match win_event { WindowEvent::FocusLost => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release() { mouse.show_cursor(true); tracing::info!("focus lost — input released"); @@ -199,7 +302,7 @@ where && keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD); use sdl3::keyboard::Scancode; if chord && sc == Scancode::Q { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.captured() { cap.release(); mouse.show_cursor(true); @@ -212,15 +315,13 @@ where continue; } if chord && sc == Scancode::D { - tracing::info!("chord: disconnect"); - if let Some(cap) = &mut capture { - cap.release(); + if let Some(st) = &mut stream { + tracing::info!("chord: disconnect"); + st.request_quit(); + mouse.show_cursor(true); + // The pump emits Ended(None); the end path routes per mode. } - if let Some(c) = &connector { - c.disconnect_quit(); - } - handle.stop.store(true, Ordering::SeqCst); - break 'main Outcome::Ended(None); + continue; } if chord && sc == Scancode::S { print_stats = !print_stats; @@ -233,26 +334,26 @@ where } continue; } - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { cap.on_key_down(sc, window.size()); } } Event::KeyUp { scancode: Some(sc), .. } => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { cap.on_key_up(sc); } } Event::MouseMotion { x, y, .. } => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { cap.on_motion(x, y); } } Event::MouseButtonDown { mouse_btn, x, y, .. } => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if !cap.captured() { // The engaging click is suppressed toward the host. cap.engage(); @@ -263,12 +364,12 @@ where } } Event::MouseButtonUp { mouse_btn, .. } => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { cap.on_button_up(mouse_btn, window.size()); } } Event::MouseWheel { x, y, .. } => { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { cap.on_wheel(x, y, window.size()); } } @@ -280,9 +381,10 @@ where pump.tick(); // Controller escape chord: release capture (+ leave fullscreen on desktop — under - // a `--fullscreen` gamescope launch there is nothing to release into). + // a `--fullscreen` gamescope launch there is nothing to release into). Only + // emitted while a session is attached. while escape_rx.try_recv().is_ok() { - if let Some(cap) = &mut capture { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release() { mouse.show_cursor(true); } @@ -294,55 +396,108 @@ where } // Escape chord held past the threshold: the controller's Ctrl+Alt+Shift+D. if disconnect_rx.try_recv().is_ok() { - tracing::info!("controller chord: disconnect"); - if let Some(cap) = &mut capture { - cap.release(); + if let Some(st) = &mut stream { + tracing::info!("controller chord: disconnect"); + st.request_quit(); + mouse.show_cursor(true); } - if let Some(c) = &connector { - c.disconnect_quit(); + } + + // --- Browse: menu navigation + overlay actions (library visible only) ------------ + if let ModeCtl::Browse(on_action) = &mut mode { + if stream.is_none() { + while let Ok(ev) = menu_rx.try_recv() { + if let Some(o) = overlay.as_mut() { + if let Some(pulse) = o.handle_menu(ev) { + gamepad.menu_rumble(pulse); + } + } + } + } + if let Some(action) = overlay.as_mut().and_then(|o| o.take_action()) { + let force_software = Arc::new(AtomicBool::new(false)); + match on_action(action, &gamepad, native, force_software.clone()) { + ActionOutcome::Handled => {} + ActionOutcome::Start(params) => { + stream = Some(StreamState::new(params, force_software)); + if let Some(o) = overlay.as_mut() { + o.session_phase(SessionPhase::Connecting); + } + } + ActionOutcome::Quit => break Some(Outcome::Ended(None)), + } } - handle.stop.store(true, Ordering::SeqCst); - break Outcome::Ended(None); } // --- Session events -------------------------------------------------------------- - while let Ok(ev) = handle.events.try_recv() { + // `stream` may become None mid-drain (browse-mode session end) — re-borrow each + // event, act, and stop draining on the terminal ones. + while let Some(st) = stream.as_mut() { + let Ok(ev) = st.handle.events.try_recv() else { + break; + }; match ev { SessionEvent::Connected { connector: c, - mode, + mode: m, fingerprint, } => { - mode_line = format!("{}×{}@{}", mode.width, mode.height, mode.refresh_hz); - tracing::info!(mode = %mode_line, "connected"); - window.set_title(&format!("{} · {}", opts.window_title, mode_line)).ok(); + st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz); + tracing::info!(mode = %st.mode_line, "connected"); + window + .set_title(&format!("{} · {}", opts.window_title, st.mode_line)) + .ok(); gamepad.attach(c.clone()); - clock_offset_ns = c.clock_offset_ns; + st.clock_offset_ns = c.clock_offset_ns; let mut cap = Capture::new(c.clone()); cap.engage(); // capture engages when the stream starts (ui_stream parity) mouse.show_cursor(false); - capture = Some(cap); - connector = Some(c); + st.capture = Some(cap); + st.connector = Some(c); if let Some(f) = opts.on_connected.as_mut() { f(fingerprint); } + if let Some(o) = overlay.as_mut() { + o.session_phase(SessionPhase::Streaming); + } } SessionEvent::Stats(s) => { - osd_text = stats_text(&mode_line, &s, &presented, hdr); + st.osd_text = stats_text(&st.mode_line, &s, &st.presented, st.hdr); if print_stats { - println!("stats: {}", osd_text.replace('\n', " | ")); + println!("stats: {}", st.osd_text.replace('\n', " | ")); } } - SessionEvent::Failed { msg, trust_rejected } => { - break 'main Outcome::ConnectFailed { msg, trust_rejected }; - } + SessionEvent::Failed { msg, trust_rejected } => match &mode { + ModeCtl::Single(_) => { + break 'main Some(Outcome::ConnectFailed { msg, trust_rejected }) + } + ModeCtl::Browse(_) => { + tracing::warn!(%msg, "connect failed — back to the library"); + stream = None; + mouse.show_cursor(true); + if let Some(o) = overlay.as_mut() { + o.session_phase(SessionPhase::Failed(&msg)); + } + break; + } + }, SessionEvent::Ended(reason) => { gamepad.detach(); - if let Some(cap) = &mut capture { + if let Some(cap) = &mut st.capture { cap.release(); - mouse.show_cursor(true); } - break 'main Outcome::Ended(reason); + mouse.show_cursor(true); + match &mode { + ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), + ModeCtl::Browse(_) => { + window.set_title(&opts.window_title).ok(); + stream = None; + if let Some(o) = overlay.as_mut() { + o.session_phase(SessionPhase::Ended(reason.as_deref())); + } + break; + } + } } } } @@ -350,24 +505,37 @@ where // --- Console UI: damage-driven overlay re-render for this iteration -------------- if let Some(o) = overlay.as_mut() { let (pw, ph) = window.size_in_pixels(); - let hint = match &capture { - Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() { - HINT_WITH_PAD - } else { - HINT_KEYBOARD - }), - _ => None, + let (stats, hint) = match &stream { + Some(st) if st.connector.is_some() => { + let hint = match &st.capture { + Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() { + HINT_WITH_PAD + } else { + HINT_KEYBOARD + }), + _ => None, + }; + ( + (print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()), + hint, + ) + } + _ => (None, None), }; + let pad_name = gamepad.active().map(|p| p.name); let ctx = FrameCtx { width: pw, height: ph, - stats: (print_stats && connector.is_some() && !osd_text.is_empty()) - .then_some(osd_text.as_str()), + stats, hint, + pad: pad_name.as_deref(), }; match o.frame(&ctx) { Ok(f) => overlay_frame = f, Err(e) => { + if matches!(mode, ModeCtl::Browse(_)) { + return Err(e).context("console UI frame (required for --browse)"); + } tracing::warn!(error = %format!("{e:#}"), "overlay frame failed — disabling the console UI"); overlay = None; @@ -377,91 +545,113 @@ where } // --- Frames: drain to the newest, upload + present ------------------------------- - let mut newest: Option = None; - while let Ok(f) = handle.frames.try_recv() { - newest = Some(f); - } - if let Some(f) = newest { - let DecodedFrame { - pts_ns, - decoded_ns, - image, - } = f; - let did_present = match image { - DecodedImage::Cpu(c) => { - hdr = c.color.is_pq(); - presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())? - } - DecodedImage::Dmabuf(d) if presenter.supports_dmabuf() && !dmabuf_demoted => { - hdr = d.color.is_pq(); - match presenter.present(&window, FrameInput::Dmabuf(d), overlay_frame.as_ref()) { - Ok(p) => { - hw_fails = 0; - p - } - // Import/CSC failure is survivable (the stream continues on the - // next frame) — but a streak means this box can't do the hw path: - // demote the decoder to software, same contract as the GTK - // presenter's GL-converter failures. - Err(e) => { - hw_fails += 1; - tracing::warn!(error = %format!("{e:#}"), fails = hw_fails, - "hardware present failed"); - if hw_fails >= 3 && !dmabuf_demoted { - dmabuf_demoted = true; - tracing::warn!("demoting the decoder to software"); - force_software.store(true, Ordering::Relaxed); + let mut presented_video = false; + if let Some(st) = &mut stream { + let mut newest: Option = None; + while let Ok(f) = st.handle.frames.try_recv() { + newest = Some(f); + } + if let Some(f) = newest { + let DecodedFrame { + pts_ns, + decoded_ns, + image, + } = f; + let did_present = match image { + DecodedImage::Cpu(c) => { + st.hdr = c.color.is_pq(); + presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())? + } + DecodedImage::Dmabuf(d) + if presenter.supports_dmabuf() && !st.dmabuf_demoted => + { + st.hdr = d.color.is_pq(); + match presenter.present( + &window, + FrameInput::Dmabuf(d), + overlay_frame.as_ref(), + ) { + Ok(p) => { + st.hw_fails = 0; + p + } + // Import/CSC failure is survivable (the stream continues on + // the next frame) — but a streak means this box can't do the + // hw path: demote the decoder to software, same contract as + // the GTK presenter's GL-converter failures. + Err(e) => { + st.hw_fails += 1; + tracing::warn!(error = %format!("{e:#}"), fails = st.hw_fails, + "hardware present failed"); + if st.hw_fails >= 3 && !st.dmabuf_demoted { + st.dmabuf_demoted = true; + tracing::warn!("demoting the decoder to software"); + st.force_software.store(true, Ordering::Relaxed); + } + false } - false } } - } - DecodedImage::Dmabuf(_) => { - // No import extensions on this device (or already demoted) — the - // pump rebuilds the decoder as software; frames flow again shortly. - if !dmabuf_demoted { - dmabuf_demoted = true; - tracing::warn!( - "no dmabuf import support on this device — demoting the \ - decoder to software" - ); - force_software.store(true, Ordering::Relaxed); + DecodedImage::Dmabuf(_) => { + // No import extensions on this device (or already demoted) — the + // pump rebuilds the decoder as software; frames flow again soon. + if !st.dmabuf_demoted { + st.dmabuf_demoted = true; + tracing::warn!( + "no dmabuf import support on this device — demoting the \ + decoder to software" + ); + st.force_software.store(true, Ordering::Relaxed); + } + false } - false + }; + if did_present { + presented_video = true; + let displayed_ns = session::now_ns(); + if opts.json_status && !st.ready_announced { + st.ready_announced = true; + println!("{{\"ready\":true}}"); + } + // The `displayed` stamp (same clamp rules as the pump's windows). + let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128) + .max(0) as u64; + if e2e > 0 && e2e < 10_000_000_000 { + st.win_e2e_us.push(e2e / 1000); + } + st.win_disp_us + .push(displayed_ns.saturating_sub(decoded_ns) / 1000); } - }; - if did_present { - let displayed_ns = session::now_ns(); - if opts.json_status && !ready_announced { - ready_announced = true; - println!("{{\"ready\":true}}"); - } - // The `displayed` stamp (same clamp rules as the pump's windows). - let e2e = - (displayed_ns as i128 + clock_offset_ns as i128 - pts_ns as i128).max(0) as u64; - if e2e > 0 && e2e < 10_000_000_000 { - win_e2e_us.push(e2e / 1000); - } - win_disp_us.push(displayed_ns.saturating_sub(decoded_ns) / 1000); + } + + // Fold the presenter window into the shared stats line once per second. + if st.win_start.elapsed() >= Duration::from_secs(1) { + let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us); + let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us); + st.presented = PresentedWindow { + e2e_p50_ms: e2e_p50 as f32 / 1000.0, + e2e_p95_ms: e2e_p95 as f32 / 1000.0, + display_ms: disp_p50 as f32 / 1000.0, + }; + st.win_e2e_us.clear(); + st.win_disp_us.clear(); + st.win_start = Instant::now(); } } - // Fold the presenter window into the shared stats line once per second. - if win_start.elapsed() >= Duration::from_secs(1) { - let (e2e_p50, e2e_p95) = session::window_percentiles(&mut win_e2e_us); - let (disp_p50, _) = session::window_percentiles(&mut win_disp_us); - presented = PresentedWindow { - e2e_p50_ms: e2e_p50 as f32 / 1000.0, - e2e_p95_ms: e2e_p95 as f32 / 1000.0, - display_ms: disp_p50 as f32 / 1000.0, - }; - win_e2e_us.clear(); - win_disp_us.clear(); - win_start = Instant::now(); + // Browse with no video driving presents (library / connecting): composite the + // overlay every iteration — FIFO vsync-throttles this to the display rate. + if matches!(mode, ModeCtl::Browse(_)) + && !presented_video + && stream.as_ref().is_none_or(|s| s.connector.is_none()) + { + presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; } }; - handle.stop.store(true, Ordering::SeqCst); + if let Some(st) = &stream { + st.handle.stop.store(true, Ordering::SeqCst); + } // Overlay resources live on the presenter's device: quiesce the queue first, drop // the overlay (its Drop destroys the Skia surfaces), THEN the presenter tears down. presenter.wait_idle();