Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift
T
enricobuehler 769a8c049d
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 4m21s
fix(client/apple): the entrance waits for the art, and every card swings
Three defects behind an entrance that read as a card sliding up:

- The centred card never rotated. The stagger fans out from an anchor,
  and the anchor was given side 0 = no rotation - but the anchor IS the
  card the eye is on, so the single most visible card only rose. Side is
  never 0 now; every card turns.

- The swing happened while the card was invisible. Opacity shared the
  transform's spring, so the card spent its whole rotation at near-zero
  alpha and only the last few degrees showed. The fade now runs on its
  own 0.22 s curve (a second .animation governs only the modifiers above
  it) while the transform springs over ~0.6 s. The travel is deeper too
  - 0.74 scale, 64 degrees, 58 pt - and the rotation sign now matches the
  coverflow's own recede, so a card unwinds INTO its resting angle
  instead of swinging against it.

- It fired before the art existed. Cards swung in as grey placeholders
  and filled with artwork afterwards. PosterImage reports when a cover
  settles (art loaded, or candidates exhausted), the coverflow counts
  the first few, and GamepadCarousel holds its entrance on a
  contentReady gate - with a 700 ms backstop so a slow or artless
  library still animates.
2026-08-07 15:16:49 +02:00

132 lines
5.3 KiB
Swift

// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad
// coverflow (LibraryCoverflowView's cover cell).
import PunktfunkKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// 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 {
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
let label: String
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
/// without reading the title.
var isLauncher: Bool = false
/// Fill the chip with a flat wash instead of a frosted material.
///
/// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them
/// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an
/// offscreen composite — so the frost stayed blank on every card and only appeared on the one
/// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat
/// wash has no backdrop to sample: it is simply always there. (Deliberately black, not
/// palette ink: the chip sits on cover art, whose colours the palette has no business
/// fighting.)
var solid: Bool = false
private var fill: AnyShapeStyle {
if isLauncher { return AnyShapeStyle(Color.brand) }
return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial)
}
var body: some View {
Text(label)
.font(.geist(11, .semibold, relativeTo: .caption2))
.foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(fill, in: Capsule())
.padding(6)
}
}
#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?
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
var onLoaded: (() -> Void)?
@State private var index = 0
@State private var image: PlatformImage?
var body: some View {
Group {
if let image {
Image(platformImage: image)
.resizable()
.scaledToFill()
.transition(.opacity)
} else if index < candidates.count {
ZStack { placeholder; ProgressView() }
.transition(.opacity)
} else {
placeholder
.transition(.opacity)
}
}
// Art crosses over its placeholder instead of replacing it between two frames. Cover
// fetches land one by one, so without this a freshly opened library is a run of cards
// visibly snapping from grey to artwork after the strip has already settled.
.animation(.easeOut(duration: 0.3), value: image != nil)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.task(id: index) { await loadCurrent() }
}
private func loadCurrent() async {
// Past the end: the placeholder IS the final look, so this poster has settled.
guard index < candidates.count else {
onLoaded?()
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
onLoaded?()
}
private var placeholder: some View {
ZStack {
Rectangle().fill(.quaternary)
Text(title)
.font(.geist(17, .semibold, relativeTo: .headline))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(8)
}
}
}