Files
punktfunk/clients/apple/Sources/PunktfunkClient/GamepadHomeView.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

404 lines
16 KiB
Swift

// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and
// when the library flag is on jump into a saved host's library (Y).
//
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
// chrome. 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. The title and the controller-glyph hints are pinned with
// `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's
// vertical budget and the card is sized off the remaining height. tvOS/macOS never mount this view.
import PunktfunkKit
import SwiftUI
#if os(iOS)
import GameController
/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the
/// carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable {
case saved(UUID)
case discovered(String)
}
/// A fully-resolved launcher tile display fields + the activate action, built fresh each render
/// from the live stores so nothing goes stale.
private struct HomeTile: Identifiable {
let id: GamepadHomeTarget
let title: String
let subtitle: String
let isOnline: Bool
let isPaired: Bool
let isConnecting: Bool
/// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline).
let filled: Bool
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
let hasLibrary: Bool
let activate: () -> Void
}
struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@State private var selection: GamepadHomeTarget?
@State private var breathe = false
private var compact: Bool { vSizeClass == .compact }
var body: some View {
GeometryReader { geo in
hero(for: geo.size)
}
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleView
.padding(.top, compact ? 4 : 10)
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
if !tiles.isEmpty {
hintBar
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
}
.background { background }
.onAppear {
discovery.start()
withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true }
}
.onDisappear { discovery.stop() }
.alert(
"Connection failed",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
}
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View {
if tiles.isEmpty {
emptyState.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
let cardWidth = min(340, size.width * 0.84)
// 96 the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
// the strip + detail always fit the region the safe-area insets leave.
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
detailPanel
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Chrome
private var background: some View {
ZStack {
LinearGradient(
colors: [.black, Color.brand.opacity(0.22), .black],
startPoint: .top, endPoint: .bottom)
// A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly.
Circle()
.fill(RadialGradient(
colors: [Color.brand.opacity(0.55), .clear],
center: .center, startRadius: 0, endRadius: 300))
.frame(width: 560, height: 560)
.blur(radius: 70)
.scaleEffect(breathe ? 1.08 : 0.92)
.opacity(breathe ? 0.5 : 0.32)
.offset(y: -20)
}
.ignoresSafeArea()
}
private var titleView: some View {
Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
.foregroundStyle(.white)
}
private var emptyState: some View {
VStack(spacing: 14) {
Image(systemName: "gamecontroller")
.font(.system(size: 46, weight: .light))
.foregroundStyle(Color.brand)
Text("No hosts yet")
.font(.geist(20, .semibold, relativeTo: .title3))
.foregroundStyle(.white)
Text("Add one with touch first — it'll show up here for the controller.")
.font(.geist(15, relativeTo: .body))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
}
}
// MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
GamepadCarousel(
items: tiles,
selection: $selection,
itemWidth: cardWidth,
spacing: 30,
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
// Stop consuming the controller while the library is presented on top otherwise the
// launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
isActive: libraryTarget == nil
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
.frame(height: cardHeight + 40)
}
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
/// brightness/saturation + a depth blur on the recessed neighbors.
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
GamepadHostTile(tile: tile, size: size)
.scrollTransition { content, phase in
let d = CGFloat(min(abs(phase.value), 1))
let scale = 1 - d * 0.12
let bright = Double(-d * 0.24)
let sat = Double(1 - d * 0.42)
let soft = d * 3
let fade = Double(1 - d * 0.22)
return content
.scaleEffect(scale)
.brightness(bright)
.saturation(sat)
.blur(radius: soft)
.opacity(fade)
}
}
/// The "now focused" host, spelled out below the strip empty (not hidden) so the layout
/// doesn't jump as the selection changes.
@ViewBuilder private var detailPanel: some View {
let tile = tiles.first { $0.id == selection }
VStack(spacing: 6) {
Text(tile?.title ?? " ")
.font(.geist(22, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 10) {
Text(tile?.subtitle ?? " ")
.font(.geist(13, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.6))
if let tile {
statusPill(online: tile.isOnline, paired: tile.isPaired)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.animation(.smooth(duration: 0.25), value: selection)
}
private func statusPill(online: Bool, paired: Bool) -> some View {
HStack(spacing: 6) {
Circle()
.fill(online ? Color.green : Color.white.opacity(0.35))
.frame(width: 6, height: 6)
Text(online ? "ONLINE" : "OFFLINE")
if paired { Text("· PAIRED") }
}
.font(.geist(11, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.55))
}
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hintBar: some View {
HStack(spacing: 18) {
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect")
if showsLibraryHint {
hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")
}
}
.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
}
private var showsLibraryHint: Bool {
guard libraryEnabled else { return false }
return tiles.first { $0.id == selection }?.hasLibrary ?? false
}
/// The active controller's real glyph for a button (Xbox "A", 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
}
// MARK: - Data + actions
/// Built fresh each render from the live stores (no stale value capture) saved hosts first,
/// then discovered-but-unsaved ones.
private var tiles: [HomeTile] {
let saved = store.hosts.map { host in
HomeTile(
id: .saved(host.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
isOnline: isOnline(host),
isPaired: host.pinnedSHA256 != nil,
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
activate: { connect(host) })
}
let discovered = discoveredUnsaved.map { d in
HomeTile(
id: .discovered(d.id),
title: d.name,
subtitle: "\(d.host):\(String(d.port))",
isOnline: true,
isPaired: false,
isConnecting: false,
filled: false,
hasLibrary: false,
activate: { connectDiscovered(d) })
}
return saved + discovered
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
}
private func isOnline(_ host: StoredHost) -> Bool {
discovery.hosts.contains { host.matches($0) }
}
private var discoveredUnsaved: [DiscoveredHost] {
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
}
}
/// One "console tile" in the host carousel a dark-glass landscape card, bigger and bolder than the
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
let tile: HomeTile
let size: CGSize
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
monogramBadge
Spacer(minLength: 0)
if tile.isOnline {
Circle()
.fill(Color.green)
.frame(width: 9, height: 9)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.padding(.top, 2)
}
.padding(20)
.frame(width: size.width, height: size.height, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.16)))
if tile.isConnecting {
ProgressView().tint(.white)
} else {
Text(monogram(tile.title))
.font(.geistFixed(25, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand)
}
}
.frame(width: 52, height: 52)
.overlay {
if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
}
}
}
private func monogram(_ name: String) -> String {
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "" }
return String(first).uppercased()
}
}
#endif