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
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:
@@ -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]| {
|
||||||
|
|||||||
@@ -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]| {
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 /
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user