diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 2b16327b..83357046 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -384,7 +384,13 @@ struct ContentView: View { .frame(minWidth: 940, minHeight: 620) } #else - .fullScreenCover(item: $libraryTarget) { host in + // iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one + // of GamepadHomeView's in-place layers (the console shell — no bottom-up cover), so the + // proxy hides the target from the cover while that mode owns it; every writer (Y on a + // tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a + // controller arriving or leaving mid-browse hands the open library to whichever + // presentation the new mode owns. + .fullScreenCover(item: touchLibraryTarget) { host in NavigationStack { LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) } @@ -401,6 +407,14 @@ struct ContentView: View { Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } }) } + /// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents + /// the library in place (see the cover's comment). + private var touchLibraryTarget: Binding { + Binding( + get: { gamepadUIActive ? nil : libraryTarget }, + set: { libraryTarget = $0 }) + } + private var approvalChoicePresented: Binding { Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } }) } @@ -558,7 +572,8 @@ struct ContentView: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: $libraryTarget, waker: waker, - connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered) + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, + launchTitle: launchTitle) } else { HomeView( store: store, model: model, discovery: discovery, @@ -574,7 +589,8 @@ struct ContentView: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: $libraryTarget, waker: waker, - connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered) + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, + launchTitle: launchTitle) // On tvOS pairing/library normally present from HomeView's navigationDestinations // — which aren't mounted while the gamepad launcher is up. Give the launcher its // own presenters (exactly one of the two homes is mounted at a time, so these can diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift index c3fe8b3e..e06561d7 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -83,6 +83,11 @@ struct ConnectOverlay: View { } } + /// The overlay's text/glyph colour: the palette's ink in the console takeover — over a pale + /// aurora, literal white was the one console surface that stayed white-on-white — and white + /// in the touch modal, whose branch is deliberately forced dark over a black scrim. + private var overlayFG: Color { gamepadUI ? ink.fg : .white } + @ViewBuilder private func content(_ phase: Phase) -> some View { // The takeover carries larger type than the compact modal. let titleSize: CGFloat = gamepadUI ? 24 : 19 @@ -90,21 +95,24 @@ struct ConnectOverlay: View { VStack(spacing: gamepadUI ? 16 : 14) { switch phase { case .connecting(let name): - ProgressView().controlSize(.large).tint(.white) + ProgressView().controlSize(.large).tint(overlayFG) Text("Connecting to \(name)") - .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG) .multilineTextAlignment(.center) Text("Establishing a secure connection…") - .font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6)) + .font(.geist(bodySize, relativeTo: .caption)) + .foregroundStyle(overlayFG.opacity(0.6)) Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6) case .waking(let w) where w.timedOut: Image(systemName: "moon.zzz.fill") - .font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9)) + .font(.system(size: gamepadUI ? 40 : 34)) + .foregroundStyle(overlayFG.opacity(0.9)) Text("\(w.hostName) didn't wake") - .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG) .multilineTextAlignment(.center) Text("It may still be booting, or it's powered off / off this network.") - .font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6)) + .font(.geist(bodySize, relativeTo: .caption)) + .foregroundStyle(overlayFG.opacity(0.6)) .multilineTextAlignment(.center) HStack(spacing: 12) { Button("Cancel") { waker.cancel() }.buttonStyle(.bordered) @@ -112,12 +120,13 @@ struct ConnectOverlay: View { } .padding(.top, 6) case .waking(let w): - ProgressView().controlSize(.large).tint(.white) + ProgressView().controlSize(.large).tint(overlayFG) Text("Waking \(w.hostName)…") - .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG) .multilineTextAlignment(.center) Text("Waiting for it to come online · \(w.seconds)s") - .font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit() + .font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6)) + .monospacedDigit() // A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel". Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() } .buttonStyle(.bordered).padding(.top, 6) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index f5af6b36..300bc243 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -14,7 +14,15 @@ import SwiftUI struct GamepadAddHostView: View { @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss + @Environment(\.gamepadHostedInShell) private var hostedInShell let onAdd: (StoredHost) -> Void + /// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover) + /// falls back to the environment dismiss. Declared AFTER `onAdd` so the existing trailing- + /// closure call sites keep binding to it, not to this. + var close: (() -> Void)? + /// Whether this screen owns the controller — false while the shell is mid-transition or the + /// connect takeover is up (see GamepadSettingsView's twin). + var controllerActive = true #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits. @@ -36,8 +44,8 @@ struct GamepadAddHostView: View { items: rows, focusID: $focusID, onActivate: { activate(id: $0.id) }, - onBack: { dismiss() }, - isActive: editing == nil + onBack: { performClose() }, + isActive: controllerActive && editing == nil ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -45,10 +53,14 @@ struct GamepadAddHostView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: 4) { + VStack(spacing: gamepadHeaderSpacing(compact: compact)) { Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) + .frame(maxWidth: .infinity) + // On the title row itself (not the header block) so it rides the title's own + // top padding — the same anchoring the settings screen's close button uses. + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") @@ -59,9 +71,8 @@ struct GamepadAddHostView: View { } } .padding(.top, gamepadTitleTopPadding(compact: compact)) - .padding(.bottom, compact ? 4 : 8) + .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .frame(maxWidth: .infinity) - .overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) } .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -73,7 +84,10 @@ struct GamepadAddHostView: View { .background { GamepadTrayScrim(edge: .bottom) } } // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. - .background { GamepadFormBackground() } + // Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin). + .background { + if !hostedInShell { GamepadFormBackground() } + } // Publish the palette's ink to this screen (text, glass, accent, scrims) — a // pale palette flips all of them, and no leaf should have to read the setting. .gamepadPaletteInk() @@ -141,15 +155,21 @@ struct GamepadAddHostView: View { #endif } + /// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS, + /// the environment dismiss under a macOS sheet / tvOS cover. + private func performClose() { + if let close { close() } else { dismiss() } + } + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc /// rides the cancel action. private var closeButton: some View { - Button { dismiss() } label: { + Button { performClose() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .glassBackground(Circle(), interactive: true) + .consoleGlassBackground(Circle(), interactive: true) .contentShape(Circle()) } .buttonStyle(.plain) @@ -237,7 +257,7 @@ struct GamepadAddHostView: View { name: name.trimmingCharacters(in: .whitespaces), address: address.trimmingCharacters(in: .whitespaces), port: UInt16(port) ?? 9777)) - dismiss() + performClose() default: openKeyboard(id) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index f0fb8fc4..995abd16 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -23,12 +23,36 @@ func buttonGlyph( /// 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. +/// at all. The other values follow the console shell's rhythm (title top = 18 design units, +/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole +/// header reads pressed against the bezel, which the tab strip's extra band made obvious. func gamepadTitleTopPadding(compact: Bool) -> CGFloat { #if os(macOS) 26 + #elseif os(tvOS) + 24 #else - compact ? 4 : 10 + compact ? 10 : 18 + #endif +} + +/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is +/// one) before the content: the console leaves ~14 units of air under its tab pills, and without +/// it the first row sits shoulder-to-shoulder with the header. +func gamepadTitleBottomPadding(compact: Bool) -> CGFloat { + #if os(tvOS) + 16 + #else + compact ? 8 : 12 + #endif +} + +/// Spacing between a header's stacked elements (title over tab strip / subtitle). +func gamepadHeaderSpacing(compact: Bool) -> CGFloat { + #if os(tvOS) + 13 + #else + compact ? 6 : 10 #endif } @@ -60,6 +84,7 @@ enum GamepadFormMetrics { static let detailFont: CGFloat = 19 static let closeFont: CGFloat = 20 static let closeSide: CGFloat = 48 + static let bandWidth: CGFloat = 380 #else static let headerFont: CGFloat = 12 static let labelFont: CGFloat = 16 @@ -74,6 +99,8 @@ enum GamepadFormMetrics { static let detailFont: CGFloat = 13 static let closeFont: CGFloat = 14 static let closeSide: CGFloat = 34 + /// The option band's (GamepadOptionBand) fixed stage inside a choice row. + static let bandWidth: CGFloat = 240 #endif } @@ -147,8 +174,21 @@ struct GamepadHintBar: View { /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { @Environment(\.gamepadInk) private var ink - /// Quiet the field for a form screen (see the type comment). - var calm = false + /// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora, + /// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE + /// it during a push/pop — the console does the same with its `bg_mix` — and every + /// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where + /// re-built gradient stops do not. + var calmMix: Double + + /// The Bool spelling every non-shell call site uses (see the type comment for `calm`). + init(calm: Bool = false) { + calmMix = calm ? 1 : 0 + } + + init(calmMix: Double) { + self.calmMix = calmMix + } @Environment(\.accessibilityReduceMotion) private var reduceMotion @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" @@ -184,21 +224,22 @@ struct GamepadScreenBackground: View { // ±8° over ~5 min — the whole field very slowly warms and cools. .hueRotation(.degrees(sin(t * 0.021) * 8)) // Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply… - .opacity(calm ? 0.6 : 1) - if calm { - // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the - // ground lands exactly where it was and the bright pools come down to meet it. - Self.color(palette.ground) - .opacity(0.4) - .blendMode(.plusLighter) - } + .opacity(1 - 0.4 * calmMix) + // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the + // ground lands exactly where it was and the bright pools come down to meet it. + // Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing, + // and an always-present layer is what lets the mix animate instead of popping. + Self.color(palette.ground) + .opacity(0.4 * calmMix) + .blendMode(.plusLighter) // Cinematic vignette: the edges settle toward the scrim so the cards sit in the // pooled light. Soft (extends past the frame) so the corners deepen rather than // crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form // screen's rows run out toward the edges, where crushing them just eats the list. EllipticalGradient( - colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)], + colors: [.clear, scrim.opacity(0.42 * strength)], center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15) + .opacity(1 - 0.5 * calmMix) // Legibility grounding for the pinned title (top) and hint pill (bottom). This one // works on the field itself (it's the backdrop's bottom layer — nothing behind it to // blur), so it stays a gradient, just a light one. diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 030ce2dd..370d20d9 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -74,6 +74,9 @@ struct GamepadHomeView: View { @ObservedObject var waker: HostWaker let connect: (StoredHost, ProfileSelection) -> Void let connectDiscovered: (DiscoveredHost) -> Void + /// Launch a library title on a host — the in-place library layer's activate path (iOS; the + /// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves). + let launchTitle: (StoredHost, String) -> Void /// The profile catalog — pinned host+profile combos render as their own tiles here, which is /// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4). @@ -93,29 +96,51 @@ struct GamepadHomeView: View { private let compact = false // no size classes on macOS; the window minimum keeps room #endif @ObservedObject private var gamepads = GamepadManager.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var selection: GamepadHomeTarget? @State private var showSettings = false @State private var showAddHost = false + /// The console's input drop: true for the transition's 0.26 s, during which NO layer polls + /// the controller — a double-tapped A can't push two screens, and the held button that + /// caused the change is long released before the next poller starts (whose own + /// `needsSnapshot` seed swallows it if not). + @State private var transitioning = false + /// Guards the gate's release against an interrupted transition: only the newest hold clears. + @State private var transitionEpoch = 0 var body: some View { - GeometryReader { geo in - hero(for: geo.size) + // The in-place shell (see GamepadShell.swift): the launcher is the base layer, the + // current sub-screen a transparent layer over it, both over ONE persistent backdrop + // that never unmounts — a push slides the screen up out of a fade while the launcher + // recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is + // constantly nil and this ZStack degenerates to the plain launcher, presented over by + // the sheets/covers below exactly as before. + ZStack { + homeLayer + .opacity(covered ? 0 : 1) + .scaleEffect(covered ? GamepadShellMotion.underScale : 1) + // The covers used to swallow touch; the recessed layer must too. + .allowsHitTesting(!covered) + #if os(iOS) + if let screen = topScreen { + screenLayer(screen) + .zIndex(1) + .id(screen.id) + .transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) + } + #endif } - // 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) + // Value-keyed rather than `withAnimation` at the triggers: pushes originate outside + // this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the + // derived id catches every writer. Reduce Motion snaps. + .animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID) + // ONE living field for every layer, still a `.background` (the layout rule in this + // file's header). Its calm is CHASED between the launcher's aurora and the form + // screens' quiet, never crossfaded per screen — the console's `bg_mix`. + .background { + GamepadScreenBackground(calmMix: calmTarget) + .animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget) } - .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { - GamepadHintBar(hints: hints) - // Equal distance from the left and bottom edges — the pill's corner inset was the - // real asymmetry (leading 22 vs bottom 10), not its internal padding. - .padding(.leading, compact ? 12 : 18) - .padding(.bottom, compact ? 12 : 18) - .padding(.top, compact ? 4 : 8) - } - .background { GamepadScreenBackground() } // Publish the palette's ink to this screen (text, glass, accent, scrims) — a // pale palette flips all of them, and no leaf should have to read the setting. .gamepadPaletteInk() @@ -129,6 +154,17 @@ struct GamepadHomeView: View { try? await Task.sleep(for: .seconds(10)) } } + #if os(iOS) + .onChange(of: topScreenID) { _, _ in + transitionEpoch += 1 + let epoch = transitionEpoch + transitioning = true + let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02 + DispatchQueue.main.asyncAfter(deadline: .now() + hold) { + if epoch == transitionEpoch { transitioning = false } + } + } + #endif // The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces // X, and historically tvOS maps a pad's X to this same press — the poll and this command // double-firing just sets the same Bool twice. @@ -136,8 +172,9 @@ struct GamepadHomeView: View { .onPlayPauseCommand { showSettings = true } #endif // 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. + // gate above). macOS has no fullScreenCover — they are generously sized sheets over the + // dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the + // shell's layers above ARE the presentation. #if os(macOS) .sheet(isPresented: $showSettings) { GamepadSettingsView(store: store) @@ -148,7 +185,7 @@ struct GamepadHomeView: View { .frame(width: 660, height: 620) } .frame(minWidth: 640, minHeight: 420) - #else + #elseif os(tvOS) .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) } .fullScreenCover(isPresented: $showAddHost) { GamepadAddHostView { store.add($0) } @@ -156,6 +193,110 @@ struct GamepadHomeView: View { #endif } + // MARK: - The shell's layers (see GamepadShell.swift) + + /// The launcher itself — everything the pre-shell body was, minus the backdrop (hoisted to + /// the shell) and the presentation modifiers (below). + private var homeLayer: 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, gamepadTitleBottomPadding(compact: compact)) + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + GamepadHintBar(hints: hints) + // Equal distance from the left and bottom edges — the pill's corner inset was the + // real asymmetry (leading 22 vs bottom 10), not its internal padding. + .padding(.leading, compact ? 12 : 18) + .padding(.bottom, compact ? 12 : 18) + .padding(.top, compact ? 4 : 8) + } + } + + #if os(iOS) + /// The screen the shell shows over the launcher — derived from the same triggers every + /// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote. + private var topScreen: GamepadScreen? { + if showSettings { return .settings } + if showAddHost { return .addHost } + if let host = libraryTarget { return .library(host) } + return nil + } + + @ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View { + // The layer owns the controller only once the push settles and nothing rides over the + // shell (the connect/wake takeover is an overlay in ContentView, above these layers). + let active = !transitioning && waker.waking == nil && model.phase != .connecting + Group { + switch screen { + case .settings: + GamepadSettingsView( + store: store, + close: { if !transitioning { showSettings = false } }, + controllerActive: active) + case .addHost: + GamepadAddHostView( + onAdd: { store.add($0) }, + close: { if !transitioning { showAddHost = false } }, + controllerActive: active) + case .library(let host): + GamepadLibraryScreen( + store: store, host: host, + onLaunch: { launchTitle(host, $0) }, + close: { if !transitioning { libraryTarget = nil } }, + controllerActive: active) + } + } + .environment(\.gamepadHostedInShell, true) + } + #endif + + private var covered: Bool { + #if os(iOS) + topScreen != nil + #else + false + #endif + } + + private var topScreenID: String? { + #if os(iOS) + topScreen?.id + #else + nil + #endif + } + + /// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The + /// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them + /// keeps its aurora — exactly what shipped. + private var calmTarget: Double { + #if os(iOS) + topScreen?.isForm == true ? 1 : 0 + #else + 0 + #endif + } + + /// Stop consuming the controller while another screen (or the connect/wake takeover) is on + /// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad), + /// and a second A during a dial would launch a concurrent connect. `.connecting` covers the + /// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the + /// transition's input drop, during which NOBODY polls. + private var homeOwnsController: Bool { + #if os(iOS) + topScreen == nil && !transitioning + && waker.waking == nil && model.phase != .connecting + #else + libraryTarget == nil && !showSettings && !showAddHost + && waker.waking == nil && model.phase != .connecting + #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 { @@ -229,12 +370,7 @@ struct GamepadHomeView: View { onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, - // Stop consuming the controller while another screen (or the connect/wake takeover) is on - // top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad), - // and a second A during a dial would launch a concurrent connect. `.connecting` covers the - // takeover's Connecting phase; `waker.waking` covers its Waking phase. - isActive: libraryTarget == nil && !showSettings && !showAddHost - && waker.waking == nil && model.phase != .connecting + isActive: homeOwnsController ) { tile in hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) } @@ -402,10 +538,15 @@ private struct GamepadHostTile: View { .foregroundStyle(ink.fg(0.5)) } if tile.isOnline { + // Status colours stay palette-independent (a pip must not change meaning + // with the wallpaper) — only the glow softens on a pale field, where it + // reads as a smudge at full strength. Circle() - .fill(Color.green) + .fill(GamepadInk.onlineGreen) .frame(width: Self.pipSide, height: Self.pipSide) - .shadow(color: .green.opacity(0.7), radius: 5) + .shadow( + color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7), + radius: 5) } } } @@ -441,7 +582,7 @@ private struct GamepadHostTile: View { startPoint: .top, endPoint: .bottom), style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) } - .shadow(color: .black.opacity(0.45), radius: 20, y: 14) + .shadow(color: ink.shadow(0.45), radius: 20, y: 14) } private var monogramBadge: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift index fbb74b34..852e3a9c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -37,6 +37,12 @@ struct GamepadInk: Equatable, Sendable { func accent(_ alpha: Double) -> Color { accent.opacity(alpha) } /// A wash under text: `alpha` is the dark-field strength, scaled for a pale one. func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) } + /// The glass base at `alpha` — what a surface's material is washed with so it carries the + /// palette's hue (the console fills its panels with exactly this colour). + func glass(_ alpha: Double) -> Color { glass.opacity(alpha) } + /// A drop shadow: always black — a white shadow is not a shadow — but softened on a pale + /// field, where full-strength black under every card reads as a smear rather than depth. + func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) } static func of(_ p: GamepadPalette) -> GamepadInk { let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z) @@ -60,6 +66,10 @@ struct GamepadInk: Equatable, Sendable { /// The shipped dark look — what a preview or a test composition gets. static let dark = GamepadInk.of(GamepadPalette.named("violet")) + + /// The online pip — deliberately NOT palette-derived: a status colour must not change + /// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim). + static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29) } private struct GamepadInkKey: EnvironmentKey { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 88965c6c..66d67ece 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -111,7 +111,9 @@ struct GamepadKeyboard: View { .font(.geist(15, .semibold, relativeTo: .callout)) } } - .foregroundStyle(focused ? Color.black : ink.fg) + // The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it — a dark + // accent palette got black-on-dark with the old literal black. + .foregroundStyle(focused ? ink.onAccent : ink.fg) .frame(maxWidth: .infinity, minHeight: compact ? 34 : 42) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift new file mode 100644 index 00000000..67ba4a44 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -0,0 +1,61 @@ +// The library as one of the gamepad shell's in-place layers (iOS): console chrome — a pinned +// title and a close ✕ styled like the settings screen's — around the shared LibraryView, whose +// gamepad branch renders the coverflow. The cover presentation used to get its title and Close +// from the wrapping NavigationStack's bar; a shell layer has no bar, so this restores both in +// the console's own grammar. Everything data-shaped (the fetch, the loading/error/empty states, +// the image session lifecycle) stays LibraryView's. + +import PunktfunkKit +import SwiftUI +#if os(iOS) + +struct GamepadLibraryScreen: View { + @Environment(\.gamepadInk) private var ink + @ObservedObject var store: HostStore + let host: StoredHost + let onLaunch: (String) -> Void + let close: () -> Void + var controllerActive = true + + /// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen. + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + + var body: some View { + LibraryView( + store: store, host: host, onLaunch: onLaunch, + onClose: close, controllerActive: controllerActive) + .safeAreaInset(edge: .top, spacing: 0) { + Text("\(host.displayName) — Library") + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(ink.fg) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) + .background { GamepadTrayScrim(edge: .top) } + } + .gamepadPaletteInk() + } + + /// Touch/click fallback for closing — the controller path is B (the coverflow's onDismiss), + /// and it also covers the loading/error/empty states, which the coverflow (and its B) never + /// mounts under. A hardware keyboard's Esc rides the cancel action. + private var closeButton: some View { + Button { close() } label: { + Image(systemName: "xmark") + .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) + .foregroundStyle(ink.fg) + .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) + .consoleGlassBackground(Circle(), interactive: true) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Close library") + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift new file mode 100644 index 00000000..e9d025ec --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift @@ -0,0 +1,95 @@ +// The gamepad UI's screen-shell vocabulary (iOS): which screen sits over the launcher, and the +// console push/pop choreography that presents it. On iOS the launcher's sub-screens (settings, +// add-host, library) are NOT system covers — they are transparent layers composited in +// GamepadHomeView's ZStack over ONE persistent living backdrop, exactly the model +// `pf-console-ui`'s shell renders on the desktop clients: a push slides the incoming screen up +// out of a fade while the outgoing one recedes; a pop mirrors it; the field underneath never +// moves and never leaves. A system `fullScreenCover` — an opaque sheet sliding up from the +// bottom edge, mounting its own backdrop — was exactly the wrong grammar for a console. +// (macOS keeps its windowed sheets and tvOS its focus-engine covers; this file's motion +// constants are iOS-only in practice, but compile everywhere for the shared call sites.) + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) || os(tvOS) + +/// The screen the shell currently shows over the launcher. Derived, not stored: the presentation +/// triggers (`showSettings`, `showAddHost`, `libraryTarget`) stay authoritative on every +/// platform — this enum is just their iOS rendering. Depth is ≤ 1 by construction (the settings +/// pin picker is an in-screen layer, and every trigger is only reachable from the launcher), so +/// there is no stack to model. +enum GamepadScreen: Identifiable { + case settings + case addHost + case library(StoredHost) + + var id: String { + switch self { + case .settings: return "settings" + case .addHost: return "addHost" + case .library(let host): return "library-\(host.id.uuidString)" + } + } + + /// The backdrop's calm target while this screen is up: the form screens quiet the field + /// (`Bg::Form` in the console); the library keeps the launcher's full aurora. + var isForm: Bool { + switch self { + case .settings, .addHost: return true + case .library: return false + } + } +} + +/// The console shell's motion constants, mapped to SwiftUI. Source of truth: +/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`). +enum GamepadShellMotion { + /// One transition, both layers — the console's `TRANSITION_S`. + static let duration: TimeInterval = 0.26 + /// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points. + static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration) + /// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same + /// ease-out at 0.30 s lands within a few percent of it and settles together with the screen. + static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30) + /// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone. + static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 } + /// The incoming screen grows from this; the revealed launcher grows back from `underScale`. + static let inScale: CGFloat = 0.985 + static let underScale: CGFloat = 0.96 +} + +extension AnyTransition { + /// The console push/pop for the top layer. Insertion: up out of a fade, growing from 0.985. + /// Removal: down into a fade at full size (the console's pop leaves scale alone). The + /// launcher's recede underneath is NOT a transition — it never unmounts — it is the + /// `covered` opacity/scale in GamepadHomeView, animated in the same transaction. + /// + /// Known deviation from the console: a pop there re-reveals the launcher from α 0.4; a + /// SwiftUI opacity animates from 0. Same duration, same landing — the revealed screen just + /// reads a beat later in the fade, not worth an explicitly-driven progress machine. + static func gamepadScreen(slide: CGFloat) -> AnyTransition { + .asymmetric( + insertion: .opacity + .combined(with: .offset(y: slide)) + .combined(with: .scale(scale: GamepadShellMotion.inScale)), + removal: .opacity.combined(with: .offset(y: slide))) + } +} + +private struct GamepadHostedInShellKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + /// True for a screen mounted as one of the shell's layers: it must NOT mount its own + /// backdrop (the shell's single persistent field is behind everything already — a second + /// one would double the mesh cost and break the "field never moves" illusion). The same + /// screens presented as macOS sheets / tvOS covers read the default `false` and keep + /// mounting their own, exactly as before. + var gamepadHostedInShell: Bool { + get { self[GamepadHostedInShellKey.self] } + set { self[GamepadHostedInShellKey.self] = newValue } + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 5f73b0ef..bff15cce 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -26,6 +26,11 @@ struct LibraryCoverflowView: View { /// 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)? + /// Whether the carousel owns the controller — the in-place shell gates it (mid-transition, + /// and under the connect takeover after A launches a title, where this coverflow used to + /// keep polling underneath). Cover/sheet presentations keep the default. + var controllerActive = true + @Environment(\.gamepadHostedInShell) private var hostedInShell #if os(iOS) /// `.compact` in a landscape phone window — drives a tighter poster so everything still fits. @@ -46,7 +51,11 @@ struct LibraryCoverflowView: View { .padding(.leading, 22) .padding(.vertical, compact ? 6 : 10) } - .background { GamepadScreenBackground() } + // Hosted in the shell, the field is the shell's own persistent aurora (the library is + // an aurora screen — the calm mix simply stays 0, so nothing even chases). + .background { + if !hostedInShell { GamepadScreenBackground() } + } // Publish the palette's ink to this screen (text, glass, accent, scrims) — a // pale palette flips all of them, and no leaf should have to read the setting. .gamepadPaletteInk() @@ -81,7 +90,8 @@ struct LibraryCoverflowView: View { spacing: 34, onActivate: { onLaunch?($0.id) }, onBack: { onDismiss?() }, - shoulderJump: 5 + shoulderJump: 5, + isActive: controllerActive ) { game in cover(game, width: coverWidth, height: coverHeight) } @@ -103,7 +113,7 @@ struct LibraryCoverflowView: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(ink.fg(0.12), lineWidth: 1) } - .shadow(color: .black.opacity(0.5), radius: 16, y: 12) + .shadow(color: ink.shadow(0.5), radius: 16, y: 12) .scrollTransition { content, phase in let v = phase.value let d = CGFloat(min(abs(v), 1)) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 03a2e918..f02b2544 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -12,6 +12,13 @@ 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 + /// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover + /// presentation — falls back to the environment dismiss. + var onClose: (() -> Void)? = nil + /// Whether the gamepad coverflow owns the controller — the shell gates it during a push/pop + /// and while the connect takeover is up. Presentations that cover the launcher keep the + /// default (their being up IS the launcher's gate). + var controllerActive = true @Environment(\.dismiss) private var dismiss @State private var games: [GameEntry] = [] @@ -72,7 +79,8 @@ struct LibraryView: View { if gamepadUIActive { LibraryCoverflowView( games: games, imageSession: imageSession, onLaunch: onLaunch, - onDismiss: { dismiss() }) + onDismiss: { (onClose ?? { dismiss() })() }, + controllerActive: controllerActive) } else { grid } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 7c4a4bba..4017f335 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -243,7 +243,7 @@ private struct ShotGamepadHome: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), waker: waker, - connect: { _, _ in }, connectDiscovered: { _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }) } } @@ -301,7 +301,7 @@ private struct ShotConnect: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), waker: waker, - connect: { _, _ in }, connectDiscovered: { _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in }) } else { ShotHome() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift new file mode 100644 index 00000000..b4397d32 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift @@ -0,0 +1,187 @@ +// The gamepad settings' "select" value as a REAL band: every option sits on a drum rotating +// about a vertical axis — the current one faces you flat, its neighbours curve away with +// perspective, shrinking and fading toward the edges. The old presentation animated a single +// Text keyed by its value (an old-out/new-in crossfade that merely implied motion), which fell +// apart under fast repeated steps: each press restarted the fade. Here the drum's position is +// one continuous value driven by a spring, and SwiftUI's spring retargeting preserves velocity — +// rapid presses accumulate into one accelerating spin instead of five restarted crossfades. +// +// The band is purely presentational: stepping semantics (left/right clamps with a boundary thud, +// A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row +// closures. Font and ink come from the environment — the row applies the same value font/colour +// it always did, and the drum's own opacity ramp multiplies on top. + +import Foundation +import SwiftUI + +#if os(iOS) || os(macOS) || os(tvOS) + +struct GamepadOptionBand: View { + let options: [String] + /// The committed selection — the caller's clamp/wrap already applied. + let selection: Int + let focused: Bool + /// The band's footprint, FIXED by the row: a step must never reflow the row (the old + /// free-width value shifted the chevrons with every label), and the drum needs its stage + /// even when the facing label is short. + let width: CGFloat + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Where the drum rests, in option steps — UNBOUNDED: a forward wrap keeps adding 1, never + /// modded back, so the ring distance below is what brings option 0 around from the right. + /// Rendering only ever reads it modulo `options.count`. + @State private var drumPosition: Double + + init(options: [String], selection: Int, focused: Bool, width: CGFloat) { + self.options = options + self.selection = selection + self.focused = focused + self.width = width + _drumPosition = State(initialValue: Double(selection)) + } + + var body: some View { + Group { + if reduceMotion { + // No drum, no travel: today's quiet crossfade, minus even the 14 pt slip. + ZStack { + Text(current) + .lineLimit(1) + .id(selection) + .transition(.opacity) + } + .animation(.smooth(duration: 0.2), value: selection) + } else { + Drum( + options: options, + rotation: drumPosition, + neighborGate: focused ? 1 : 0, + target: drumPosition, + // Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge. + radius: width * 0.72) + } + } + .frame(width: width) + .clipped() + // Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut. + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.12), + .init(color: .black, location: 0.88), + .init(color: .clear, location: 1), + ], + startPoint: .leading, endPoint: .trailing) + } + .onChange(of: selection) { old, new in step(from: old, to: new) } + // The options list itself can mutate under the drum (a custom resolution appears, a + // controller connects, the buffer options re-derive from a new refresh rate) — the ring + // math is only valid while drumPosition ≡ selection (mod count), so re-seat without a spin. + .onChange(of: options.count) { _, _ in snap() } + // One element to VoiceOver — the neighbour texts are rendering, not content. + .accessibilityElement(children: .ignore) + .accessibilityLabel(current) + } + + private var current: String { + options.indices.contains(selection) ? options[selection] : "" + } + + /// One step spins the drum; anything else (an external write from the touch settings, a + /// re-derived options list) re-seats it — a spin to a value the user didn't step to would + /// read as the UI acting on its own. + private func step(from old: Int, to new: Int) { + let n = options.count + let raw = new - old + let delta: Int? = if n > 1 && old == n - 1 && new == 0 { + 1 // A's wrap from the last option: keep spinning FORWARD, the way the thumb pressed. + } else if abs(raw) == 1 { + raw + } else { + nil + } + guard let delta, !reduceMotion else { return snap() } + withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { + drumPosition += Double(delta) + } + } + + private func snap() { + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { drumPosition = Double(selection) } + } +} + +/// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED +/// rotation every frame of the spring — each option's offset/scale/opacity follows the real arc, +/// and options more than one step away genuinely enter and leave mid-spin. (A plain `.animation` +/// on independent modifiers can't do that: each modifier would lerp its own endpoints and the +/// in-between options would never appear.) +private struct Drum: View, Animatable { + let options: [String] + /// The interpolated drum position, in option steps. + var rotation: Double + /// 1 while the row is focused — the resting drum shows its neighbours only under focus (an + /// unfocused row is one flat Text, visually and costwise what it was before the band). + var neighborGate: Double + /// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The + /// distance between them is "how mid-flight are we" — it keeps the neighbours visible while + /// an unfocused drum finishes settling, fading them continuously as it lands. + let target: Double + /// Drum radius in points (from the band width — see the caller). + let radius: Double + + var animatableData: AnimatablePair { + get { AnimatablePair(rotation, neighborGate) } + set { + rotation = newValue.first + neighborGate = newValue.second + } + } + + /// Angular pitch between adjacent options on the drum. + private static let stepAngle = 34.0 * .pi / 180.0 + + var body: some View { + let n = options.count + let flight = min(1, abs(rotation - target) * 3) + let content = ZStack { + ForEach(0.. some View { + let angle = d * Self.stepAngle + let depth = cos(angle) + // The facing option never gates: an unfocused row still shows its value. + let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) + Text(options[i]) + .lineLimit(1) + .scaleEffect(0.70 + 0.30 * depth) + // Foreshorten the label as it turns away — this is what sells the cylinder. + .rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4) + .offset(x: radius * sin(angle)) + .opacity(alpha) + .zIndex(depth) + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 808ff0c9..6727577e 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -47,10 +47,18 @@ enum GpSettingsTab: String, CaseIterable, Hashable { struct GamepadSettingsView: View { @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss + @Environment(\.gamepadHostedInShell) private var hostedInShell /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen /// itself (ContentView owns the instance). @ObservedObject var store: HostStore + /// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover) + /// falls back to the environment dismiss. See `performClose`. + var close: (() -> Void)? + /// Whether this screen owns the controller. The shell holds it false during a push/pop (the + /// console's input drop) and while the connect takeover is up; a system presentation never + /// needs the gate and keeps the default. + var controllerActive = true @AppStorage(DefaultsKey.streamWidth) private var width = 1920 @AppStorage(DefaultsKey.streamHeight) private var height = 1080 @AppStorage(DefaultsKey.streamHz) private var hz = 60 @@ -127,7 +135,8 @@ struct GamepadSettingsView: View { onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, onBack: { back() }, - onShoulder: { step(tabBy: $0) } + onShoulder: { step(tabBy: $0) }, + isActive: controllerActive ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -135,7 +144,7 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: compact ? 4 : 8) { + VStack(spacing: gamepadHeaderSpacing(compact: compact)) { Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) @@ -146,7 +155,7 @@ struct GamepadSettingsView: View { if pinTarget == nil { tabStrip } } .padding(.top, gamepadTitleTopPadding(compact: compact)) - .padding(.bottom, compact ? 4 : 8) + .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { @@ -168,8 +177,12 @@ struct GamepadSettingsView: View { } // The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real // colour and luminance to lens without the launcher's contrast, and the palette setting - // applies here too, so this screen previews the row you're stepping. - .background { GamepadFormBackground() } + // applies here too, so this screen previews the row you're stepping. Hosted in the + // shell, the field is the SHELL's (one persistent backdrop, calm-chased) — mounting a + // second would double the mesh and snap where the shell crossfades. + .background { + if !hostedInShell { GamepadFormBackground() } + } // Publish the palette's ink to this screen (text, glass, accent, scrims) — a // pale palette flips all of them, and no leaf should have to read the setting. .gamepadPaletteInk() @@ -275,15 +288,21 @@ struct GamepadSettingsView: View { focusID = landing } + /// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS, + /// the environment dismiss under a macOS sheet / tvOS cover. + private func performClose() { + if let close { close() } else { dismiss() } + } + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc /// rides the cancel action. private var closeButton: some View { - Button { dismiss() } label: { + Button { performClose() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .glassBackground(Circle(), interactive: true) + .consoleGlassBackground(Circle(), interactive: true) .contentShape(Circle()) } .buttonStyle(.plain) @@ -338,7 +357,7 @@ struct GamepadSettingsView: View { pinTarget = nil focusID = "profile-\(profile.id)" } else { - dismiss() + performClose() } } @@ -364,24 +383,33 @@ struct GamepadSettingsView: View { .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) - // Keyed by the value so a change slides the new option in instead of - // hard-swapping the string — a QUIET horizontal slip following the user's - // motion (a right-step enters from the right), crossfading over ~14 pt. - // Deliberately not `.push`: that travels the whole container width, loud - // and visibly outside the row. The ZStack is the stable home the - // removed/inserted texts transition within. - let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 - ZStack { - Text(row.value) + if let labels = row.optionLabels, let idx = row.selectedIndex { + // A choice row's value is a REAL band — the options ride a rotating + // drum, so fast repeated steps spin it instead of restarting a fade. + GamepadOptionBand( + options: labels, selection: idx, focused: focused, width: bandWidth) .font(.geist(m.valueFont, .medium, relativeTo: .callout)) .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) - .lineLimit(1) - .id(row.value) - .transition(.asymmetric( - insertion: .offset(x: slide).combined(with: .opacity), - removal: .offset(x: -slide).combined(with: .opacity))) + } else { + // Toggles and the flat rows keep the quiet slip: keyed by the value so + // a change slides the new string in following the user's motion (a + // right-step enters from the right), crossfading over ~14 pt. The + // ZStack is the stable home the removed/inserted texts transition + // within. (A two-position switch on a drum would read as a coin flip — + // both sibling clients keep toggles a different control, too.) + let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 + ZStack { + Text(row.value) + .font(.geist(m.valueFont, .medium, relativeTo: .callout)) + .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) + .lineLimit(1) + .id(row.value) + .transition(.asymmetric( + insertion: .offset(x: slide).combined(with: .opacity), + removal: .offset(x: -slide).combined(with: .opacity))) + } + .animation(.smooth(duration: 0.22), value: row.value) } - .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( @@ -411,6 +439,17 @@ struct GamepadSettingsView: View { rows.first { $0.id == focusID }?.detail ?? " " } + /// The option band's fixed stage. A portrait phone is the one place the full 240 pt starves + /// the row's label (everywhere else the 620 pt row cap leaves room to spare), so it alone + /// narrows the stage. + private var bandWidth: CGFloat { + #if os(iOS) + hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth + #else + GamepadFormMetrics.bandWidth + #endif + } + // MARK: - Row model private struct Row: Identifiable { @@ -423,6 +462,11 @@ struct GamepadSettingsView: View { let value: String /// One-line explanation shown near the hint bar while this row is focused. let detail: String + /// A choice row's full option list (labels only — the tags stay inside the closures) + /// and where its drum currently rests. nil ⇒ the value renders as plain text (toggles, + /// actions, profiles — a two-position switch is not a drum; see GamepadOptionBand). + var optionLabels: [String]? + var selectedIndex: Int? /// Whether left/right means anything here — false hides the value's chevrons (the /// Profiles rows navigate, and the placeholder rows do nothing at all). var adjustable = true @@ -793,6 +837,10 @@ struct GamepadSettingsView: View { id: id, tab: tab, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, + // The band mounts only once the value is a known option — the "—" of an unknown + // current renders flat, and the first step's snap-to-first seats the drum. + optionLabels: index != nil ? options.map(\.label) : nil, + selectedIndex: index, enabled: enabled, adjust: { delta in // Unknown current value: snap to the first option on any step. diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index 6f429e44..f8b64170 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -70,12 +70,17 @@ extension View { // MARK: - Console glass (gamepad host tiles + settings rows) -/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and +/// Liquid Glass tuned for the gamepad UI's "console" surfaces — the host-carousel tiles and /// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately /// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the -/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes -/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces -/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance. +/// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes +/// it flex on press. +/// +/// Every tier is WASHED with the palette's `ink.glass` — the same surface colour the console +/// fills its panels with — so switching the background palette recolours the surfaces, not just +/// the text on them. The wash alphas are tune-on-device values with one fixed direction: the +/// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62 +/// pair), because a thin white wash over a colourful field reads as haze, not as a surface. private struct ConsoleGlass: ViewModifier { let shape: S var tint: Color? @@ -86,16 +91,19 @@ private struct ConsoleGlass: ViewModifier { @Environment(\.gamepadInk) private var ink private var scheme: ColorScheme { ink.isLight ? .light : .dark } + /// The palette wash over the material tiers (the material itself supplies the blur body). + private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) } func body(content: Content) -> some View { #if os(tvOS) // ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these // surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the // Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights - // the 10-foot platform). The tint rides an overlay so the focused row keeps its wash. + // the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost. content.background { shape.fill(.ultraThinMaterial) .environment(\.colorScheme, scheme) + .overlay { shape.fill(materialWash) } .overlay { if let tint { shape.fill(tint) } } @@ -104,7 +112,14 @@ private struct ConsoleGlass: ViewModifier { if #available(iOS 26, macOS 26, *) { content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) } else { - content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) } + content.background { + shape.fill(.ultraThinMaterial) + .environment(\.colorScheme, scheme) + .overlay { shape.fill(materialWash) } + .overlay { + if let tint { shape.fill(tint) } + } + } } #endif } @@ -112,8 +127,13 @@ private struct ConsoleGlass: ViewModifier { #if !os(tvOS) @available(iOS 26, macOS 26, *) private var glass: Glass { - var g: Glass = .regular - if let tint { g = g.tint(tint) } + // Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share + // it: mixed 60 % toward the caller's (the focused row must still read accented on + // every palette) over the palette base. If device QA finds the mixed focus wash too + // weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit. + let wash = ink.glass(ink.isLight ? 0.60 : 0.45) + var g: Glass = .regular.tint( + tint.map { wash.mix(with: $0, by: 0.6) } ?? wash) if interactive { g = g.interactive() } return g } @@ -121,9 +141,51 @@ private struct ConsoleGlass: ViewModifier { } extension View { - /// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial` - /// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule. + /// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial` + /// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's + /// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule. func consoleGlass(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View { modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive)) } } + +// MARK: - Console floating glass (the gamepad screens' close buttons) + +/// `glassBackground` for a floating control INSIDE the gamepad UI (the close ✕): same shape +/// contract, but washed with the palette's ink and frosted to the palette's scheme — plain +/// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink +/// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the +/// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI, +/// where the palette means nothing. +private struct ConsoleGlassBackground: ViewModifier { + let shape: S + var interactive = false + @Environment(\.gamepadInk) private var ink + + private var scheme: ColorScheme { ink.isLight ? .light : .dark } + + func body(content: Content) -> some View { + if #available(iOS 26, macOS 26, tvOS 26, *) { + content + .glassEffect( + (interactive ? Glass.regular.interactive() : .regular) + .tint(ink.glass(ink.isLight ? 0.60 : 0.45)), + in: shape) + .environment(\.colorScheme, scheme) + } else { + content.background { + shape.fill(.regularMaterial) + .environment(\.colorScheme, scheme) + .overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) } + } + } + } +} + +extension View { + /// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story + /// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip. + func consoleGlassBackground(_ shape: S, interactive: Bool = false) -> some View { + modifier(ConsoleGlassBackground(shape: shape, interactive: interactive)) + } +}