//! The game library page (mouse/keyboard): the target host's library as a responsive //! poster grid over `pf-client-core::library` — the WinUI counterpart of the GTK shell's //! `ui_library.rs`, sharing its service layer (mTLS fetch against the host's management //! API, pre-classified errors, the 3-worker art pipeline) and its four states //! (loading / error+retry / empty / grid). Reached from a paired host's "…" menu //! ("Browse library…", behind the Settings "Show game library" experimental toggle); //! picking a title starts a normal stream carrying `--launch id` — the host launches the //! app during the connect handshake. //! //! Poster bytes land in a small disk cache (`%LOCALAPPDATA%\punktfunk\art-cache`) and the //! `Image` widget loads `file:///` URIs from it — reactor's `ImageSource` has no //! from-bytes constructor, and the cache makes revisits (and offline browsing) instant. //! //! Re-render discipline: the fetch and the art stream complete on worker threads, so the //! whole [`LibraryState`] lives in ROOT state (see the app module docs) and arrives here //! as a prop; `Shared::library_gen` invalidates a superseded fetch exactly like the //! speed test's generation guard. use super::connect::initiate_launch; use super::style::*; use super::{AppCtx, Screen, Svc}; use pf_client_core::library; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Arc; use windows_reactor::*; /// Poster-grid metrics: minimum poster-column width before dropping a column, the gap, /// and the 2:3 portrait ratio. const POSTER_MIN_WIDTH: f64 = 150.0; const POSTER_GAP: f64 = 12.0; const POSTER_RATIO: f64 = 1.5; /// One game, as the grid renders it (the wire `GameEntry` minus the artwork paths, which /// resolve into `LibraryState::art`). #[derive(Clone, PartialEq)] pub(crate) struct Game { pub(crate) id: String, pub(crate) title: String, pub(crate) store: String, } #[derive(Clone, PartialEq, Default)] pub(crate) enum LibraryPhase { #[default] Loading, Failed(String), Empty, Ready(Vec), } /// The page's whole thread-driven state: the fetch phase plus poster art as it lands /// (game id → `file:///` URI into the disk cache). #[derive(Clone, PartialEq, Default)] pub(crate) struct LibraryState { pub(crate) phase: LibraryPhase, pub(crate) art: HashMap, } /// Props for the library page: the services plus the fetch/art state driving re-render. #[derive(Clone)] pub(crate) struct LibraryProps { pub(crate) svc: Svc, pub(crate) state: LibraryState, } impl PartialEq for LibraryProps { fn eq(&self, other: &Self) -> bool { self.svc == other.svc && self.state == other.state } } /// Fetch the library for `Shared::target` off the UI thread, publishing into root state: /// phase first, then art entries as the workers stream them in. A newer call (re-open, /// Retry, another host) bumps `Shared::library_gen`, and a superseded worker stops /// publishing — the speed test's generation pattern. pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState) { let target = ctx.shared.target.lock().unwrap().clone(); let generation = ctx.shared.library_gen.fetch_add(1, Ordering::SeqCst) + 1; set_library.call(LibraryState::default()); // Loading, no art let (shared, identity, set) = ( ctx.shared.clone(), ctx.identity.clone(), set_library.clone(), ); std::thread::Builder::new() .name("pf-library".into()) .spawn(move || { let pin = target.fp_hex.as_deref().and_then(crate::trust::parse_hex32); let publish = |state: &LibraryState| { if shared.library_gen.load(Ordering::SeqCst) == generation { set.call(state.clone()); } }; let mut state = LibraryState::default(); let games = match library::fetch_games( &target.addr, library::DEFAULT_MGMT_PORT, &identity, pin, ) { Ok(games) => games, Err(e) => { state.phase = LibraryPhase::Failed(e.to_string()); return publish(&state); } }; if games.is_empty() { state.phase = LibraryPhase::Empty; return publish(&state); } // Seed cached posters; queue the art pipeline for the rest. let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT); let cache = art_cache_dir(); let mut jobs: VecDeque<(String, Vec)> = VecDeque::new(); for g in &games { match cache.as_deref().and_then(|d| cached_art_uri(d, &g.id)) { Some(uri) => { state.art.insert(g.id.clone(), uri); } None => { let candidates = g.art.poster_candidates(&base); if !candidates.is_empty() { jobs.push_back((g.id.clone(), candidates)); } } } } state.phase = LibraryPhase::Ready( games .iter() .map(|g| Game { id: g.id.clone(), title: g.title.clone(), store: g.store.clone(), }) .collect(), ); publish(&state); if jobs.is_empty() { return; } let rx = library::spawn_art_fetch(base, identity, pin, jobs); while let Ok((id, bytes)) = rx.recv_blocking() { if shared.library_gen.load(Ordering::SeqCst) != generation { return; // superseded — stop touching state (and the cache is shared anyway) } if let Some(uri) = cache.as_deref().and_then(|d| store_art(d, &id, &bytes)) { state.art.insert(id, uri); publish(&state); } } }) .ok(); } /// `%LOCALAPPDATA%\punktfunk\art-cache` — poster bytes by game id, so the `Image` widget /// has a `file:///` URI to load and revisits skip the network entirely. Small (one poster /// per library entry); no eviction yet. fn art_cache_dir() -> Option { let base = std::env::var_os("LOCALAPPDATA")?; Some(PathBuf::from(base).join("punktfunk").join("art-cache")) } /// The cache filename for a game id (`steam:570` → `steam_570.img`) — ids are short and /// store-qualified, so the sanitized form stays unique in practice. fn art_file(dir: &Path, id: &str) -> PathBuf { let safe: String = id .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); dir.join(format!("{safe}.img")) } fn cached_art_uri(dir: &Path, id: &str) -> Option { let p = art_file(dir, id); p.exists().then(|| file_uri(&p)) } fn store_art(dir: &Path, id: &str, bytes: &[u8]) -> Option { std::fs::create_dir_all(dir).ok()?; let p = art_file(dir, id); std::fs::write(&p, bytes).ok()?; Some(file_uri(&p)) } fn file_uri(p: &Path) -> String { format!("file:///{}", p.display().to_string().replace('\\', "/")) } /// The store badge text — shared spelling with the GTK page and the console UI's posters. fn store_label(store: &str) -> &'static str { match store { "steam" => "Steam", "custom" => "Custom", "heroic" => "Heroic", "lutris" => "Lutris", "epic" => "Epic", "gog" => "GOG", "xbox" => "Xbox", _ => "Game", } } /// Monogram for the placeholder poster: the first letters of the first two words. fn initials(title: &str) -> String { title .split_whitespace() .take(2) .filter_map(|w| w.chars().next()) .flat_map(char::to_uppercase) .collect() } /// One poster tile: the artwork (or a monogram placeholder while it loads) with the store /// badge overlaid top-left, the title below, tap-to-launch across the whole tile. fn poster_tile( game: &Game, art_uri: Option<&str>, poster_h: f64, on_tap: Box, ) -> Element { let poster: Element = match art_uri { Some(uri) => Image::new_with_uri(uri) .stretch(Stretch::UniformToFill) .height(poster_h) .into(), None => border( text_block(initials(&game.title)) .font_size(28.0) .semibold() .foreground(ThemeRef::SecondaryText) .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center), ) .background(ThemeRef::SubtleFill) .height(poster_h) .into(), }; let framed = border(grid(vec![ poster, pill(store_label(&game.store), Pill::Neutral) .horizontal_alignment(HorizontalAlignment::Left) .vertical_alignment(VerticalAlignment::Top) .margin(uniform(6.0)) .into(), ])) .corner_radius(8.0) .border_brush(ThemeRef::CardStroke) .border_thickness(uniform(1.0)); border( vstack(( framed, text_block(&game.title) .font_size(12.0) .wrap() .margin(edges(2.0, 6.0, 2.0, 0.0)), )) .spacing(0.0), ) .background(hit_test_backstop()) .on_tapped(on_tap) .into() } pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element { let ctx = &props.svc.ctx; let target = ctx.shared.target.lock().unwrap().clone(); let (ss, st) = (props.svc.set_screen.clone(), props.svc.set_status.clone()); // Responsive poster columns from the live window width (the hosts page's pattern). let window = cx.use_inner_size(); let content_w = (window.width - 64.0).clamp(POSTER_MIN_WIDTH, 1120.0); let cols = (((content_w + POSTER_GAP) / (POSTER_MIN_WIDTH + POSTER_GAP)).floor() as usize).clamp(2, 6); let tile_w = (content_w - POSTER_GAP * (cols as f64 - 1.0)) / cols as f64; let poster_h = tile_w * POSTER_RATIO; let back_btn = button("Back").icon(Symbol::Back).on_click({ let ss = ss.clone(); move || ss.call(Screen::Hosts) }); let title = if target.name.is_empty() { "Game library".to_string() } else { format!("Game library \u{00B7} {}", target.name) }; let mut body: Vec = vec![page_header(&title, back_btn)]; match &props.state.phase { LibraryPhase::Loading => body.push( card( hstack(( ProgressRing::indeterminate().width(18.0).height(18.0), text_block("Loading the library\u{2026}").foreground(ThemeRef::SecondaryText), )) .spacing(12.0), ) .into(), ), LibraryPhase::Failed(msg) => { body.push( InfoBar::new("Couldn't load the library") .message(msg.clone()) .error() .is_closable(false) .into(), ); let (ctx2, set_library) = (ctx.clone(), props.svc.set_library.clone()); body.push( button("Retry") .accent() .icon(Symbol::Refresh) .on_click(move || start_fetch(&ctx2, &set_library)) .horizontal_alignment(HorizontalAlignment::Left) .into(), ); } LibraryPhase::Empty => body.push( card( text_block( "No games yet \u{2014} add titles to the host's library (its console or web \ UI) and they appear here.", ) .wrap() .foreground(ThemeRef::SecondaryText), ) .into(), ), LibraryPhase::Ready(games) => { let tiles: Vec = games .iter() .map(|g| { let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); let (target, id) = (target.clone(), g.id.clone()); poster_tile( g, props.state.art.get(&g.id).map(String::as_str), poster_h, Box::new(move || { initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st) }), ) }) .collect(); body.push(tile_grid(tiles, cols, POSTER_GAP)); } } page_wide(body) }