Files
punktfunk/clients/linux/src/ui_library.rs
T
enricobuehler 57ae00a9c8
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
fix(clients): GTK + Decky polish batch from live Deck/Windows testing
GTK Linux client:
- hosts/library: clicking a card was dead — the handler was on
  FlowBoxChild::activate (never emitted on click); bridge child-activated
  → child.activate() on the FlowBox (ui_hosts, ui_library).
- stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were
  dropped because the key controller sat on the overlay, which loses focus
  to the header back button after nav.push+fullscreen — move it to the
  window and remove it on teardown.
- video: a mid-session VAAPI decode error rebuilt a software decoder but
  never requested a keyframe, so under the infinite GOP the picture stayed
  gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware
  decoder, and demote to software only after repeated failures.
- stream: fix a per-session Capture↔overlay reference cycle that leaked the
  overlay subtree + the Arc<NativeClient> on every session end — hold the
  overlay weakly.
- stream: accumulate the fractional wheel remainder so precision-scroll
  (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped.
- gamepad library: keep the launcher smooth on the Deck — freeze the aurora
  and trim the visible card range (fewer 3D offscreen passes) on low-power.
- gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to
  diagnose an empty controller list on the Deck.
- cli: --connect host:<badport> silently did nothing; default to 9777 + warn.
- css: add the missing .pf-neutral pill rule; fix the clipped most-recent
  accent (inset outline instead of a corner-clipped box-shadow bar).

Decky plugin:
- surface the on-screen library browser: label the host-row Games button.
- fix silent pin data-loss — the detached Games modal captured a frozen
  pins array, so pinning a second game clobbered the first; mirror pins in
  a ref and track the modal's pinned ids locally for a live label.
- route pair-required hosts through the pairing modal from the fullscreen
  Stream button (parity with the QAM panel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:37:04 +00:00

357 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's
//! unified library fetched over the management API (`library.rs`), pushed onto the nav
//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously
//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping
//! a title starts a session that asks the host to launch it (the library id rides the
//! Hello via `ConnectRequest::launch`).
use crate::app::App;
use crate::library::{self, GameEntry};
use crate::trust;
use crate::ui_hosts::ConnectRequest;
use adw::prelude::*;
use gtk::{gdk, glib};
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
/// card activation); dropped when the page is popped, which also winds down any in-flight
/// art consumer (its weak upgrade fails).
struct State {
app: Rc<App>,
/// The host this library belongs to — cards clone it and add `launch`.
req: ConnectRequest,
stack: gtk::Stack,
flow: gtk::FlowBox,
error_page: adw::StatusPage,
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
art: RefCell<HashMap<String, gdk::Texture>>,
/// The Picture each entry currently renders into (rebuilt per render), so async art
/// results land on the right card.
pics: RefCell<HashMap<String, gtk::Picture>>,
/// Screenshot mode: render injected entries only, never touch the network.
mock: Cell<bool>,
}
/// Open the library page for a saved host and start the fetch.
pub fn open(app: Rc<App>, req: ConnectRequest) {
let state = build(app.clone(), req);
load(&state);
}
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
/// entry id) with no host and no network — the CI `library` scene.
pub fn open_mock(
app: Rc<App>,
req: ConnectRequest,
games: Vec<GameEntry>,
art: Vec<(String, gdk::Texture)>,
) {
let state = build(app.clone(), req);
state.mock.set(true);
state.art.borrow_mut().extend(art);
if games.is_empty() {
state.stack.set_visible_child_name("empty");
} else {
render(&state, &games);
state.stack.set_visible_child_name("grid");
}
}
/// Build the page (loading / error / empty / grid states in a stack) and push it.
fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
let flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(6)
.column_spacing(12)
.row_spacing(18)
.valign(gtk::Align::Start)
.build();
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
// `activate` — bridge it so each poster's connect handler (below) runs on click.
flow.connect_child_activated(|_, child| {
child.activate();
});
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&flow);
let clamp = adw::Clamp::builder()
.maximum_size(1100)
.child(&content)
.build();
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let loading = gtk::Box::new(gtk::Orientation::Vertical, 12);
loading.set_valign(gtk::Align::Center);
let spinner = gtk::Spinner::new();
spinner.set_size_request(32, 32);
spinner.start();
spinner.set_halign(gtk::Align::Center);
loading.append(&spinner);
let loading_label = gtk::Label::new(Some("Loading library…"));
loading_label.add_css_class("dim-label");
loading.append(&loading_label);
let error_page = adw::StatusPage::builder()
.icon_name("dialog-error-symbolic")
.title("Couldn't load the library")
.build();
let retry = gtk::Button::with_label("Retry");
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_halign(gtk::Align::Center);
error_page.set_child(Some(&retry));
let empty = adw::StatusPage::builder()
.icon_name("applications-games-symbolic")
.title("No games found")
.description(
"No games found on this host. Install Steam titles or add custom \
entries in the host's web console.",
)
.build();
let stack = gtk::Stack::new();
stack.add_named(&loading, Some("loading"));
stack.add_named(&error_page, Some("error"));
stack.add_named(&empty, Some("empty"));
stack.add_named(&scrolled, Some("grid"));
let header = adw::HeaderBar::new();
let reload = gtk::Button::from_icon_name("view-refresh-symbolic");
reload.set_tooltip_text(Some("Reload"));
header.pack_end(&reload);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title(format!("{} — Library", req.name))
.child(&toolbar)
.build();
let state = Rc::new(State {
app: app.clone(),
req,
stack,
flow,
error_page,
art: RefCell::new(HashMap::new()),
pics: RefCell::new(HashMap::new()),
mock: Cell::new(false),
});
{
let state = state.clone();
reload.connect_clicked(move |_| load(&state));
}
{
let state = state.clone();
retry.connect_clicked(move |_| load(&state));
}
app.nav.push(&page);
state
}
/// The mgmt port for this host: the live mDNS `mgmt` TXT when the host is advertising,
/// else the well-known default (Apple's `effectiveMgmtPort`).
fn mgmt_port(state: &State) -> u16 {
state
.app
.hosts_ui()
.and_then(|h| h.mgmt_port_for(&state.req))
.unwrap_or(library::DEFAULT_MGMT_PORT)
}
/// Fetch the library off the main thread and route the result into the grid or the
/// error/empty states.
fn load(state: &Rc<State>) {
if state.mock.get() {
return; // screenshot scene renders injected entries only
}
state.stack.set_visible_child_name("loading");
let port = mgmt_port(state);
let addr = state.req.addr.clone();
let identity = state.app.identity.clone();
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
let (tx, rx) = async_channel::bounded(1);
std::thread::Builder::new()
.name("punktfunk-library".into())
.spawn(move || {
let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin));
})
.expect("spawn library thread");
let weak = Rc::downgrade(state);
glib::spawn_future_local(async move {
let Ok(result) = rx.recv().await else { return };
let Some(state) = weak.upgrade() else { return };
match result {
Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"),
Ok(games) => {
render(&state, &games);
state.stack.set_visible_child_name("grid");
load_art(&state, &games);
}
Err(e) => {
state.error_page.set_description(Some(&e.to_string()));
state.stack.set_visible_child_name("error");
}
}
});
}
/// (Re)build the poster grid from one library snapshot. Cached textures apply
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
fn render(state: &Rc<State>, games: &[GameEntry]) {
state.flow.remove_all();
state.pics.borrow_mut().clear();
for game in games {
state.flow.append(&game_card(state, game));
}
}
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
/// monogram placeholder underneath the async art. Activation starts a session launching
/// this title (silent on a pinned host — the normal trust gate applies).
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
let monogram = gtk::Label::new(Some(&initials(&game.title)));
monogram.add_css_class("pf-poster-monogram");
monogram.set_halign(gtk::Align::Center);
monogram.set_valign(gtk::Align::Center);
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
placeholder.append(&monogram);
monogram.set_vexpand(true);
let pic = gtk::Picture::new();
pic.set_content_fit(gtk::ContentFit::Cover);
if let Some(tex) = state.art.borrow().get(&game.id) {
pic.set_paintable(Some(tex));
}
state.pics.borrow_mut().insert(game.id.clone(), pic.clone());
let badge = gtk::Label::new(Some(store_label(&game.store)));
badge.add_css_class("pf-pill");
badge.add_css_class("pf-store-badge");
badge.set_halign(gtk::Align::Start);
badge.set_valign(gtk::Align::Start);
badge.set_margin_start(6);
badge.set_margin_top(6);
let poster = gtk::Overlay::new();
poster.set_child(Some(&placeholder));
poster.add_overlay(&pic);
poster.add_overlay(&badge);
poster.add_css_class("pf-poster");
poster.set_overflow(gtk::Overflow::Hidden);
poster.set_size_request(150, 225);
poster.set_halign(gtk::Align::Center);
let title = gtk::Label::new(Some(&game.title));
title.add_css_class("caption");
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
title.set_max_width_chars(16);
title.set_tooltip_text(Some(&game.title));
let card = gtk::Box::new(gtk::Orientation::Vertical, 6);
card.append(&poster);
card.append(&title);
let child = gtk::FlowBoxChild::new();
child.set_child(Some(&card));
let app = state.app.clone();
let mut req = state.req.clone();
req.launch = Some((game.id.clone(), game.title.clone()));
child.connect_activate(move |_| crate::ui_trust::initiate_connect(app.clone(), req.clone()));
child
}
/// Fetch poster art for every uncached entry on a small worker pool, walking each
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
/// texturing the first that loads on the main loop.
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
let port = mgmt_port(state);
let base = library::base_url(&state.req.addr, port);
let jobs: VecDeque<(String, Vec<String>)> = {
let cache = state.art.borrow();
games
.iter()
.filter(|g| !cache.contains_key(&g.id))
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
.filter(|(_, candidates)| !candidates.is_empty())
.collect()
};
if jobs.is_empty() {
return;
}
let identity = state.app.identity.clone();
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
let weak = Rc::downgrade(state);
glib::spawn_future_local(async move {
while let Ok((id, bytes)) = rx.recv().await {
let Some(state) = weak.upgrade() else { break };
// Texture decode happens here on the main loop — posters are small (tens of
// KB), and `from_bytes` handles jpeg/png alike.
match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) {
Ok(tex) => {
if let Some(pic) = state.pics.borrow().get(&id) {
pic.set_paintable(Some(&tex));
}
state.art.borrow_mut().insert(id, tex);
}
Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"),
}
}
});
}
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
/// stores per the host's provider list), with the id prefix as a fallback spelling.
/// Shared with the gamepad launcher's posters.
pub 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 tile: the first letters of the first two words.
/// Shared with the gamepad launcher's posters.
pub fn initials(title: &str) -> String {
title
.split_whitespace()
.take(2)
.filter_map(|w| w.chars().next())
.flat_map(char::to_uppercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initials_take_two_words() {
assert_eq!(initials("Dota 2"), "D2");
assert_eq!(initials("half-life"), "H");
assert_eq!(initials("The Witness III"), "TW");
assert_eq!(initials(""), "");
}
}