ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m56s
ci / rust (pull_request) Successful in 4m21s
The strip entrance never ran in the library, for two reasons: - The trigger was lost. Flipping the state inside onAppear puts the change in the SAME transaction as the view's insertion, where SwiftUI runs with animations disabled. The launcher got away with it; the library's strip mounts late - only once the fetch lands - and lost every time. The flip now defers one runloop turn, so it is an ordinary animated state change. - The art snapped in behind it. Covers hard-swapped from grey placeholder to image, so even a working entrance was followed by a run of cards popping to artwork after the strip had settled. PosterImage cross-fades now (the touch grid inherits it). And the entrance is 3D: a card starts turned away on the drum, small, low and invisible, then swings flat, grows and rises on an overshooting spring. Cards left of the anchor hinge on their trailing edge and cards right of it on their leading one, so the strip FANS OPEN from the cursor instead of sweeping past it - the same hinge-and-perspective language the coverflow's own recede speaks, so arriving and scrolling read as one object. Reduce Motion still drops every bit of travel.
123 lines
4.8 KiB
Swift
123 lines
4.8 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?
|
|
@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 {
|
|
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 {
|
|
ZStack {
|
|
Rectangle().fill(.quaternary)
|
|
Text(title)
|
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|