ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m48s
ci / rust (pull_request) Canceled after 6m16s
Two more from the on-glass pass: - The coverflow's store/source chip only showed its background on the centred cover. Same mechanism as the tray blur: a card rides a scrollTransition that composites it with opacity < 1 and a 3D rotation, and a material cannot sample a backdrop through an offscreen composite - so the frost stayed blank everywhere except the one card sitting at exactly full opacity. The coverflow's chip is a flat wash now (StoreBadge gains `solid`), which has no backdrop to sample and is therefore simply always there. The touch grid keeps its material - its cards carry no transform, so its frost samples fine. - Host cards and library covers now arrive with the strip instead of being there: each card rises out of a fade on a lightly overshooting spring, delayed by its distance from the cursor, so the strip assembles outward from where the eye already is. Implemented once in GamepadCarousel, so the launcher and the coverflow inherit it together. Transforms only - snapping, the callers' own scrollTransition and the tvOS focus engine are untouched - and Reduce Motion drops the travel for a plain unstaggered cross-fade.
116 lines
4.4 KiB
Swift
116 lines
4.4 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()
|
|
} else if index < candidates.count {
|
|
ZStack { placeholder; ProgressView() }
|
|
} 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 {
|
|
ZStack {
|
|
Rectangle().fill(.quaternary)
|
|
Text(title)
|
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|