feat(clients/windows): game library page + session window placement + help polish
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m56s
arch / build-publish (push) Successful in 5m56s
audit / bun-audit (push) Successful in 13s
audit / cargo-audit (push) Successful in 1m34s
ci / web (push) Successful in 1m11s
windows-host / package (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m27s
release / apple (push) Successful in 8m39s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
ci / bench (push) Successful in 5m0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m16s
apple / screenshots (push) Successful in 5m35s
ci / rust (push) Successful in 10m32s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 6m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
flatpak / build-publish (push) Successful in 5m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m33s
docker / deploy-docs (push) Successful in 18s

UX polish batch 2 (plan workstreams D/E5/E6):

- D: a mouse/keyboard game library page over pf-client-core::library (the GTK
  ui_library.rs counterpart): responsive 2-6 column poster grid (2:3 portraits,
  store badge, monogram placeholder while art streams in), loading / error+retry
  / empty / grid states, tap-to-play via a normal spawn carrying --launch id.
  Poster bytes land in a disk cache (%LOCALAPPDATA%\punktfunk\art-cache) so the
  Image widget loads file:/// URIs (reactor has no from-bytes source) and
  revisits render instantly. Reached from a paired host's "Browse library..."
  menu item behind the new "Show game library (experimental)" Settings toggle
  (the shared library_enabled field, Apple/GTK parity). A Shared::library_gen
  generation guard keeps a superseded fetch from publishing (speed-test pattern).

- E5: the session window opens at the shell's own top-left (--window-pos, new
  SessionOpts::window_pos) instead of centered on the primary display - streams
  land on the monitor the shell is on, and the hide/restore handoff reads as one
  window changing content. Fullscreen follows that display.

- E6: the Help shortcuts add Alt+Enter and the controller escape chord
  (LB+RB+Start+Back, hold to disconnect).

