Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift
T
enricobuehler 133e25849d feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit:
Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split
along the same seams.

The gamepad mode is couch-complete, and now on macOS too (the living-room
Mac case), not just iOS/iPadOS:

- GamepadSettingsView: a console-style, fully controller-navigable settings
  screen (X from the launcher) — up/down moves focus, left/right steps values
  (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a
  one-line description. Backed by GamepadMenuList, the vertical sibling of
  GamepadCarousel, and SettingsOptions — the option lists hoisted out of
  SettingsView statics and shared by the touch, tvOS and gamepad settings.
- GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad
  — field rows open an on-screen controller keyboard (dpad grid, A types,
  X backspaces, B done); the launcher carousel ends in an Add Host tile, so
  the dead-end "add one with touch first" empty state is gone.
- Launcher polish: contextual hint bar with the pad's real button glyphs,
  controller name + battery chip, one shared console chrome.
- GamepadScreenBackground: an animated aurora (TimelineView-driven drifting
  blobs in the brand's violet family, breathing radii, slow hue shift,
  legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a
  .metal library only bundles reliably in one of the two build systems (SPM vs
  the xcodeproj's synced folders) these sources compile under.
- macOS port: settings/add-host/library present as sized sheets (a macOS sheet
  takes its content's IDEAL size, and the GeometryReader-driven screens
  collapsed to nothing), NSScreen-based mode lists, scroll indicators .never
  (the "always show scroll bars" setting overrides .hidden), tray scrims so
  scrolled rows dim under the pinned title/hints, extra title clearance, and a
  PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/
  library render-verified live on a real Mac + LAN hosts.
- GamepadMenuInput: X button support, and (re)start now snapshots held buttons
  so a controller handoff press never fires twice (the B that closed the
  keyboard no longer also cancels the screen underneath).
- Cleanups: one "Connection failed" alert in ContentView instead of one per
  home screen; HostDiscovery.advertises/unsaved shared by both home screens.
- host: can_encode_444 stub for the non-Linux/Windows host build (the macOS
  synthetic-source loopback used by the Swift tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:24:44 +02:00

233 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView,
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// controller-glyph hint bar, and the connected-controller status chip. One look across every
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
@MainActor
func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
}
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
/// at all, so the iOS value hugs the top edge there.
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#if os(macOS)
26
#else
compact ? 4 : 10
#endif
}
/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
let glyph: String
let text: String
var id: String { glyph + text }
}
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
struct GamepadHintBar: View {
let hints: [GamepadHint]
var body: some View {
HStack(spacing: 18) {
ForEach(hints) { hint in
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: 19))
.foregroundStyle(.white)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
}
}
/// The console backdrop: a living "aurora" field in the brand's violet family soft color blobs
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
/// background but calmer (long 3090 s periods, muted opacities, a legibility scrim on top, so it
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
/// two radial gradients driven by a TimelineView give the same look with none of that risk.
///
/// Applied via `.background { }` NOT as a ZStack sibling so the `.ignoresSafeArea()` here
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
/// speeds (rad/s periods of 3090 s), and a radius that slowly breathes.
private struct Blob {
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
let phase: (x: Double, y: Double)
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
let radius: CGFloat
let breathe: (amount: CGFloat, speed: Double)
let opacity: Double
}
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue related hues so the
/// field shifts within one temperature instead of strobing through the rainbow.
private static let blobs: [Blob] = [
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
speed: (0.111, 0.083), phase: (0.0, 1.9),
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
speed: (0.071, 0.096), phase: (2.4, 0.7),
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
speed: (0.089, 0.067), phase: (4.1, 3.2),
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
speed: (0.059, 0.104), phase: (1.2, 5.0),
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
]
var body: some View {
Group {
if reduceMotion {
field(at: 0)
} else {
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
// of a battery-fed couch device vs. the default display rate.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
field(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.ignoresSafeArea()
}
private func field(at t: TimeInterval) -> some View {
GeometryReader { geo in
let side = max(geo.size.width, geo.size.height)
ZStack {
Color.black
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
}
}
// ±10° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 10))
// Composite the additive blobs offscreen once instead of per-layer.
.drawingGroup()
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
// near-black, whatever the blobs are doing behind them.
LinearGradient(
stops: [
.init(color: .black.opacity(0.55), location: 0),
.init(color: .black.opacity(0.15), location: 0.35),
.init(color: .black.opacity(0.20), location: 0.65),
.init(color: .black.opacity(0.60), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
}
}
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
return Circle()
.fill(RadialGradient(
colors: [blob.color, blob.color.opacity(0)],
center: .center, startRadius: 0, endRadius: r / 2))
.frame(width: r, height: r)
.position(x: x * size.width, y: y * size.height)
.opacity(blob.opacity)
.blendMode(.plusLighter)
}
}
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
var body: some View {
LinearGradient(
stops: [
.init(color: .black.opacity(0.92), location: 0),
.init(color: .black.opacity(0.85), location: 0.55),
.init(color: .black.opacity(0), location: 1),
],
startPoint: edge == .top ? .top : .bottom,
endPoint: edge == .top ? .bottom : .top)
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the near-opaque part, rows dim before they reach it.
.padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea()
}
}
/// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
let controller: GamepadManager.DiscoveredController
var body: some View {
HStack(spacing: 7) {
Image(systemName: controller.hasTouchpadAndMotion
? "playstation.logo" : "gamecontroller.fill")
.font(.system(size: 12))
Text(controller.name)
.lineLimit(1)
if let level = controller.batteryLevel {
Image(systemName: batterySymbol(level))
.font(.system(size: 12))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
}
}
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
private func batterySymbol(_ level: Float) -> String {
if controller.isCharging { return "battery.100.bolt" }
switch level {
case ..<0.125: return "battery.0"
case ..<0.375: return "battery.25"
case ..<0.625: return "battery.50"
case ..<0.875: return "battery.75"
default: return "battery.100"
}
}
}
#endif