feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
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 29s
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 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
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 29s
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 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
This commit is contained in:
@@ -5,6 +5,11 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct LibraryView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@@ -12,10 +17,25 @@ struct LibraryView: View {
|
||||
/// 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
|
||||
@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)
|
||||
// Gamepad-driven browsing is iOS/iPadOS-only — see HomeView's identical gate. tvOS keeps its
|
||||
// existing plain-grid presentation of this same view unchanged.
|
||||
@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
|
||||
@@ -29,8 +49,20 @@ struct LibraryView: View {
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@@ -42,7 +74,17 @@ struct LibraryView: View {
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
#if os(iOS)
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
onDismiss: { dismiss() })
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
#else
|
||||
grid
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +93,10 @@ struct LibraryView: View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game) }
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game)
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +167,13 @@ struct LibraryView: View {
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
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
|
||||
@@ -137,23 +186,30 @@ struct LibraryView: View {
|
||||
/// (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)
|
||||
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 }
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
@@ -162,31 +218,62 @@ private struct GameCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
|
||||
private struct PosterImage: View {
|
||||
#if canImport(UIKit)
|
||||
private typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
private extension Image {
|
||||
init(platformImage: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: platformImage)
|
||||
#elseif canImport(AppKit)
|
||||
self.init(nsImage: platformImage)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||||
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
|
||||
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
|
||||
}
|
||||
Group {
|
||||
if let image {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
.id(index) // recreate AsyncImage so it loads the newly-selected URL
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.task(id: index) { await loadCurrent() }
|
||||
}
|
||||
|
||||
private func loadCurrent() async {
|
||||
guard index < candidates.count else { return }
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
return
|
||||
}
|
||||
image = loaded
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
|
||||
Reference in New Issue
Block a user