Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift
T
enricobuehler a9aa5357cf
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m8s
ci / web (pull_request) Successful in 3m38s
ci / rust-arm64 (pull_request) Successful in 6m11s
ci / rust (pull_request) Failing after 10m53s
feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:40:58 +02:00

153 lines
6.8 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 {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let imageSession: URLSession?
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)?
#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?
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)
}
.background { 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()
}
@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 // detail line + spacers
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)
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
) { game in
cover(game, width: coverWidth, height: coverHeight)
}
.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) -> some View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.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)
}
}
/// 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 {
Text(game.isCustom ? "CUSTOM" : "STEAM")
.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 onLaunch != nil {
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
}
}
#endif