4e00037a89
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
Stream reliability - Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation). Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback. - Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 = identity) and let the layer's contentsGravity scale via the system compositor — the same path stage-1's videoGravity used — instead of scaling in-shader. - Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR actually lands, so a keyframe request swallowed by the throttle can't strand a frozen frame; 100 ms keyframe throttle to match the Android path. - Fix "Publishing changes from within view updates": defer the HostStore writes out of the .onChange(of: model.phase) callback. - Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial queue) to stop the UI-stall warning. Controllers - Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811) so a transient server interruption self-heals instead of cascading; playsHapticsOnly so a controller engine doesn't join the always-active streaming audio session. - Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic. UI / design - Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the license surfaced in Acknowledgements. - Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel with custom-resolution entry; 10-bit HDR toggle in Display. - Industrial host-card redesign (left-aligned, bold, brand monogram tiles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
6.9 KiB
Swift
203 lines
6.9 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
|
|
|
|
@State private var games: [GameEntry] = []
|
|
@State private var loading = false
|
|
@State private var errorText: String?
|
|
|
|
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
|
|
}
|
|
.task { await load() }
|
|
}
|
|
|
|
@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 {
|
|
grid
|
|
}
|
|
}
|
|
|
|
private var grid: some View {
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 18) {
|
|
ForEach(games) { game in
|
|
if let onLaunch {
|
|
Button { onLaunch(game.id) } label: { GameCard(game: game) }
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
GameCard(game: game)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
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 {
|
|
games = try await LibraryClient.fetch(
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
PosterImage(candidates: game.art.posterCandidates, title: game.title)
|
|
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.overlay(alignment: .topLeading) { storeBadge }
|
|
Text(game.title)
|
|
.font(.geist(12, relativeTo: .caption))
|
|
.lineLimit(2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var storeBadge: some View {
|
|
Text(game.isCustom ? "Custom" : "Steam")
|
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
.padding(6)
|
|
}
|
|
}
|
|
|
|
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
|
|
private struct PosterImage: View {
|
|
let candidates: [URL]
|
|
let title: String
|
|
@State private var index = 0
|
|
|
|
var body: some View {
|
|
if index < candidates.count {
|
|
AsyncImage(url: candidates[index]) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image.resizable().scaledToFill()
|
|
case .failure:
|
|
// Advance to the next candidate on the next render pass.
|
|
Color.clear.onAppear { index += 1 }
|
|
case .empty:
|
|
ZStack { placeholder; ProgressView() }
|
|
@unknown default:
|
|
placeholder
|
|
}
|
|
}
|
|
.id(index) // recreate AsyncImage so it loads the newly-selected URL
|
|
} else {
|
|
placeholder
|
|
}
|
|
}
|
|
|
|
private var placeholder: some View {
|
|
ZStack {
|
|
Rectangle().fill(.quaternary)
|
|
Text(title)
|
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|