Files
punktfunk/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift
T
enricobuehler ecbbff5544
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
feat(apple): gamepad ui
2026-07-01 15:14:19 +02:00

172 lines
7.5 KiB
Swift

// The gamepad-driven presentation of the game library (iOS/iPadOS only 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)
import GameController
import UIKit
struct LibraryCoverflowView: View {
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)?
/// `.compact` in a landscape phone window drives a tighter poster so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@State private var selection: String?
private var compact: Bool { vSizeClass == .compact }
var body: some View {
GeometryReader { geo in
content(for: geo.size)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
hintBar
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
.background {
LinearGradient(
colors: [.black, Color.brand.opacity(0.16), .black],
startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
}
}
@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(.white.opacity(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(.white)
.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(.white.opacity(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 hintBar: some View {
HStack(spacing: 18) {
if onLaunch != nil {
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")
}
hint(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")
}
.font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
}
private func hint(glyph: String, text: String) -> some View {
HStack(spacing: 7) {
Image(systemName: glyph)
.font(.system(size: 19))
.foregroundStyle(.white)
Text(text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
/// The active controller's real glyph for a button (Xbox "B", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
private func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
}
}
#endif