Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift
T
enricobuehler 49fa344c92
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m48s
ci / docs-site (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m25s
fix(client/apple): the gamepad UI answers its first field test
Six findings from the on-device pass over #91, all iOS-facing:

- The tray blurs no longer grow into place on a push: the screen layer
  resolves its internal layout (safe-area trays, the scrims' full-bleed)
  in a geometryGroup BEFORE the insertion animates.

- The option band is LINEAR now, not a ring. A ring showed the first
  option waiting to the right of the last one - unreachable, since
  left/right clamps - and on a 2-option ring the unselected item flipped
  sides with every step (the 60/120 Hz row). Positions are fixed, the
  ends are the ends, and A's wrap travels back across the list. Options
  other than the facing one exist only while the drum is moving, so a
  long label never sits under a resting neighbour as overlapping text.

- Toggles (and the pin rows) ride the band too: Off left of On,
  matching the left-off/right-on step semantics.

- The close X is gone from settings, add-host and the library - a
  gamepad UI exits with B. A chromeless cancel button keeps hardware
  Esc and the macOS sheet working, and the library's loading/error/empty
  states gain a zero-size B listener so a controller-only user is never
  trapped where the coverflow (and its B) doesn't exist yet.

- The heading is a real heading: leading-aligned with the 24 pt content
  inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18) - launcher,
  settings, add-host and library alike. The launcher's hidden-mirror
  chip trick died with the centred title that needed it.

Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed.
2026-08-07 14:35:47 +02:00

270 lines
11 KiB
Swift

// Experimental game-library browser (plan step 3, gated behind DefaultsKey.libraryEnabled).
// Renders a poster grid of the host's library fetched over the management API. Read-only:
// launching a chosen title is a later step. Reached from a host card's "Browse Library…"
// context-menu action, which only appears when the feature flag is on.
import PunktfunkKit
import SwiftUI
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` ⇒ browse-only (cards aren't tappable).
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
/// presentation — falls back to the environment dismiss.
var onClose: (() -> Void)? = nil
/// Whether the gamepad coverflow owns the controller — the shell gates it during a push/pop
/// and while the connect takeover is up. Presentations that cover the launcher keep the
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
@State private var games: [GameEntry] = []
@State private var loading = false
@State private var errorText: String?
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession?
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(macOS)
ToolbarItemGroup { reloadButton }
#else
ToolbarItem(placement: .primaryAction) { reloadButton }
#endif
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
// (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable
// Close action. tvOS already has its own pushed-navigation back (Menu button).
#if !os(tvOS)
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
#endif
}
.task { await load() }
.onDisappear {
imageSession?.finishTasksAndInvalidate()
imageSession = nil
}
#if os(iOS) || os(macOS)
// B closes the library even before the coverflow exists (loading / error / empty):
// the coverflow's carousel owns B once games render; until then this zero-size
// listener does — without it a controller-only user is trapped on an error screen
// (the gamepad screens carry no close chrome).
.background {
if gamepadUIActive && games.isEmpty {
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
}
}
#endif
}
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorText, games.isEmpty {
errorState(errorText)
} else if games.isEmpty {
emptyState
} else {
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
onDismiss: { (onClose ?? { dismiss() })() },
controllerActive: controllerActive)
} else {
grid
}
}
}
private var grid: some View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
// renders exactly as it did before.
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
}
.padding()
}
}
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
}
}
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
.tracking(1.1)
.foregroundStyle(.secondary)
}
private var columns: [GridItem] {
#if os(tvOS)
let minW: CGFloat = 220
#else
let minW: CGFloat = 130
#endif
return [GridItem(.adaptive(minimum: minW), spacing: 18)]
}
private func errorState(_ text: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(text)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button("Retry") { Task { await load() } }
.glassProminentButtonStyle()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "square.grid.2x2")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No games found on this host.")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var reloadButton: some View {
Button { Task { await load() } } label: {
Label("Reload", systemImage: "arrow.clockwise")
}
.disabled(loading)
}
private func load() async {
loading = true
errorText = nil
let current = store.hosts.first { $0.id == host.id } ?? host
// mTLS uses this client's persistent identity (the host paired it over QUIC). No identity
// yet → the user hasn't connected/paired, which is also when there's nothing to browse.
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
games = []
errorText = "Connect to this host once first — the library uses the identity created "
+ "on pairing to authenticate."
loading = false
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
games = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256
).launchersFirst
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
} catch {
games = []
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
}
loading = false
}
}
#if os(iOS) || os(macOS)
/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same
/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows
/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up.
private struct LibraryBackCatcher: View {
let active: Bool
let onBack: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onBack = onBack
if active { input.start() }
}
.onChange(of: active) { _, nowActive in
if nowActive { input.start() } else { input.stop() }
}
.onDisappear { input.stop() }
}
}
#endif
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
/// (portrait → header → hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let imageSession: URLSession?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
}