feat(apple): gamepad ui
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

This commit is contained in:
2026-07-01 15:14:19 +02:00
parent c8be614d9a
commit ecbbff5544
22 changed files with 1782 additions and 74 deletions
@@ -45,6 +45,16 @@ struct ContentView: View {
#if !os(macOS)
@State private var showSettings = false
#endif
#if os(iOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for
// GamepadHomeView instead of retrofitting HomeView's touch UI see `home` below.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View {
Group {
@@ -114,11 +124,23 @@ struct ContentView: View {
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
// The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page
// card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the
// launcher behind it stops consuming the controller see GamepadHomeView's `isActive`).
// macOS has no `fullScreenCover`, so it keeps the sheet there.
#if os(macOS)
.sheet(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
}
#else
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
}
#endif
#endif
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
@@ -171,6 +193,23 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
#elseif os(iOS)
Group {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
}
}
#else
HomeView(
store: store, model: model, discovery: discovery,
@@ -0,0 +1,266 @@
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
// by a controller (iOS/iPadOS only).
//
// The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
// middle. The CALLER owns each card's look, including its own `.scrollTransition` this component
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
//
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight so
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
//
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
// Both fire on a move, on confirm, and for a non-wrapping list a duller bump plus a short visual
// recoil when a move is refused at either end.
import PunktfunkKit
import SwiftUI
#if os(iOS)
import UIKit
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item]
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
/// It is deliberately not what drives the scroll (see the file header).
@Binding var selection: Item.ID?
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
/// insets center exactly one at a time.
let itemWidth: CGFloat
let spacing: CGFloat
/// A activate the centered item.
let onActivate: (Item) -> Void
/// Y the screen's secondary action (e.g. open a host's library); nil disables it.
var onSecondary: (() -> Void)?
/// B back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
var onBack: (() -> Void)?
/// L1/R1 jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
/// otherwise poll the SAME controller at once driving both. The parent sets this false while
/// something is presented on top so only the front-most carousel consumes the gamepad.
var isActive: Bool = true
@ViewBuilder let card: (Item) -> Card
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0
/// The id the scroll view is aligned to its own two-way `.scrollPosition` state.
@State private var scrolledID: Item.ID?
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
/// drag the cursor backward during a fast held direction.
@State private var lastNav = Date.distantPast
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
/// write that lands mid-animation the scroll view stays stuck on the old item even though the
/// binding updated so we never issue one until the previous animation reports complete, then
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
@State private var isScrolling = false
/// A short horizontal recoil when a move is refused at a list end.
@State private var bumpOffset: CGFloat = 0
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
/// confirm and end-stop events (moves trigger on `cursor`).
@State private var activateTick = 0
@State private var boundaryTick = 0
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
/// (longer than a move animation, so overlapping held-stick moves never let it through).
private let navSettle: TimeInterval = 0.4
var body: some View {
GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(items) { item in
card(item)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
}
}
.frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
.onAppear {
reconcile()
wire()
if isActive { input.start() }
}
.onDisappear {
input.stop()
haptics.stop()
}
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
// carousel stops polling so it can't navigate behind the front-most one.
.onChange(of: isActive) { _, active in
if active {
wire()
input.start()
} else {
input.stop()
haptics.stop()
}
}
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
// programmatic scroll is animating (its own intermediate id write-backs would regress the
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
// which never sets `isScrolling` moves the cursor here.
.onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
selection = newValue
}
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
// current `items` value (a plain array unlike an observed object it would otherwise go
// stale in the closures stored on `input`).
.onChange(of: items.map(\.id)) { _, _ in
reconcile()
wire()
}
}
// MARK: - Input wiring
private func wire() {
input.onMove = { move($0) }
input.onConfirm = { activate() }
input.onSecondary = onSecondary
input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
}
private func move(_ direction: GamepadMenuInput.Direction) {
let forward = direction == .right || direction == .down
step(by: forward ? 1 : -1, clampAtEnds: false)
}
private func shoulder(right: Bool) {
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
}
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
/// already there. The cursor is the authority the scroll view is pointed at it, never read for it.
private func step(by delta: Int, clampAtEnds: Bool) {
guard !items.isEmpty else { return }
var target = cursor + delta
if target < 0 || target >= items.count {
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
target = min(max(target, 0), items.count - 1)
}
guard target != cursor else { return boundaryBump(forward: delta > 0) }
cursor = target
lastNav = Date()
haptics.move()
selection = items[target].id // text/detail updates immediately; the scroll chases
commitScroll()
}
private let scrollAnim: TimeInterval = 0.24
/// A hair past `scrollAnim` long enough that the scroll has actually settled before the next
/// write, short enough to stay responsive.
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
/// re-target the LATEST cursor once it settles coalescing a fast burst (hold OR quick flicks) and
/// always converging on the final item, instead of getting stuck on the old card.
///
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
/// reliable completion against (it can fire early which is exactly what let quick flicks slip a
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
private func commitScroll() {
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
let id = items[cursor].id
guard scrolledID != id else { return }
isScrolling = true
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
MainActor.assumeIsolated {
isScrolling = false
commitScroll() // the cursor may have advanced while this scroll ran chase it
}
}
}
private func activate() {
guard cursor >= 0, cursor < items.count else { return }
activateTick &+= 1
haptics.confirm()
onActivate(items[cursor])
}
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
/// any other re-centers on it.
private func tap(_ item: Item) {
if let idx = index(of: item.id), idx == cursor {
activate()
} else if let idx = index(of: item.id) {
cursor = idx
lastNav = Date()
haptics.move()
selection = item.id
commitScroll()
}
}
// MARK: - Selection housekeeping
private func index(of id: Item.ID?) -> Int? {
guard let id else { return nil }
return items.firstIndex { $0.id == id }
}
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
/// change keep the same focused item when it survives, else clamp the cursor into range.
private func reconcile() {
guard !items.isEmpty else {
cursor = 0
if scrolledID != nil { scrolledID = nil }
if selection != nil { selection = nil }
return
}
if let sid = scrolledID, let idx = index(of: sid) {
cursor = idx
if selection != sid { selection = sid }
} else {
let idx = min(max(cursor, 0), items.count - 1)
cursor = idx
let id = items[idx].id
scrolledID = id
selection = id
}
}
private func boundaryBump(forward: Bool) {
boundaryTick &+= 1
haptics.boundary()
let recoil: CGFloat = forward ? -16 : 16
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
}
}
#endif
@@ -0,0 +1,403 @@
// 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
@@ -0,0 +1,171 @@
// 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
@@ -5,6 +5,11 @@
import PunktfunkKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
struct LibraryView: View {
@ObservedObject var store: HostStore
@@ -12,10 +17,25 @@ struct LibraryView: View {
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
var onLaunch: ((String) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var games: [GameEntry] = []
@State private var loading = false
@State private var errorText: String?
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession?
#if os(iOS)
// Gamepad-driven browsing is iOS/iPadOS-only see HomeView's identical gate. tvOS keeps its
// existing plain-grid presentation of this same view unchanged.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View {
content
@@ -29,8 +49,20 @@ struct LibraryView: View {
#else
ToolbarItem(placement: .primaryAction) { reloadButton }
#endif
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
// (ContentView's `.sheet(item: $libraryTarget)`) give it a focusable, dpad-reachable
// Close action. tvOS already has its own pushed-navigation back (Menu button).
#if !os(tvOS)
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
#endif
}
.task { await load() }
.onDisappear {
imageSession?.finishTasksAndInvalidate()
imageSession = nil
}
}
@ViewBuilder private var content: some View {
@@ -42,7 +74,17 @@ struct LibraryView: View {
} else if games.isEmpty {
emptyState
} else {
#if os(iOS)
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
onDismiss: { dismiss() })
} else {
grid
}
#else
grid
#endif
}
}
@@ -51,10 +93,10 @@ struct LibraryView: View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game) }
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game)
GameCard(game: game, imageSession: imageSession)
}
}
}
@@ -125,6 +167,13 @@ struct LibraryView: View {
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
} catch {
games = []
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
@@ -137,23 +186,30 @@ struct LibraryView: View {
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let imageSession: URLSession?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title)
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { storeBadge }
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
}
private var storeBadge: some View {
Text(game.isCustom ? "Custom" : "Steam")
/// 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 {
let isCustom: Bool
var body: some View {
Text(isCustom ? "Custom" : "Steam")
.font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 6)
.padding(.vertical, 3)
@@ -162,31 +218,62 @@ private struct GameCard: View {
}
}
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
private struct PosterImage: View {
#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 {
if index < candidates.count {
AsyncImage(url: candidates[index]) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
// Advance to the next candidate on the next render pass.
Color.clear.onAppear { index += 1 }
case .empty:
ZStack { placeholder; ProgressView() }
@unknown default:
placeholder
}
Group {
if let image {
Image(platformImage: image)
.resizable()
.scaledToFill()
} else if index < candidates.count {
ZStack { placeholder; ProgressView() }
} else {
placeholder
}
.id(index) // recreate AsyncImage so it loads the newly-selected URL
} 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 {
@@ -38,6 +38,7 @@ struct SettingsView: View {
#endif
#if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail).
@@ -738,6 +739,9 @@ struct SettingsView: View {
Text(option.label).tag(option.tag)
}
}
#if os(iOS)
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
#endif
#if DEBUG && !os(tvOS)
Button("Test Controller…") { showControllerTest = true }
.disabled(gamepads.active == nil)
@@ -746,9 +750,17 @@ struct SettingsView: View {
} header: {
Text("Controllers")
} footer: {
Text(Self.controllersFooter)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
// The iOS-only gamepad-UI blurb is appended here, not merged into the shared
// `controllersFooter` constant tvOS's `tvBody` reuses that exact string (line ~348)
// for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter)
#if os(iOS)
Text(Self.gamepadUIFooter)
#endif
}
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@@ -856,6 +868,15 @@ struct SettingsView: View {
+ "from the next session. Two identical controllers may swap a manual selection "
+ "after reconnecting."
#if os(iOS)
private static let gamepadUIFooter =
"When a controller is connected, the host list and game library switch to a "
+ "controller-friendly layout — larger focus targets and a swipeable cover browser "
+ "for the library. Turn this off to always use the touch layout. (The system may "
+ "still move basic focus with a controller connected even with this off — that's "
+ "outside the app's control.)"
#endif
/// "Use controller" choices: Automatic, every forwardable controller, and so a stale
/// pin stays visible instead of leaving the Picker selection tag-less any pinned id
/// that is NOT among the selectable (extended) entries, present-but-unusable included.