feat(session): console game library — Skia coverflow + SkSL aurora, --browse (phase 4b)
The run loop grows a browse mode: the console library idles between streams in ONE window (no gamescope handoff), overlay actions launch sessions via run_browse's callback, session end returns to the library. The Overlay contract gains menu routing (MenuEvent → haptic pulse), action draining, and session-phase edges. pf-console-ui ports the GTK launcher wholesale: the coverflow's springs, cursor arithmetic and recede/tilt constants move verbatim with their tests (plus new projection tests — focused-card centering, the inner- edge-recedes corridor); paint order is draw order (the gtk::Fixed restack hack is gone); the aurora renders as an SkSL runtime shader at full rate on every box (the 30 Hz CPU-upscale path and its frozen-on- Deck fallback are deleted — the generated SkSL is compile-tested); titles/scenes shape through textlayout (CJK-safe). Poster art streams in as encoded bytes through the shared model and decodes renderer-side. The session binary wires --browse host[:port] [--mgmt PORT]: KnownHosts lookup (unpaired renders the pair-first scene), library + art fetch on threads, PUNKTFUNK_FAKE_LIBRARY dev hook, and a session_params helper shared with --connect. The minimal build refuses --browse cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LibraryGame>,
|
||||
/// Fetched poster bytes the renderer hasn't decoded yet (id, encoded image).
|
||||
art_in: VecDeque<(String, Vec<u8>)>,
|
||||
/// 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<Mutex<Shared>>);
|
||||
|
||||
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<LibraryGame>) {
|
||||
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<u8>) {
|
||||
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<LibraryGame>, u64) {
|
||||
let s = self.0.lock().unwrap();
|
||||
(s.phase.clone(), s.games.clone(), s.generation)
|
||||
}
|
||||
|
||||
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LibraryGame>,
|
||||
// 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<Instant>,
|
||||
t0: Instant,
|
||||
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
|
||||
art: HashMap<String, Image>,
|
||||
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<String>,
|
||||
actions: VecDeque<OverlayAction>,
|
||||
}
|
||||
|
||||
impl LibraryUi {
|
||||
pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result<LibraryUi> {
|
||||
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<MenuPulse> {
|
||||
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<OverlayAction> {
|
||||
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<MenuPulse> {
|
||||
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<usize> = (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::<u8>(), 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<String>,
|
||||
}
|
||||
|
||||
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<Fonts> {
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
@@ -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<Font>,
|
||||
/// The console library (`--browse`) — `None` for a plain `--connect` session.
|
||||
library: Option<LibraryUi>,
|
||||
fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
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<MenuPulse> {
|
||||
if self.library_visible() {
|
||||
self.library.as_mut().and_then(|l| l.menu(event))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||
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<Option<OverlayFrame>> {
|
||||
// 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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Box<dyn Overlay>>` (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<MenuPulse> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Drain one pending action raised by handled input. Called once per loop
|
||||
/// iteration; return `None` when idle.
|
||||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||
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
|
||||
|
||||
+357
-167
@@ -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<Box<dyn FnMut([u8; 32])>>,
|
||||
/// 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<Box<dyn Overlay>>,
|
||||
}
|
||||
|
||||
@@ -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<F>(mut opts: SessionOpts, build_params: F) -> Result<Outcome>
|
||||
/// 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<F>(opts: SessionOpts, build_params: F) -> Result<Outcome>
|
||||
where
|
||||
F: FnOnce(&GamepadService, Mode, Arc<AtomicBool>) -> 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<F>(opts: SessionOpts, on_action: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(OverlayAction, &GamepadService, Mode, Arc<AtomicBool>) -> 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<dyn FnMut(&GamepadService, Mode, Arc<AtomicBool>) -> SessionParams + 'a>;
|
||||
/// The browse-mode action callback (Launch → params, Retry/Quit → outcome).
|
||||
type OnAction<'a> =
|
||||
Box<dyn FnMut(OverlayAction, &GamepadService, Mode, Arc<AtomicBool>) -> 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<Arc<NativeClient>>,
|
||||
capture: Option<Capture>,
|
||||
force_software: Arc<AtomicBool>,
|
||||
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<u64>,
|
||||
win_disp_us: Vec<u64>,
|
||||
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<AtomicBool>) -> 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<Option<Outcome>> {
|
||||
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<StreamState> = 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<Arc<NativeClient>> = None;
|
||||
let mut capture: Option<Capture> = 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<u64> = Vec::with_capacity(256);
|
||||
let mut win_disp_us: Vec<u64> = 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<OverlayFrame> = 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<Event> = 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<DecodedFrame> = 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<DecodedFrame> = 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();
|
||||
|
||||
Reference in New Issue
Block a user