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
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.
132 lines
5.3 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|