ci / rust-arm64 (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m49s
ci / docs-site (pull_request) Successful in 2m21s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 2m31s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 6m49s
A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the
environment: the modifier covers its descendants, never the body's own `ink.…` references. So
each of these screens read whatever was published above it — and on tvOS, where they are
presented as covers rather than nested in the iOS shell, that is nothing at all. They got the
bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the
real palette, which is why a pale field came out with a white title, white row labels and white
values under correctly-pale glass, with the focus wash still brand violet instead of the
palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment
key over.
Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that
publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was
reading the parent's ink too and bleaching a pale field's scrim toward white.
Three more of the same family, all tvOS-only:
- the pairing cover drew the system's dark chrome straight over the launcher showing through
it (a tvOS cover has no background of its own): the PIN prompt was white on the bright
aurora. It gets the console field and the palette now, in the launcher's branch only — the
touch route to the same sheet still belongs to the system background.
- the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView
from outside its own ink, so the shelf's name stayed white over content that had already gone
dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes').
- the library's loading / error / empty states mounted no backdrop at all; only the coverflow
did. They now take the same field, so the spinner no longer sits on the launcher's own
aurora with the host tiles still visible behind it.
And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is
chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends
of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put
white on light grey. It takes `onAccent` now, like the selected settings tab.
Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings,
add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and
tvOS both build.
227 lines
11 KiB
Swift
227 lines
11 KiB
Swift
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
|
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
|
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
|
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
|
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
|
|
// the shoulders (L1/R1) jump a handful at a time through a long library.
|
|
//
|
|
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
|
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
|
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
|
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
|
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
|
|
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
|
|
// remains, so a tall 2:3 poster + the detail line always fit.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
import GameController
|
|
|
|
struct LibraryCoverflowView: View {
|
|
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
|
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
|
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
|
private var ink: GamepadInk { .stored(paletteID) }
|
|
let games: [GameEntry]
|
|
let artLoader: LibraryArtLoader?
|
|
var onLaunch: ((String) -> Void)?
|
|
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
|
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
|
var onDismiss: (() -> Void)?
|
|
/// Whether the carousel owns the controller — the in-place shell gates it (mid-transition,
|
|
/// and under the connect takeover after A launches a title, where this coverflow used to
|
|
/// keep polling underneath). Cover/sheet presentations keep the default.
|
|
var controllerActive = true
|
|
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
|
|
|
#if os(iOS)
|
|
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
|
|
|
private var compact: Bool { vSizeClass == .compact }
|
|
#else
|
|
private let compact = false // no size classes on macOS
|
|
#endif
|
|
@State private var selection: String?
|
|
/// How many covers have settled (art loaded, or every candidate exhausted).
|
|
@State private var artSettled = 0
|
|
/// The backstop below has fired: play the entrance regardless of what the art is doing.
|
|
@State private var artWaitOver = false
|
|
|
|
/// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and
|
|
/// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for
|
|
/// the first few covers — every poster is fetched in parallel, so those land together and
|
|
/// cover the visible strip. The wait is capped: a slow or artless library still animates.
|
|
private var contentReady: Bool {
|
|
artWaitOver || artSettled >= min(4, games.count)
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
content(for: geo.size)
|
|
}
|
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
|
GamepadHintBar(hints: hints)
|
|
.padding(.leading, 22)
|
|
.padding(.vertical, compact ? 6 : 10)
|
|
}
|
|
// Hosted in the shell, the field is the shell's own persistent aurora (the library is
|
|
// an aurora screen — the calm mix simply stays 0, so nothing even chases).
|
|
.background {
|
|
if !hostedInShell { GamepadScreenBackground() }
|
|
}
|
|
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
|
// pale palette flips all of them, and no leaf should have to read the setting.
|
|
.gamepadPaletteInk()
|
|
// The entrance's backstop (see `contentReady`).
|
|
.task {
|
|
try? await Task.sleep(for: .milliseconds(700))
|
|
artWaitOver = true
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func content(for size: CGSize) -> some View {
|
|
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
|
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
|
// clamped by width on a narrow screen.
|
|
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
|
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
|
let coverWidth = coverHeight * 2 / 3
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 4)
|
|
if showsGroupHeading {
|
|
groupHeading.padding(.bottom, 6)
|
|
}
|
|
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
|
detailPanel
|
|
.padding(.top, 12)
|
|
Spacer(minLength: 4)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
|
|
GamepadCarousel(
|
|
items: games,
|
|
selection: $selection,
|
|
itemWidth: coverWidth,
|
|
spacing: 34,
|
|
onActivate: { onLaunch?($0.id) },
|
|
onBack: { onDismiss?() },
|
|
shoulderJump: 5,
|
|
isActive: controllerActive,
|
|
contentReady: contentReady
|
|
) { game, entrance in
|
|
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
|
|
}
|
|
.frame(height: coverHeight + 44)
|
|
}
|
|
|
|
/// One cover + the coverflow recede. Every continuous visual reads the scroll view's own
|
|
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
|
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
|
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
|
private func cover(
|
|
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
|
|
) -> some View {
|
|
PosterImage(
|
|
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
|
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
|
.frame(width: width, height: height)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(alignment: .topLeading) {
|
|
// `solid`: a frosted chip can't sample a backdrop through this card's own
|
|
// composited transform, so it would only show up on the centred card.
|
|
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
|
}
|
|
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
|
|
// Beneath the scroll transition, never around it — see CardEntrance.
|
|
.modifier(entrance)
|
|
.scrollTransition { content, phase in
|
|
let v = phase.value
|
|
let d = CGFloat(min(abs(v), 1))
|
|
let scale = 1 - d * 0.24
|
|
let rot = v * -38
|
|
let anchor: UnitPoint = v < 0 ? .trailing : .leading
|
|
let bright = Double(-d * 0.22)
|
|
let fade = Double(1 - d * 0.38)
|
|
return content
|
|
.scaleEffect(scale)
|
|
.rotation3DEffect(
|
|
.degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55)
|
|
.brightness(bright)
|
|
.opacity(fade)
|
|
}
|
|
}
|
|
|
|
/// Does this library have both groups? Only then does the heading earn its row — a
|
|
/// launcher-less library gets exactly the layout it had before design D4.
|
|
private var showsGroupHeading: Bool {
|
|
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
|
|
}
|
|
|
|
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
|
|
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
|
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
|
private var groupHeading: some View {
|
|
let selected = games.first { $0.id == selection }
|
|
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
|
.tracking(1.4)
|
|
.foregroundStyle(ink.fg(0.45))
|
|
}
|
|
|
|
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
|
@ViewBuilder private var detailPanel: some View {
|
|
let game = games.first { $0.id == selection }
|
|
VStack(spacing: 6) {
|
|
Text(game?.title ?? " ")
|
|
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
|
.foregroundStyle(ink.fg)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.multilineTextAlignment(.center)
|
|
if let game {
|
|
// main's richer store label, in the palette's ink.
|
|
Text(
|
|
game.isLauncher
|
|
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
|
|
)
|
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
|
.tracking(1.2)
|
|
.foregroundStyle(ink.fg(0.5))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 24)
|
|
.animation(.smooth(duration: 0.25), value: selection)
|
|
}
|
|
|
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
|
|
|
private var hints: [GamepadHint] {
|
|
var hints: [GamepadHint] = []
|
|
if let onLaunch {
|
|
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
|
let opens = games.first { $0.id == selection }?.isLauncher == true
|
|
hints.append(.init(
|
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
|
|
// Reads `selection` when it fires, not when the legend was built (see the
|
|
// launcher's twin) — and does nothing with no title centred, which is exactly
|
|
// what A does.
|
|
action: { if let id = selection { onLaunch(id) } }))
|
|
}
|
|
hints.append(.init(
|
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
|
|
action: { onDismiss?() }))
|
|
return hints
|
|
}
|
|
}
|
|
#endif
|