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:
@@ -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
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<bool>, 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<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.
|
||||
/// 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,
|
||||
|
||||
@@ -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 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<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 {
|
||||
@@ -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<AppCtx>) -> 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::<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).
|
||||
cx.use_effect((), {
|
||||
@@ -468,6 +480,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> 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<AppCtx>) -> 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 /
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -88,6 +88,23 @@ pub(crate) fn page_wide(children: Vec<Element>) -> 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<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.
|
||||
pub(crate) fn page_header(title: &str, action: Button) -> Element {
|
||||
grid((
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user