diff --git a/clients/session/src/browse.rs b/clients/session/src/browse.rs index 0f7b18c1..41b91a46 100644 --- a/clients/session/src/browse.rs +++ b/clients/session/src/browse.rs @@ -8,7 +8,9 @@ //! 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 crate::session_main::{ + arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos, +}; use pf_client_core::{library, trust}; use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay}; use pf_presenter::overlay::OverlayAction; @@ -61,6 +63,7 @@ pub fn run(target: &str) -> u8 { let opts = pf_presenter::SessionOpts { window_title: format!("Punktfunk · {host_label}"), fullscreen: fullscreen_mode(), + window_pos: window_pos(), print_stats: settings.show_stats || arg_flag("--stats"), json_status, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index c45be83f..c17a233d 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -52,6 +52,15 @@ mod session_main { || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() } + /// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning + /// shell passes its own position so the session opens on the same monitor); absent or + /// unparsable = centered on the primary display. + pub(crate) fn window_pos() -> Option<(i32, i32)> { + let v = arg_value("--window-pos")?; + let (x, y) = v.split_once(',')?; + Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) + } + /// `host[:port]`, port defaulting to the native 9777. pub(crate) fn parse_host_port(target: &str) -> (String, u16) { match target.rsplit_once(':') { @@ -308,6 +317,7 @@ mod session_main { let opts = pf_presenter::SessionOpts { window_title: format!("Punktfunk · {title}"), fullscreen, + window_pos: window_pos(), print_stats: settings.show_stats || arg_flag("--stats"), json_status: true, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index e61f7b34..3261e610 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -79,6 +79,44 @@ fn initiate_opts( } } +/// Start a stream that launches a library title on connect (`--launch id`) — the library +/// page's tap-to-play. The library only opens for paired hosts, so the pin resolves like +/// a normal initiate; a host forgotten mid-visit routes to the PIN ceremony instead. +pub(crate) fn initiate_launch( + ctx: &Arc, + target: Target, + launch: String, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + *ctx.shared.target.lock().unwrap() = target.clone(); + let known = KnownHosts::load(); + let pin = target + .fp_hex + .as_deref() + .and_then(trust::parse_hex32) + .or_else(|| { + known + .find_by_addr(&target.addr, target.port) + .and_then(|k| trust::parse_hex32(&k.fp_hex)) + }); + let Some(pin) = pin else { + set_screen.call(Screen::Pair); + return; + }; + connect_with( + ctx, + &target, + Some(pin), + set_screen, + set_status, + ConnectOpts { + launch: Some(launch), + ..ConnectOpts::default() + }, + ); +} + /// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh /// of the display our window is on (mirrors the Linux/Swift clients' native-display default). pub(crate) fn resolve_mode(s: &Settings) -> Mode { @@ -181,6 +219,11 @@ pub(crate) struct ConnectOpts { /// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag, /// so it can't loop. wake_on_fail: bool, + /// A library title id (`steam:570`, …) the host launches during the connect handshake — + /// the library page's tap-to-play. Spawn mode passes it as `--launch`; the legacy + /// in-process path has no launch plumbing (it predates the library and is slated for + /// deletion). + launch: Option, } impl Default for ConnectOpts { @@ -191,6 +234,7 @@ impl Default for ConnectOpts { awaiting_approval: false, cancel: None, wake_on_fail: false, + launch: None, } } } @@ -396,7 +440,7 @@ fn connect_spawn( &fp_arg, opts.connect_timeout.as_secs(), fullscreen, - None, + opts.launch.as_deref(), child, move |event| { use crate::spawn::SpawnEvent; @@ -544,7 +588,7 @@ pub(crate) fn request_access(props: &Svc, target: &Target) { persist_paired: true, awaiting_approval: true, cancel: Some(cancel), - wake_on_fail: false, + ..ConnectOpts::default() }, ); } diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs index 3738aa99..03fc282f 100644 --- a/clients/windows/src/app/help.rs +++ b/clients/windows/src/app/help.rs @@ -8,15 +8,21 @@ use super::Screen; use windows_reactor::*; /// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it -/// does. Read-only — the bindings themselves live in the input hook ([`crate::input`]). +/// does. Read-only — the keyboard bindings live in the session window (`pf-presenter`'s run +/// loop; the legacy builtin path's in [`crate::input`]), the controller chord in its gamepad +/// service. const STREAM_SHORTCUTS: &[(&str, &str)] = &[ - ("F11", "Toggle fullscreen"), + ("F11 / Alt+Enter", "Toggle fullscreen"), ( "Ctrl+Alt+Shift+Q", "Release captured input (click the stream to recapture)", ), ("Ctrl+Alt+Shift+D", "Disconnect"), ("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), + ( + "LB+RB+Start+Back", + "Controller: release input / leave fullscreen \u{2014} hold to disconnect", + ), ]; /// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill. diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index f76d2b1a..76acc17e 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -13,6 +13,7 @@ use windows_reactor::*; /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. const MENU_CONNECT: &str = "Connect"; +const MENU_LIBRARY: &str = "Browse library\u{2026}"; const MENU_CONSOLE: &str = "Open console UI"; const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_WAKE: &str = "Wake host"; @@ -186,22 +187,6 @@ fn status_row(online: Option, badge: &str, kind: Pill) -> Element { .into() } -/// Lay tiles into a `cols`-wide grid of equal-width star columns (rows share the height of -/// their tallest tile, so a grid row always lines up). -fn tile_grid(tiles: Vec, cols: usize) -> Element { - let rows = tiles.len().div_ceil(cols); - let mut children = Vec::with_capacity(tiles.len()); - for (i, t) in tiles.into_iter().enumerate() { - children.push(t.grid_row((i / cols) as i32).grid_column((i % cols) as i32)); - } - grid(children) - .columns(vec![GridLength::Star(1.0); cols]) - .rows(vec![GridLength::Auto; rows]) - .column_spacing(TILE_GAP) - .row_spacing(TILE_GAP) - .into() -} - /// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel. /// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region. fn rename_editor( @@ -269,6 +254,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { set: props.set_hover.clone(), }; let known = KnownHosts::load(); + // The experimental library gate ("Show game library" in Settings) — GTK/Apple parity. + let library_enabled = ctx.settings.lock().unwrap().library_enabled; // Responsive column count from the live window width (re-renders on resize): as many // TILE_MIN_WIDTH columns as fit the page's content width, at least one. @@ -445,8 +432,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .automation_name("More options") .menu_flyout({ let mut items = vec![menu_item(MENU_CONNECT)]; - // The gamepad library — paired hosts only (the mgmt API needs the - // paired identity) and only where the session ships the console UI. + // The library surfaces — mouse/KB page and the gamepad console UI — + // for paired hosts only (the mgmt API needs the paired identity); + // the page additionally sits behind the experimental toggle, the + // console UI behind the x64-only skia build. + if library_enabled && k.paired { + items.push(menu_item(MENU_LIBRARY)); + } if CONSOLE_UI_AVAILABLE && k.paired { items.push(menu_item(MENU_CONSOLE)); } @@ -464,6 +456,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { MENU_CONNECT => { initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) } + MENU_LIBRARY => { + *svc.ctx.shared.target.lock().unwrap() = target.clone(); + super::library::start_fetch(&svc.ctx, &svc.set_library); + svc.set_screen.call(Screen::Library); + } MENU_CONSOLE => { open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) } @@ -508,7 +505,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { })), )); } - body.push(tile_grid(tiles, cols)); + body.push(tile_grid(tiles, cols, TILE_GAP)); } // Discovered hosts not already saved above. @@ -560,7 +557,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))), )); } - body.push(tile_grid(tiles, cols)); + body.push(tile_grid(tiles, cols, TILE_GAP)); } // Forget confirmation (modal; shown while `forget` holds a pending host). Confirmed first, diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs new file mode 100644 index 00000000..773eaf86 --- /dev/null +++ b/clients/windows/src/app/library.rs @@ -0,0 +1,355 @@ +//! The game library page (mouse/keyboard): the target host's library as a responsive +//! poster grid over `pf-client-core::library` — the WinUI counterpart of the GTK shell's +//! `ui_library.rs`, sharing its service layer (mTLS fetch against the host's management +//! API, pre-classified errors, the 3-worker art pipeline) and its four states +//! (loading / error+retry / empty / grid). Reached from a paired host's "…" menu +//! ("Browse library…", behind the Settings "Show game library" experimental toggle); +//! picking a title starts a normal stream carrying `--launch id` — the host launches the +//! app during the connect handshake. +//! +//! Poster bytes land in a small disk cache (`%LOCALAPPDATA%\punktfunk\art-cache`) and the +//! `Image` widget loads `file:///` URIs from it — reactor's `ImageSource` has no +//! from-bytes constructor, and the cache makes revisits (and offline browsing) instant. +//! +//! Re-render discipline: the fetch and the art stream complete on worker threads, so the +//! whole [`LibraryState`] lives in ROOT state (see the app module docs) and arrives here +//! as a prop; `Shared::library_gen` invalidates a superseded fetch exactly like the +//! speed test's generation guard. + +use super::connect::initiate_launch; +use super::style::*; +use super::{AppCtx, Screen, Svc}; +use pf_client_core::library; +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use windows_reactor::*; + +/// Poster-grid metrics: minimum poster-column width before dropping a column, the gap, +/// and the 2:3 portrait ratio. +const POSTER_MIN_WIDTH: f64 = 150.0; +const POSTER_GAP: f64 = 12.0; +const POSTER_RATIO: f64 = 1.5; + +/// One game, as the grid renders it (the wire `GameEntry` minus the artwork paths, which +/// resolve into `LibraryState::art`). +#[derive(Clone, PartialEq)] +pub(crate) struct Game { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) store: String, +} + +#[derive(Clone, PartialEq, Default)] +pub(crate) enum LibraryPhase { + #[default] + Loading, + Failed(String), + Empty, + Ready(Vec), +} + +/// The page's whole thread-driven state: the fetch phase plus poster art as it lands +/// (game id → `file:///` URI into the disk cache). +#[derive(Clone, PartialEq, Default)] +pub(crate) struct LibraryState { + pub(crate) phase: LibraryPhase, + pub(crate) art: HashMap, +} + +/// Props for the library page: the services plus the fetch/art state driving re-render. +#[derive(Clone)] +pub(crate) struct LibraryProps { + pub(crate) svc: Svc, + pub(crate) state: LibraryState, +} + +impl PartialEq for LibraryProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.state == other.state + } +} + +/// Fetch the library for `Shared::target` off the UI thread, publishing into root state: +/// phase first, then art entries as the workers stream them in. A newer call (re-open, +/// Retry, another host) bumps `Shared::library_gen`, and a superseded worker stops +/// publishing — the speed test's generation pattern. +pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState) { + let target = ctx.shared.target.lock().unwrap().clone(); + let generation = ctx.shared.library_gen.fetch_add(1, Ordering::SeqCst) + 1; + set_library.call(LibraryState::default()); // Loading, no art + let (shared, identity, set) = ( + ctx.shared.clone(), + ctx.identity.clone(), + set_library.clone(), + ); + std::thread::Builder::new() + .name("pf-library".into()) + .spawn(move || { + let pin = target.fp_hex.as_deref().and_then(crate::trust::parse_hex32); + let publish = |state: &LibraryState| { + if shared.library_gen.load(Ordering::SeqCst) == generation { + set.call(state.clone()); + } + }; + let mut state = LibraryState::default(); + let games = match library::fetch_games( + &target.addr, + library::DEFAULT_MGMT_PORT, + &identity, + pin, + ) { + Ok(games) => games, + Err(e) => { + state.phase = LibraryPhase::Failed(e.to_string()); + return publish(&state); + } + }; + if games.is_empty() { + state.phase = LibraryPhase::Empty; + return publish(&state); + } + + // Seed cached posters; queue the art pipeline for the rest. + let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT); + let cache = art_cache_dir(); + let mut jobs: VecDeque<(String, Vec)> = VecDeque::new(); + for g in &games { + match cache.as_deref().and_then(|d| cached_art_uri(d, &g.id)) { + Some(uri) => { + state.art.insert(g.id.clone(), uri); + } + None => { + let candidates = g.art.poster_candidates(&base); + if !candidates.is_empty() { + jobs.push_back((g.id.clone(), candidates)); + } + } + } + } + state.phase = LibraryPhase::Ready( + games + .iter() + .map(|g| Game { + id: g.id.clone(), + title: g.title.clone(), + store: g.store.clone(), + }) + .collect(), + ); + publish(&state); + + if jobs.is_empty() { + return; + } + let rx = library::spawn_art_fetch(base, identity, pin, jobs); + while let Ok((id, bytes)) = rx.recv_blocking() { + if shared.library_gen.load(Ordering::SeqCst) != generation { + return; // superseded — stop touching state (and the cache is shared anyway) + } + if let Some(uri) = cache.as_deref().and_then(|d| store_art(d, &id, &bytes)) { + state.art.insert(id, uri); + publish(&state); + } + } + }) + .ok(); +} + +/// `%LOCALAPPDATA%\punktfunk\art-cache` — poster bytes by game id, so the `Image` widget +/// has a `file:///` URI to load and revisits skip the network entirely. Small (one poster +/// per library entry); no eviction yet. +fn art_cache_dir() -> Option { + let base = std::env::var_os("LOCALAPPDATA")?; + Some(PathBuf::from(base).join("punktfunk").join("art-cache")) +} + +/// The cache filename for a game id (`steam:570` → `steam_570.img`) — ids are short and +/// store-qualified, so the sanitized form stays unique in practice. +fn art_file(dir: &Path, id: &str) -> PathBuf { + let safe: String = id + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + dir.join(format!("{safe}.img")) +} + +fn cached_art_uri(dir: &Path, id: &str) -> Option { + let p = art_file(dir, id); + p.exists().then(|| file_uri(&p)) +} + +fn store_art(dir: &Path, id: &str, bytes: &[u8]) -> Option { + std::fs::create_dir_all(dir).ok()?; + let p = art_file(dir, id); + std::fs::write(&p, bytes).ok()?; + Some(file_uri(&p)) +} + +fn file_uri(p: &Path) -> String { + format!("file:///{}", p.display().to_string().replace('\\', "/")) +} + +/// The store badge text — shared spelling with the GTK page and the console UI's posters. +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 poster: the first letters of the first two words. +fn initials(title: &str) -> String { + title + .split_whitespace() + .take(2) + .filter_map(|w| w.chars().next()) + .flat_map(char::to_uppercase) + .collect() +} + +/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store +/// badge overlaid top-left, the title below, tap-to-launch across the whole tile. +fn poster_tile( + game: &Game, + art_uri: Option<&str>, + poster_h: f64, + on_tap: Box, +) -> Element { + let poster: Element = match art_uri { + Some(uri) => Image::new_with_uri(uri) + .stretch(Stretch::UniformToFill) + .height(poster_h) + .into(), + None => border( + text_block(initials(&game.title)) + .font_size(28.0) + .semibold() + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center), + ) + .background(ThemeRef::SubtleFill) + .height(poster_h) + .into(), + }; + let framed = border(grid(vec![ + poster, + pill(store_label(&game.store), Pill::Neutral) + .horizontal_alignment(HorizontalAlignment::Left) + .vertical_alignment(VerticalAlignment::Top) + .margin(uniform(6.0)) + .into(), + ])) + .corner_radius(8.0) + .border_brush(ThemeRef::CardStroke) + .border_thickness(uniform(1.0)); + + border( + vstack(( + framed, + text_block(&game.title) + .font_size(12.0) + .wrap() + .margin(edges(2.0, 6.0, 2.0, 0.0)), + )) + .spacing(0.0), + ) + .background(hit_test_backstop()) + .on_tapped(on_tap) + .into() +} + +pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element { + let ctx = &props.svc.ctx; + let target = ctx.shared.target.lock().unwrap().clone(); + let (ss, st) = (props.svc.set_screen.clone(), props.svc.set_status.clone()); + + // Responsive poster columns from the live window width (the hosts page's pattern). + let window = cx.use_inner_size(); + let content_w = (window.width - 64.0).clamp(POSTER_MIN_WIDTH, 1120.0); + let cols = + (((content_w + POSTER_GAP) / (POSTER_MIN_WIDTH + POSTER_GAP)).floor() as usize).clamp(2, 6); + let tile_w = (content_w - POSTER_GAP * (cols as f64 - 1.0)) / cols as f64; + let poster_h = tile_w * POSTER_RATIO; + + let back_btn = button("Back").icon(Symbol::Back).on_click({ + let ss = ss.clone(); + move || ss.call(Screen::Hosts) + }); + let title = if target.name.is_empty() { + "Game library".to_string() + } else { + format!("Game library \u{00B7} {}", target.name) + }; + let mut body: Vec = vec![page_header(&title, back_btn)]; + + match &props.state.phase { + LibraryPhase::Loading => body.push( + card( + hstack(( + ProgressRing::indeterminate().width(18.0).height(18.0), + text_block("Loading the library\u{2026}").foreground(ThemeRef::SecondaryText), + )) + .spacing(12.0), + ) + .into(), + ), + LibraryPhase::Failed(msg) => { + body.push( + InfoBar::new("Couldn't load the library") + .message(msg.clone()) + .error() + .is_closable(false) + .into(), + ); + let (ctx2, set_library) = (ctx.clone(), props.svc.set_library.clone()); + body.push( + button("Retry") + .accent() + .icon(Symbol::Refresh) + .on_click(move || start_fetch(&ctx2, &set_library)) + .horizontal_alignment(HorizontalAlignment::Left) + .into(), + ); + } + LibraryPhase::Empty => body.push( + card( + text_block( + "No games yet \u{2014} add titles to the host's library (its console or web \ + UI) and they appear here.", + ) + .wrap() + .foreground(ThemeRef::SecondaryText), + ) + .into(), + ), + LibraryPhase::Ready(games) => { + let tiles: Vec = games + .iter() + .map(|g| { + let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); + let (target, id) = (target.clone(), g.id.clone()); + poster_tile( + g, + props.state.art.get(&g.id).map(String::as_str), + poster_h, + Box::new(move || { + initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st) + }), + ) + }) + .collect(); + body.push(tile_grid(tiles, cols, POSTER_GAP)); + } + } + + page_wide(body) +} diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 83370b86..e88944de 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -26,6 +26,7 @@ mod connect; mod help; mod hosts; +mod library; mod licenses; mod pair; mod settings; @@ -67,6 +68,9 @@ pub(crate) enum Screen { Pair, /// Per-host network speed test (probe burst + recommended bitrate). SpeedTest, + /// The target host's game library (poster grid; tap-to-launch) — paired hosts only, + /// behind the "Show game library" experimental toggle. + Library, } /// The host we're about to connect to / pair with / speed-test (carried into those screens @@ -100,6 +104,9 @@ pub(crate) struct Svc { /// Speed-test lifecycle lives in root state (thread-driven — see the module docs); the hosts /// page resets it to `Running` before navigating, the probe worker completes it. pub(crate) set_speed: AsyncSetState, + /// Library fetch/art state — root for the same reason; the hosts page kicks a fetch + /// off before navigating, the worker (and the art stream) completes it. + pub(crate) set_library: AsyncSetState, } impl PartialEq for Svc { @@ -138,6 +145,9 @@ pub(crate) struct Shared { /// Whether the live session child is a `--browse` console-UI run (vs a stream) — the /// session status page words itself accordingly. Set by each spawn. pub(crate) browse: std::sync::atomic::AtomicBool, + /// Library-fetch generation (the speed test's guard pattern): bumped per fetch so a + /// superseded worker (re-open, Retry, another host) stops publishing. + pub(crate) library_gen: std::sync::atomic::AtomicU64, } pub struct AppCtx { @@ -270,6 +280,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { // Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state // (thread-driven → must be root to re-render — see the module docs), passed to the hosts page. let (probed, set_probed) = cx.use_async_state(HashMap::::new()); + // Library fetch/art state (thread-driven → root; see `library::start_fetch`). + let (library, set_library) = cx.use_async_state(library::LibraryState::default()); // Continuous LAN discovery (spawned once). cx.use_effect((), { @@ -468,6 +480,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { set_screen: set_screen.clone(), set_status: set_status.clone(), set_speed: set_speed.clone(), + set_library: set_library.clone(), }; let body = match &screen { Screen::Hosts => component( @@ -505,6 +518,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { Screen::Help => help::help_page(&set_screen), Screen::Pair => component(pair::pair_page, svc), Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), + Screen::Library => component( + library::library_page, + library::LibraryProps { + svc, + state: library, + }, + ), // Spawn mode (the default): the stream runs in the punktfunk-session child's own // window; this screen is a status page (no hooks — inline is sound). The legacy // in-process SwapChainPanel page stays behind the "Streaming engine" setting / diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 48989f7c..8c235108 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -343,6 +343,16 @@ pub(crate) fn settings_page( let ss = set_screen.clone(); button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) }; + let library_toggle = setting_toggle( + ctx, + "Show game library (experimental)", + s.library_enabled, + |s, on| s.library_enabled = on, + ) + .tooltip( + "Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \ + launches in the stream. Mirrors the Apple client's toggle.", + ); // The selected section's content — per-control guidance lives on hover tooltips, so the // card is just the controls. @@ -375,7 +385,10 @@ pub(crate) fn settings_page( "Audio", settings_card(vec![channels_combo.into(), mic_toggle.into()]), ), - "about" => ("About", settings_card(vec![licenses_button.into()])), + "about" => ( + "About", + settings_card(vec![library_toggle.into(), licenses_button.into()]), + ), _ => ( "Display", settings_card(vec![ diff --git a/clients/windows/src/app/style.rs b/clients/windows/src/app/style.rs index 764acfc8..488f7da4 100644 --- a/clients/windows/src/app/style.rs +++ b/clients/windows/src/app/style.rs @@ -88,6 +88,23 @@ pub(crate) fn page_wide(children: Vec) -> Element { scroll_view(col).into() } +/// Lay tiles into a `cols`-wide grid of equal-width star columns (rows share the height of +/// their tallest tile, so a grid row always lines up). Shared by the hosts page's host +/// tiles and the library page's posters. +pub(crate) fn tile_grid(tiles: Vec, cols: usize, gap: f64) -> Element { + let rows = tiles.len().div_ceil(cols); + let mut children = Vec::with_capacity(tiles.len()); + for (i, t) in tiles.into_iter().enumerate() { + children.push(t.grid_row((i / cols) as i32).grid_column((i % cols) as i32)); + } + grid(children) + .columns(vec![GridLength::Star(1.0); cols]) + .rows(vec![GridLength::Auto; rows]) + .column_spacing(gap) + .row_spacing(gap) + .into() +} + /// A page header: a large bold title on the left, one action button on the right. pub(crate) fn page_header(title: &str, action: Button) -> Element { grid(( diff --git a/clients/windows/src/shell_window.rs b/clients/windows/src/shell_window.rs index ffe73e83..e7b18578 100644 --- a/clients/windows/src/shell_window.rs +++ b/clients/windows/src/shell_window.rs @@ -10,9 +10,9 @@ //! `stream::window_dpi`. use std::sync::atomic::{AtomicIsize, Ordering}; -use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::{HWND, RECT}; use windows::Win32::UI::WindowsAndMessaging::{ - FindWindowW, IsWindow, SetForegroundWindow, ShowWindow, SW_HIDE, SW_SHOW, + FindWindowW, GetWindowRect, IsWindow, SetForegroundWindow, ShowWindow, SW_HIDE, SW_SHOW, }; static SHELL_HWND: AtomicIsize = AtomicIsize::new(0); @@ -53,3 +53,13 @@ pub(crate) fn restore() { } } } + +/// The shell window's top-left in desktop coordinates — passed to the spawned session +/// (`--window-pos`) so its window opens on the SAME monitor, roughly where the shell is, +/// and the visibility handoff reads as one window changing content. +pub(crate) fn position() -> Option<(i32, i32)> { + let h = shell_hwnd()?; + let mut r = RECT::default(); + unsafe { GetWindowRect(h, &mut r).ok()? }; + Some((r.left, r.top)) +} diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index 25a4370d..a8e77b3c 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -97,6 +97,7 @@ pub(crate) fn session_binary() -> std::path::PathBuf { /// streams fullscreen" toggle); `launch` carries a library title id for the host to /// launch during the handshake. `Err` = the spawn itself failed (binary missing?) — /// surfaced as a connect error by the caller. +#[allow(clippy::too_many_arguments)] // one cohesive spawn spec (session_params precedent) pub(crate) fn spawn_session( addr: &str, port: u16, @@ -120,6 +121,7 @@ pub(crate) fn spawn_session( if let Some(id) = launch { cmd.arg("--launch").arg(id); } + add_window_pos(&mut cmd); spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) } @@ -141,9 +143,19 @@ pub(crate) fn spawn_browse( if fullscreen { cmd.arg("--fullscreen"); } + add_window_pos(&mut cmd); spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) } +/// Hand the shell window's position to the child (`--window-pos`) so the session window +/// opens on the same monitor, where the shell is — the hide/restore handoff then reads as +/// one window changing content instead of a window jumping displays. +fn add_window_pos(cmd: &mut Command) { + if let Some((x, y)) = crate::shell_window::position() { + cmd.arg("--window-pos").arg(format!("{x},{y}")); + } +} + /// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`]. fn spawn_with( mut cmd: Command, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 378daac5..82d9930d 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -246,11 +246,12 @@ pub fn probe_reachable_many( ) -> Vec { let handles: Vec<_> = targets .into_iter() - .map(|(addr, port)| { - std::thread::spawn(move || NativeClient::probe(&addr, port, timeout)) - }) + .map(|(addr, port)| std::thread::spawn(move || NativeClient::probe(&addr, port, timeout))) .collect(); - handles.into_iter().map(|h| h.join().unwrap_or(false)).collect() + handles + .into_iter() + .map(|h| h.join().unwrap_or(false)) + .collect() } /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 86860915..8c2f5162 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -31,6 +31,12 @@ pub struct SessionOpts { pub window_title: String, /// Start fullscreen (gamescope / `--fullscreen`). pub fullscreen: bool, + /// The window's top-left in desktop coordinates; `None` = centered on the primary + /// display. The shells pass their own window's position so the stream opens on the + /// SAME monitor (and the shell⇄session visibility handoff reads as one window + /// changing content, not a window jumping displays). Fullscreen follows the display + /// this lands on. + pub window_pos: Option<(i32, i32)>, /// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live). pub print_stats: bool, /// Emit the `{"ready":true}` stdout line after the first presented frame. @@ -239,7 +245,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?; let mut window = { let mut b = video.window(&opts.window_title, 1280, 720); - b.position_centered().resizable().vulkan(); + match opts.window_pos { + Some((x, y)) => b.position(x, y), + None => b.position_centered(), + }; + b.resizable().vulkan(); if opts.fullscreen { b.fullscreen(); }