Also: rustfmt normalization of the merged probe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 00:39:11 +02:00
parent f5f186b691
commit f4850625bd
13 changed files with 531 additions and 33 deletions
+4 -1
View File
@@ -8,7 +8,9 @@
//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no //! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no
//! host (portrait paths starting with `/` load from disk), the GPU-only dev path. //! 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_client_core::{library, trust};
use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay}; use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay};
use pf_presenter::overlay::OverlayAction; use pf_presenter::overlay::OverlayAction;
@@ -61,6 +63,7 @@ pub fn run(target: &str) -> u8 {
let opts = pf_presenter::SessionOpts { let opts = pf_presenter::SessionOpts {
window_title: format!("Punktfunk · {host_label}"), window_title: format!("Punktfunk · {host_label}"),
fullscreen: fullscreen_mode(), fullscreen: fullscreen_mode(),
window_pos: window_pos(),
print_stats: settings.show_stats || arg_flag("--stats"), print_stats: settings.show_stats || arg_flag("--stats"),
json_status, json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
+10
View File
@@ -52,6 +52,15 @@ mod session_main {
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() || 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. /// `host[:port]`, port defaulting to the native 9777.
pub(crate) fn parse_host_port(target: &str) -> (String, u16) { pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
match target.rsplit_once(':') { match target.rsplit_once(':') {
@@ -308,6 +317,7 @@ mod session_main {
let opts = pf_presenter::SessionOpts { let opts = pf_presenter::SessionOpts {
window_title: format!("Punktfunk · {title}"), window_title: format!("Punktfunk · {title}"),
fullscreen, fullscreen,
window_pos: window_pos(),
print_stats: settings.show_stats || arg_flag("--stats"), print_stats: settings.show_stats || arg_flag("--stats"),
json_status: true, json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
+46 -2
View File
@@ -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<AppCtx>,
target: Target,
launch: String,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
*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 /// 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). /// of the display our window is on (mirrors the Linux/Swift clients' native-display default).
pub(crate) fn resolve_mode(s: &Settings) -> Mode { 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, /// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag,
/// so it can't loop. /// so it can't loop.
wake_on_fail: bool, 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<String>,
} }
impl Default for ConnectOpts { impl Default for ConnectOpts {
@@ -191,6 +234,7 @@ impl Default for ConnectOpts {
awaiting_approval: false, awaiting_approval: false,
cancel: None, cancel: None,
wake_on_fail: false, wake_on_fail: false,
launch: None,
} }
} }
} }
@@ -396,7 +440,7 @@ fn connect_spawn(
&fp_arg, &fp_arg,
opts.connect_timeout.as_secs(), opts.connect_timeout.as_secs(),
fullscreen, fullscreen,
None, opts.launch.as_deref(),
child, child,
move |event| { move |event| {
use crate::spawn::SpawnEvent; use crate::spawn::SpawnEvent;
@@ -544,7 +588,7 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
persist_paired: true, persist_paired: true,
awaiting_approval: true, awaiting_approval: true,
cancel: Some(cancel), cancel: Some(cancel),
wake_on_fail: false, ..ConnectOpts::default()
}, },
); );
} }
+8 -2
View File
@@ -8,15 +8,21 @@ use super::Screen;
use windows_reactor::*; use windows_reactor::*;
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it /// 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)] = &[ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
("F11", "Toggle fullscreen"), ("F11 / Alt+Enter", "Toggle fullscreen"),
( (
"Ctrl+Alt+Shift+Q", "Ctrl+Alt+Shift+Q",
"Release captured input (click the stream to recapture)", "Release captured input (click the stream to recapture)",
), ),
("Ctrl+Alt+Shift+D", "Disconnect"), ("Ctrl+Alt+Shift+D", "Disconnect"),
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), ("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. /// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
+17 -20
View File
@@ -13,6 +13,7 @@ use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect"; const MENU_CONNECT: &str = "Connect";
const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_CONSOLE: &str = "Open console UI"; const MENU_CONSOLE: &str = "Open console UI";
const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host"; const MENU_WAKE: &str = "Wake host";
@@ -186,22 +187,6 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.into() .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<Element>, 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. /// 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. /// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
fn rename_editor( fn rename_editor(
@@ -269,6 +254,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
set: props.set_hover.clone(), set: props.set_hover.clone(),
}; };
let known = KnownHosts::load(); 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 // 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. // 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") .automation_name("More options")
.menu_flyout({ .menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT)]; let mut items = vec![menu_item(MENU_CONNECT)];
// The gamepad library — paired hosts only (the mgmt API needs the // The library surfaces — mouse/KB page and the gamepad console UI —
// paired identity) and only where the session ships the 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 { if CONSOLE_UI_AVAILABLE && k.paired {
items.push(menu_item(MENU_CONSOLE)); items.push(menu_item(MENU_CONSOLE));
} }
@@ -464,6 +456,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
MENU_CONNECT => { MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) 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 => { MENU_CONSOLE => {
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) 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. // 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))), 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, // Forget confirmation (modal; shown while `forget` holds a pending host). Confirmed first,
+355
View File
@@ -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<Game>),
}
/// 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<String, String>,
}
/// 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<AppCtx>, set_library: &AsyncSetState<LibraryState>) {
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<String>)> = 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<PathBuf> {
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<String> {
let p = art_file(dir, id);
p.exists().then(|| file_uri(&p))
}
fn store_art(dir: &Path, id: &str, bytes: &[u8]) -> Option<String> {
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<dyn Fn()>,
) -> 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<Element> = 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<Element> = 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)
}
+20
View File
@@ -26,6 +26,7 @@
mod connect; mod connect;
mod help; mod help;
mod hosts; mod hosts;
mod library;
mod licenses; mod licenses;
mod pair; mod pair;
mod settings; mod settings;
@@ -67,6 +68,9 @@ pub(crate) enum Screen {
Pair, Pair,
/// Per-host network speed test (probe burst + recommended bitrate). /// Per-host network speed test (probe burst + recommended bitrate).
SpeedTest, 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 /// 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 /// 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. /// page resets it to `Running` before navigating, the probe worker completes it.
pub(crate) set_speed: AsyncSetState<SpeedState>, pub(crate) set_speed: AsyncSetState<SpeedState>,
/// 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<library::LibraryState>,
} }
impl PartialEq for Svc { 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 /// 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. /// session status page words itself accordingly. Set by each spawn.
pub(crate) browse: std::sync::atomic::AtomicBool, 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 { pub struct AppCtx {
@@ -270,6 +280,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state // 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. // (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::<String, bool>::new()); let (probed, set_probed) = cx.use_async_state(HashMap::<String, bool>::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). // Continuous LAN discovery (spawned once).
cx.use_effect((), { cx.use_effect((), {
@@ -468,6 +480,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_screen: set_screen.clone(), set_screen: set_screen.clone(),
set_status: set_status.clone(), set_status: set_status.clone(),
set_speed: set_speed.clone(), set_speed: set_speed.clone(),
set_library: set_library.clone(),
}; };
let body = match &screen { let body = match &screen {
Screen::Hosts => component( Screen::Hosts => component(
@@ -505,6 +518,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
Screen::Help => help::help_page(&set_screen), Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc), Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), 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 // 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 // window; this screen is a status page (no hooks — inline is sound). The legacy
// in-process SwapChainPanel page stays behind the "Streaming engine" setting / // in-process SwapChainPanel page stays behind the "Streaming engine" setting /
+14 -1
View File
@@ -343,6 +343,16 @@ pub(crate) fn settings_page(
let ss = set_screen.clone(); let ss = set_screen.clone();
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) 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 // The selected section's content — per-control guidance lives on hover tooltips, so the
// card is just the controls. // card is just the controls.
@@ -375,7 +385,10 @@ pub(crate) fn settings_page(
"Audio", "Audio",
settings_card(vec![channels_combo.into(), mic_toggle.into()]), 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", "Display",
settings_card(vec![ settings_card(vec![
+17
View File
@@ -88,6 +88,23 @@ pub(crate) fn page_wide(children: Vec<Element>) -> Element {
scroll_view(col).into() 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<Element>, 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. /// 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 { pub(crate) fn page_header(title: &str, action: Button) -> Element {
grid(( grid((
+12 -2
View File
@@ -10,9 +10,9 @@
//! `stream::window_dpi`. //! `stream::window_dpi`.
use std::sync::atomic::{AtomicIsize, Ordering}; use std::sync::atomic::{AtomicIsize, Ordering};
use windows::Win32::Foundation::HWND; use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{ 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); 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))
}
+12
View File
@@ -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 /// streams fullscreen" toggle); `launch` carries a library title id for the host to
/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) — /// launch during the handshake. `Err` = the spawn itself failed (binary missing?) —
/// surfaced as a connect error by the caller. /// 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( pub(crate) fn spawn_session(
addr: &str, addr: &str,
port: u16, port: u16,
@@ -120,6 +121,7 @@ pub(crate) fn spawn_session(
if let Some(id) = launch { if let Some(id) = launch {
cmd.arg("--launch").arg(id); cmd.arg("--launch").arg(id);
} }
add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
} }
@@ -141,9 +143,19 @@ pub(crate) fn spawn_browse(
if fullscreen { if fullscreen {
cmd.arg("--fullscreen"); cmd.arg("--fullscreen");
} }
add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) 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`]. /// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`].
fn spawn_with( fn spawn_with(
mut cmd: Command, mut cmd: Command,
+5 -4
View File
@@ -246,11 +246,12 @@ pub fn probe_reachable_many(
) -> Vec<bool> { ) -> Vec<bool> {
let handles: Vec<_> = targets let handles: Vec<_> = targets
.into_iter() .into_iter()
.map(|(addr, port)| { .map(|(addr, port)| std::thread::spawn(move || NativeClient::probe(&addr, port, timeout)))
std::thread::spawn(move || NativeClient::probe(&addr, port, timeout))
})
.collect(); .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 /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
+11 -1
View File
@@ -31,6 +31,12 @@ pub struct SessionOpts {
pub window_title: String, pub window_title: String,
/// Start fullscreen (gamescope / `--fullscreen`). /// Start fullscreen (gamescope / `--fullscreen`).
pub fullscreen: bool, 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). /// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
pub print_stats: bool, pub print_stats: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame. /// 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<Option<Outcome>
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?; .map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
let mut window = { let mut window = {
let mut b = video.window(&opts.window_title, 1280, 720); 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 { if opts.fullscreen {
b.fullscreen(); b.fullscreen();
} }