133e25849d
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>
369 lines
16 KiB
Swift
369 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 anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
|
// gamepad settings screen, and the carousel always ends in an Add Host tile that opens the
|
|
// controller-keyboard add flow. (A tap still works as a fallback for all of it.)
|
|
//
|
|
// 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. macOS mounts it too (the
|
|
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
|
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS) || os(macOS)
|
|
import GameController
|
|
|
|
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
|
/// action. Hashable so it can be the carousel's scroll-position identity.
|
|
private enum GamepadHomeTarget: Hashable {
|
|
case saved(UUID)
|
|
case discovered(String)
|
|
case addHost
|
|
}
|
|
|
|
/// 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
|
|
var isOnline = false
|
|
var isPaired = false
|
|
var isConnecting = false
|
|
/// Saved (solid monogram) vs. discovered-but-unsaved / action (tinted outline).
|
|
var filled = false
|
|
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
|
|
var hasLibrary = false
|
|
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
|
var icon: String?
|
|
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
|
var showsStatus = true
|
|
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
|
|
#if os(iOS)
|
|
/// `.compact` in a landscape phone window — drives tighter chrome 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; the window minimum keeps room
|
|
#endif
|
|
@ObservedObject private var gamepads = GamepadManager.shared
|
|
@State private var selection: GamepadHomeTarget?
|
|
@State private var showSettings = false
|
|
@State private var showAddHost = false
|
|
|
|
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) {
|
|
titleBar
|
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
|
.padding(.bottom, compact ? 4 : 8)
|
|
}
|
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
|
GamepadHintBar(hints: hints)
|
|
.padding(.leading, 22)
|
|
.padding(.vertical, compact ? 6 : 10)
|
|
}
|
|
.background { GamepadScreenBackground() }
|
|
.onAppear { discovery.start() }
|
|
.onDisappear { discovery.stop() }
|
|
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
|
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
|
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
|
#if os(macOS)
|
|
.sheet(isPresented: $showSettings) {
|
|
GamepadSettingsView()
|
|
.frame(width: 720, height: 640)
|
|
}
|
|
.sheet(isPresented: $showAddHost) {
|
|
GamepadAddHostView { store.add($0) }
|
|
.frame(width: 660, height: 620)
|
|
}
|
|
.frame(minWidth: 640, minHeight: 420)
|
|
#else
|
|
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
|
.fullScreenCover(isPresented: $showAddHost) {
|
|
GamepadAddHostView { store.add($0) }
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
|
|
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
|
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 titleBar: some View {
|
|
Text("Select a Host")
|
|
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.overlay(alignment: .trailing) {
|
|
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
|
// room; a compact-height phone gives the pixels to the carousel instead.
|
|
if !compact, let active = gamepads.active {
|
|
ControllerStatusChip(controller: active)
|
|
.padding(.trailing, 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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() },
|
|
onTertiary: { showSettings = true },
|
|
// Stop consuming the controller while another screen is presented on top — otherwise
|
|
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
|
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
|
) { 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, tile.showsStatus {
|
|
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 hints: [GamepadHint] {
|
|
let selected = tiles.first { $0.id == selection }
|
|
var hints = [GamepadHint(
|
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
|
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
|
if libraryEnabled, selected?.hasLibrary == true {
|
|
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
|
}
|
|
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
|
return hints
|
|
}
|
|
|
|
// MARK: - Data + actions
|
|
|
|
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
|
|
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
|
|
/// empty and manual entry is always one press away).
|
|
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: discovery.advertises(host),
|
|
isPaired: host.pinnedSHA256 != nil,
|
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
|
filled: true,
|
|
hasLibrary: true,
|
|
activate: { connect(host) })
|
|
}
|
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
|
HomeTile(
|
|
id: .discovered(d.id),
|
|
title: d.name,
|
|
subtitle: "\(d.host):\(String(d.port))",
|
|
isOnline: true,
|
|
activate: { connectDiscovered(d) })
|
|
}
|
|
let add = HomeTile(
|
|
id: .addHost,
|
|
title: "Add Host",
|
|
subtitle: "Register a host by address",
|
|
icon: "plus",
|
|
showsStatus: false,
|
|
activate: { showAddHost = true })
|
|
return saved + discovered + [add]
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// 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 if let icon = tile.icon {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 24, weight: .semibold))
|
|
.foregroundStyle(Color.brand)
|
|
} 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
|