1b610d6bf5
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s
Plan step 3 — the Apple client surfaces the host's game library, behind a feature flag (`DefaultsKey.libraryEnabled`, default OFF). Browsing only; launching a chosen title is step 4. - PunktfunkKit `LibraryClient`: Codable GameEntry/Artwork/LaunchSpec mirroring crates/punktfunk-host/src/library.rs, and an async fetch of GET /api/v1/library with a bearer token. Typed LibraryError guides setup (the common case is "needs a --mgmt-token"). `Artwork.posterCandidates` = portrait → header → hero. - `LibraryView`: cross-platform poster grid (LazyVGrid, AsyncImage that walks the art candidates past load failures to a text placeholder), a store badge, and an inline Connection form (mgmt port + token) that surfaces when the API is unreachable / 401 / no token set. Read-only. - StoredHost gains `mgmtPort`/`mgmtToken` (the mgmt API is a distinct port from the data plane and needs a token off-loopback). Both OPTIONAL — synthesized Decodable ignores property defaults but treats a missing Optional as nil, so older saved hosts decode unchanged (a defaulted non-optional would wipe the list). HostStore.setMgmt. - Entry point: a flag-gated "Browse Library…" host-card context action → LibraryView (sheet on macOS/iOS, pushed on tvOS), mirroring the pair/speed-test plumbing. Plus a Settings "Experimental" toggle. Can't compile Swift on the Linux dev box; CI (apple.yml: swift build + swift test on the mac mini) verifies the macOS path. Added LibraryClientTests (decode + art order) for `swift test`. iOS/tvOS-only branches mirror existing patterns. Live-verify on the Mac pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
8.8 KiB
Swift
259 lines
8.8 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
|
|
|
|
@State private var games: [GameEntry] = []
|
|
@State private var loading = false
|
|
@State private var errorText: String?
|
|
@State private var showConfig = false
|
|
// Connection form state, seeded from the saved host.
|
|
@State private var portText: String = ""
|
|
@State private var tokenText: String = ""
|
|
|
|
var body: some View {
|
|
content
|
|
.navigationTitle("\(host.displayName) — Library")
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
#if os(macOS)
|
|
ToolbarItemGroup {
|
|
connectionButton
|
|
reloadButton
|
|
}
|
|
#else
|
|
ToolbarItem(placement: .primaryAction) { reloadButton }
|
|
ToolbarItem(placement: .cancellationAction) { connectionButton }
|
|
#endif
|
|
}
|
|
.sheet(isPresented: $showConfig) { connectionSheet }
|
|
.task {
|
|
seedForm()
|
|
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
|
|
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("Connection Settings…") { showConfig = true }
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.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 var connectionButton: some View {
|
|
Button { showConfig = true } label: {
|
|
Label("Connection", systemImage: "network")
|
|
}
|
|
}
|
|
|
|
private var connectionSheet: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
LabeledContent("Host") { Text(host.address) }
|
|
TextField("Management port", text: $portText)
|
|
#if !os(macOS)
|
|
.keyboardType(.numberPad)
|
|
#endif
|
|
TextField("Management token", text: $tokenText)
|
|
.autocorrectionDisabled(true)
|
|
#if !os(macOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
} header: {
|
|
Text("Management API")
|
|
} footer: {
|
|
Text("The host must expose its management API on the LAN: "
|
|
+ "`serve --mgmt-bind 0.0.0.0 --mgmt-token <token>`. The default port "
|
|
+ "is \(punktfunkDefaultMgmtPort). Enter the same token here.")
|
|
}
|
|
}
|
|
.navigationTitle("Library Connection")
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") {
|
|
let port = UInt16(portText) ?? punktfunkDefaultMgmtPort
|
|
store.setMgmt(host.id, port: port, token: tokenText)
|
|
showConfig = false
|
|
Task { await load() }
|
|
}
|
|
}
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { showConfig = false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func seedForm() {
|
|
// Always reflect the latest saved values (the host snapshot may predate a setMgmt).
|
|
let current = store.hosts.first { $0.id == host.id } ?? host
|
|
portText = String(current.effectiveMgmtPort)
|
|
tokenText = current.mgmtToken ?? ""
|
|
}
|
|
|
|
private func load() async {
|
|
loading = true
|
|
errorText = nil
|
|
let current = store.hosts.first { $0.id == host.id } ?? host
|
|
do {
|
|
games = try await LibraryClient.fetch(
|
|
address: current.address,
|
|
port: current.effectiveMgmtPort,
|
|
token: current.mgmtToken)
|
|
} catch {
|
|
games = []
|
|
if let libError = error as? LibraryError {
|
|
errorText = libError.errorDescription
|
|
// Token rejected — drop the user straight into the connection form.
|
|
if case .unauthorized = libError { showConfig = true }
|
|
} else {
|
|
errorText = error.localizedDescription
|
|
}
|
|
// No credential entered yet → also straight to setup.
|
|
if current.mgmtToken == nil { showConfig = true }
|
|
}
|
|
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(.caption)
|
|
.lineLimit(2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var storeBadge: some View {
|
|
Text(game.isCustom ? "Custom" : "Steam")
|
|
.font(.caption2.weight(.semibold))
|
|
.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(.headline)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|