diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 300bc243..b6b09ab0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -53,26 +53,24 @@ struct GamepadAddHostView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like every gamepad heading — and no close chrome (B is the exit). 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.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) .foregroundStyle(ink.fg(0.55)) - .multilineTextAlignment(.center) - .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) + .multilineTextAlignment(.leading) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading) } } + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -95,6 +93,18 @@ struct GamepadAddHostView: View { .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Cancel") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif #if os(tvOS) // tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom // tray — the remote and the pad both drive it natively. Same `editing` state as the @@ -161,24 +171,6 @@ struct GamepadAddHostView: View { 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 { performClose() } 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) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Cancel") - } - // MARK: - Rows private struct Row: Identifiable { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 64369f6d..ab4e7d13 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -55,7 +55,14 @@ struct GamepadCarousel: View where Item.ID: Hash /// 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 + /// Whether the cards are worth showing off yet — the entrance holds until this is true. The + /// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything + /// whose cards are ready the moment they mount leaves it alone. + var contentReady: Bool = true + /// Builds one card. The `CardEntrance` handed along is the card's share of the strip's + /// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own + /// `.scrollTransition` — see `CardEntrance` for why that placement is load-bearing. + @ViewBuilder let card: (Item, CardEntrance) -> Card @State private var input = GamepadMenuInput(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared) @@ -83,6 +90,26 @@ struct GamepadCarousel: View where Item.ID: Hash /// confirm and end-stop events (moves trigger on `cursor`). @State private var activateTick = 0 @State private var boundaryTick = 0 + /// The strip's entrance, as ONE timeline: 0 = every card still away, 1 = every card landed + /// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per + /// mount — a strip that re-played its entrance every time a screen popped off the top of it + /// would be noise, and the shell's push/pop carries that motion already. So it plays when a + /// screen is entered: the launcher when the gamepad UI comes up, the coverflow each time the + /// library opens (its layer mounts fresh). + /// + /// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers, + /// because those modifiers wrap the caller's card — INCLUDING its `.scrollTransition` — and a + /// delayed spring flipping while the scroll view was still settling captured the transition's + /// own per-frame phase updates, stranding the centred card in a half-receded state until the + /// next scroll re-drove it. Nothing here wraps the card in an animation at all. + @State private var entranceProgress: Double = 0 + /// Which card the entrance fans out from — the cursor as it stood when the strip was armed, + /// so a restored selection assembles around where the eye already is instead of sweeping in + /// from the left. + @State private var entranceAnchor = 0 + /// The entrance has been scheduled; it plays exactly once per mount. + @State private var entranceArmed = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// 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). @@ -94,24 +121,27 @@ struct GamepadCarousel: View where Item.ID: Hash ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: spacing) { - ForEach(items) { item in + // Enumerated for the entrance stagger only — identity stays `item.id`, + // which is what `.scrollTargetLayout()` and `scrollPosition` key on. + ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in #if os(tvOS) // A focusable Button per card: the focus engine does the navigating // (remote swipes and pad dpad alike), select activates. The bare style // below keeps the tile's own look — the `.scrollTransition` center pop // is the focus treatment, since focus and center track each other. Button { activate(item) } label: { - card(item) + card(item, entrance(idx)) .frame(width: itemWidth) } .buttonStyle(ConsoleBareButtonStyle()) .focused($focusedID, equals: item.id) .id(item.id) #else - card(item) + card(item, entrance(idx)) .frame(width: itemWidth) .contentShape(Rectangle()) .onTapGesture { tap(item) } + .id(item.id) // explicit scroll-target identity for scrollPosition #endif } } @@ -165,7 +195,10 @@ struct GamepadCarousel: View where Item.ID: Hash reconcile() wire() if isActive { input.start() } + armEntrance() } + // The cards became worth showing (the library's covers got their art) — play now. + .onChange(of: contentReady) { _, _ in armEntrance() } .onDisappear { input.stop() haptics.stop() @@ -200,9 +233,55 @@ struct GamepadCarousel: View where Item.ID: Hash .onChange(of: items.map(\.id)) { _, _ in reconcile() wire() + // A strip that mounted empty (its content arrived after) still gets its entrance. + armEntrance() } } + // MARK: - Entrance + + /// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing. + /// + /// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the + /// same transaction as the view's insertion, where SwiftUI runs with animations disabled — so + /// the cards would simply BE there. Note the failure mode is benign either way: progress + /// reaching 1 without animating leaves every card at exact identity, never stranded. + private func armEntrance() { + guard !entranceArmed, contentReady, !items.isEmpty else { return } + entranceArmed = true + // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. + entranceAnchor = cursor + // Not just the next runloop turn (a change made inside `onAppear` lands in the + // insertion's transaction, where animations are disabled) but a couple of frames: the + // GeometryReader's first pass can report no width at all, so the strip has to lay out + // for real and the scroll view has to centre itself on the cursor before this starts. + // Cards are invisible until then (progress 0 ⇒ opacity 0), so the wait never shows. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + // Linear on purpose: the master timeline is a clock, and each card eases its OWN + // slice of it (see `CardEntrance`) — a spring here would warp every card's curve. + withAnimation( + reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total) + ) { + entranceProgress = 1 + } + } + } + + /// The card's share of the strip's entrance: it swings in on the drum, the anchored card + /// landing first and its neighbours fanning outward to either side. + private func entrance(_ idx: Int) -> CardEntrance { + // Capped so a several-hundred-title library never queues a card behind a visibly long + // wait — everything past the cap lands together, well off-screen anyway. + let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07) + return CardEntrance( + progress: entranceProgress, + start: delay / CardEntrance.total, + // Never zero: the anchor is the card the eye is ON, so it must swing like the rest — + // giving it "no rotation" left the one card you actually watch merely sliding up. + side: idx < entranceAnchor ? -1 : 1, + reduceMotion: reduceMotion) + } + // MARK: - Input wiring private func wire() { @@ -346,4 +425,87 @@ struct GamepadCarousel: View where Item.ID: Hash withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 } } } + +/// How a card arrives when its strip does: turned away on the drum, small, low and invisible — +/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to +/// the left of the anchor hinge on their trailing edge and cards to its right on their leading +/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card +/// itself only grows, since it is already facing you. Each card carries its own delay (see +/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a +/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede +/// speaks, so the arrival and the scrolling feel like one object. +/// +/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll +/// transition derives its phase from the geometry of the view it wraps, so an entrance layered +/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from +/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only +/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the +/// transition measures a card that never moves and simply composes its own scale/rotation on top. +/// +/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS +/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain, +/// unstaggered cross-fade. +struct CardEntrance: ViewModifier, Animatable { + /// How long ONE card takes to travel, and the most any card waits before it starts. + static let perCard: Double = 0.6 + static let maxDelay: Double = 0.42 + /// The master timeline the carousel animates 0 → 1. + static var total: Double { perCard + maxDelay } + + /// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this + /// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure + /// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch + /// the caller's `.scrollTransition` mid-scroll and strand it. + var progress: Double + /// Where this card's window opens on that timeline, 0…1. + let start: Double + /// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor), + /// +1 hinged on its leading edge (right of it). Never 0 — every card turns, including the + /// centred one. + let side: Double + let reduceMotion: Bool + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + func body(content: Content) -> some View { + // This card's own 0…1, sliced out of the master clock. + let span = Self.perCard / Self.total + let raw = min(max((progress - start) / span, 0), 1) + // The travel eases out with a whisker of overshoot, so a card settles rather than stops. + let travel = Self.easeOutBack(raw) + // The fade is FAR quicker than the travel — it finishes in the first third of the window. + // Sharing one curve meant the card spent its whole swing at near-zero opacity and only + // the last few degrees ever showed, which is why this read as a small slide. + let fade = Self.easeOut(min(raw / 0.34, 1)) + // Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. The + // sign matches the coverflow's own recede (right of centre turns negative about its + // leading edge), so the arrival deepens the turn the card wears at rest and unwinds into + // it instead of swinging the opposite way. + let away = reduceMotion ? 0 : 1 - travel + return content + .opacity(reduceMotion ? raw : fade) + .scaleEffect(1 - 0.26 * away) + .rotation3DEffect( + .degrees(side * -64 * away), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + perspective: 0.65) + .offset(y: 34 * away) + } + + /// `1 - (1-t)³`, with a small overshoot past 1 before it settles. + private static func easeOutBack(_ t: Double) -> Double { + let c1 = 1.2, c3 = c1 + 1 + let u = t - 1 + return 1 + c3 * u * u * u + c1 * u * u + } + + private static func easeOut(_ t: Double) -> Double { + let u = 1 - t + return 1 - u * u * u + } +} #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 995abd16..b35097c0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -32,7 +32,7 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat { #elseif os(tvOS) 24 #else - compact ? 10 : 18 + compact ? 18 : 28 #endif } @@ -57,12 +57,13 @@ func gamepadHeaderSpacing(compact: Bool) -> CGFloat { } /// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the -/// in-hand compact-aware sizes elsewhere. +/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict +/// on the smaller first cut was "way too small" once the title moved off-centre. func gamepadTitleSize(compact: Bool) -> CGFloat { #if os(tvOS) 44 #else - compact ? 20 : 30 + compact ? 24 : 34 #endif } @@ -82,8 +83,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 18 static let rowMaxWidth: CGFloat = 920 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 @@ -97,8 +96,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 14 static let rowMaxWidth: CGFloat = 620 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 @@ -383,20 +380,39 @@ struct GamepadTrayScrim: View { // to keep the pinned title legible, so it has to frost dark under white ink and // light under dark ink. .environment(\.colorScheme, ink.isLight ? .light : .dark) - // Fade the whole blur out toward the content so it dissolves rather than ending on a line. + // Sink the material's grey luminance lift toward the palette's shade (black on a + // dark field — field ask: the frost read GREY over the aurora). Inside the mask, so + // the tint dissolves with the blur. + .overlay(ink.shade(0.35)) + // Fade the whole blur out toward the content so it dissolves rather than ending on a + // line. The strong region sits deep (0.65) because the first stretch of the gradient + // now runs over the fixed 80 pt outer overhang below. .mask { LinearGradient( stops: [ .init(color: .black, location: 0), - .init(color: .black.opacity(0.9), location: 0.5), + .init(color: .black.opacity(0.92), location: 0.65), .init(color: .clear, location: 1), ], startPoint: fromEdge, endPoint: toContent) } // Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own - // text always sits on the strong part, rows blur out before they reach it. - .padding(edge == .top ? .bottom : .top, -32) - .ignoresSafeArea() + // text always sits on the strong part, rows blur out before they reach it. The bottom + // gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and + // the field verdict on the short reach was rows colliding visibly with the legend. + .padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72) + // Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a + // beat after insertion (outside any geometry group and outside this view's own + // transaction), which is exactly the pop the field kept seeing — vertically first, + // then, once the vertical runway became padding, on the X axis alone (the landscape + // side insets). 80 pt clears every inset on every device; backgrounds never clip, + // so the overhang simply draws. + .padding(edge == .top ? .top : .bottom, -80) + .padding(.horizontal, -80) + // And the shape must NEVER animate: mounted inside a pushed shell layer, any late + // geometry would ride the push's transaction and visibly grow into place. The + // layer's own fade/slide still carries the scrim; only its SHAPE is pinned. + .transaction { $0.animation = nil } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 370d20d9..94d3bab4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -124,6 +124,11 @@ struct GamepadHomeView: View { #if os(iOS) if let screen = topScreen { screenLayer(screen) + // Settle the screen's internal layout before the insertion animates, so + // descendants never lerp from a half-resolved first frame. (Not sufficient + // for the tray blurs on its own — safe-area expansion resolves outside a + // geometry group; GamepadTrayScrim pins its own geometry too.) + .geometryGroup() .zIndex(1) .id(screen.id) .transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) @@ -322,32 +327,27 @@ struct GamepadHomeView: View { // MARK: - Chrome private var titleBar: some View { - // The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone - // it sat directly on top of the centred title ("Select a Host" ran straight into the pad - // name). Laying it out as a row with a hidden mirror on the leading side keeps the title - // optically centred AND clear of the chip at every width; the title shrinks a little - // before it would ever truncate. + // Leading title (a console heading, not a floating label — field ask), chip trailing. + // The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip; + // a leading title needs none of it — the flexible frame keeps the two apart, and the + // title shrinks a little before it would ever truncate. HStack(spacing: 12) { - statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - statusChip(hidden: false) + .frame(maxWidth: .infinity, alignment: .leading) + statusChip } - .padding(.horizontal, 20) + .padding(.horizontal, 24) } /// 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. `hidden` renders the same - /// chip purely as a width reserve. - @ViewBuilder private func statusChip(hidden: Bool) -> some View { + /// compact-height phone gives the pixels to the carousel instead. + @ViewBuilder private var statusChip: some View { if !compact, let active = gamepads.active { ControllerStatusChip(controller: active) - .opacity(hidden ? 0 : 1) - .accessibilityHidden(hidden) } } @@ -371,8 +371,8 @@ struct GamepadHomeView: View { onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, isActive: homeOwnsController - ) { tile in - hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) + ) { tile, entrance in + hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance) } .frame(height: cardHeight + 40) } @@ -381,8 +381,12 @@ struct GamepadHomeView: View { /// 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 { + private func hostCard( + _ tile: HomeTile, size: CGSize, entrance: CardEntrance + ) -> some View { GamepadHostTile(tile: tile, size: size) + // Beneath the scroll transition, never around it — see CardEntrance. + .modifier(entrance) .scrollTransition { content, phase in let d = CGFloat(min(abs(phase.value), 1)) let scale = 1 - d * 0.12 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 67ba4a44..8402e890 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -27,35 +27,29 @@ struct GamepadLibraryScreen: View { store: store, host: host, onLaunch: onLaunch, onClose: close, controllerActive: controllerActive) .safeAreaInset(edge: .top, spacing: 0) { + // Leading, like every gamepad heading — no close chrome, B is the exit (the + // coverflow's, or LibraryView's own back-catcher before the coverflow exists). 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) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .background { GamepadTrayScrim(edge: .top) } } + // A hardware keyboard's Esc still closes, without chrome. + .background { + Button("Close") { close() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } .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/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index bff15cce..509d3fc8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -41,6 +41,18 @@ struct LibraryCoverflowView: View { private let compact = false // no size classes on macOS #endif @State private var selection: String? + /// How many covers have settled (art loaded, or every candidate exhausted). + @State private var artSettled = 0 + /// The backstop below has fired: play the entrance regardless of what the art is doing. + @State private var artWaitOver = false + + /// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and + /// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for + /// the first few covers — every poster is fetched in parallel, so those land together and + /// cover the visible strip. The wait is capped: a slow or artless library still animates. + private var contentReady: Bool { + artWaitOver || artSettled >= min(4, games.count) + } var body: some View { GeometryReader { geo in @@ -59,6 +71,11 @@ struct LibraryCoverflowView: View { // 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() + // The entrance's backstop (see `contentReady`). + .task { + try? await Task.sleep(for: .milliseconds(700)) + artWaitOver = true + } } @ViewBuilder private func content(for size: CGSize) -> some View { @@ -91,9 +108,10 @@ struct LibraryCoverflowView: View { onActivate: { onLaunch?($0.id) }, onBack: { onDismiss?() }, shoulderJump: 5, - isActive: controllerActive - ) { game in - cover(game, width: coverWidth, height: coverHeight) + isActive: controllerActive, + contentReady: contentReady + ) { game, entrance in + cover(game, width: coverWidth, height: coverHeight, entrance: entrance) } .frame(height: coverHeight + 44) } @@ -102,18 +120,26 @@ struct LibraryCoverflowView: View { /// 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) + private func cover( + _ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance + ) -> some View { + PosterImage( + candidates: game.art.posterCandidates, title: game.title, session: imageSession, + onLoaded: { artSettled += 1 }) .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(alignment: .topLeading) { - StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + // `solid`: a frosted chip can't sample a backdrop through this card's own + // composited transform, so it would only show up on the centred card. + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true) } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(ink.fg(0.12), lineWidth: 1) } .shadow(color: ink.shadow(0.5), radius: 16, y: 12) + // Beneath the scroll transition, never around it — see CardEntrance. + .modifier(entrance) .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 f02b2544..13efdf4e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -65,6 +65,17 @@ struct LibraryView: View { imageSession?.finishTasksAndInvalidate() imageSession = nil } + #if os(iOS) || os(macOS) + // B closes the library even before the coverflow exists (loading / error / empty): + // the coverflow's carousel owns B once games render; until then this zero-size + // listener does — without it a controller-only user is trapped on an error screen + // (the gamepad screens carry no close chrome). + .background { + if gamepadUIActive && games.isEmpty { + LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() } + } + } + #endif } @ViewBuilder private var content: some View { @@ -210,6 +221,30 @@ struct LibraryView: View { } } +#if os(iOS) || os(macOS) +/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same +/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows +/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up. +private struct LibraryBackCatcher: View { + let active: Bool + let onBack: () -> Void + @State private var input = GamepadMenuInput(manager: .shared) + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { + input.onBack = onBack + if active { input.start() } + } + .onChange(of: active) { _, nowActive in + if nowActive { input.start() } else { input.stop() } + } + .onDisappear { input.stop() } + } +} +#endif + /// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs /// (portrait → header → hero) and finally a text placeholder. private struct GameCard: View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 79d2219d..86d577ac 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -17,16 +17,29 @@ struct StoreBadge: View { /// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size /// without reading the title. var isLauncher: Bool = false + /// Fill the chip with a flat wash instead of a frosted material. + /// + /// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them + /// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an + /// offscreen composite — so the frost stayed blank on every card and only appeared on the one + /// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat + /// wash has no backdrop to sample: it is simply always there. (Deliberately black, not + /// palette ink: the chip sits on cover art, whose colours the palette has no business + /// fighting.) + var solid: Bool = false + + private var fill: AnyShapeStyle { + if isLauncher { return AnyShapeStyle(Color.brand) } + return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial) + } var body: some View { Text(label) .font(.geist(11, .semibold, relativeTo: .caption2)) - .foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .padding(.horizontal, 6) .padding(.vertical, 3) - .background( - isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial), - in: Capsule()) + .background(fill, in: Capsule()) .padding(6) } } @@ -58,6 +71,10 @@ struct PosterImage: View { let candidates: [URL] let title: String let session: URLSession? + /// Fires once this poster has settled — art loaded, or every candidate exhausted and the + /// placeholder is what it will be. The gamepad coverflow waits on a few of these before + /// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles. + var onLoaded: (() -> Void)? @State private var index = 0 @State private var image: PlatformImage? @@ -67,19 +84,30 @@ struct PosterImage: View { Image(platformImage: image) .resizable() .scaledToFill() + .transition(.opacity) } else if index < candidates.count { ZStack { placeholder; ProgressView() } + .transition(.opacity) } else { placeholder + .transition(.opacity) } } + // Art crosses over its placeholder instead of replacing it between two frames. Cover + // fetches land one by one, so without this a freshly opened library is a run of cards + // visibly snapping from grey to artwork after the strip has already settled. + .animation(.easeOut(duration: 0.3), value: image != nil) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() .task(id: index) { await loadCurrent() } } private func loadCurrent() async { - guard index < candidates.count else { return } + // Past the end: the placeholder IS the final look, so this poster has settled. + guard index < candidates.count else { + onLoaded?() + return + } guard let session, let data = try? await session.data(from: candidates[index]).0, let loaded = PlatformImage(data: data) else { @@ -87,6 +115,7 @@ struct PosterImage: View { return } image = loaded + onLoaded?() } private var placeholder: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 41606725..9e06fdb3 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -224,9 +224,18 @@ struct StreamHUDView: View { /// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped. + /// + /// On iOS it also has to CLEAR THE CORNER. A rounded corner of radius `r` pulls the card's + /// edge inward by `r − √(r² − (r−y)²)` at a distance `y` below the top, so the first and last + /// lines of a padded stack sit inside the arc unless the padding keeps pace with the radius. + /// At `0.45 · r` that intrusion stays well inside the padding across the whole range this + /// card can wear (≈4.6 pt of arc against 12.6 pt of padding at the 28 pt cap), so no line + /// ever runs into the curve. private var cardPadding: CGFloat { #if os(tvOS) return 16 + #elseif os(iOS) + return max(10, cardCornerRadius * 0.45) #else return 10 #endif @@ -246,13 +255,20 @@ struct StreamHUDView: View { #endif } - /// The card's corner radius. On iOS it's concentric with the physical display corner — - /// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the - /// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a - /// flat-cornered device (or a hidden radius) still gets a sensibly rounded card. + /// The card's corner radius. On iOS it aims to be concentric with the physical display + /// corner — `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform + /// right around the corner instead of a small-radius card cutting into the very rounded + /// glass — but that aim is BOUNDED by what a card this small can actually carry. + /// + /// Unbounded, a modern phone (~62 pt of display radius) asked for a 48 pt corner on a card + /// whose lines sit 10 pt from the edge: the arc reaches ~19 pt inward at the first line, so + /// the top and bottom lines rendered INSIDE the curve. Concentricity is only a virtue while + /// the radius is small next to the card; past that it is just a blob eating its own text. + /// 28 pt is the most this card's stack can wear (with `cardPadding` scaling alongside), and + /// devices whose display radius asks for less than that still get a truly concentric corner. private var cardCornerRadius: CGFloat { #if os(iOS) - return max(12, DeviceMetrics.displayCornerRadius - edgeInset) + return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset)) #elseif os(tvOS) return 16 // scales with the roomier padding #else diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift index b4397d32..ad43ad23 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift @@ -1,10 +1,19 @@ -// 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 gamepad settings' "select" value as a REAL band: the options sit side by side on a drum +// segment curving about a vertical axis — the current one faces you flat, and a step rotates the +// next one in with perspective. 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 travel instead of five restarted crossfades. +// +// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first +// option waiting to the right of the last one, which left/right can't reach (adjust clamps) — +// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped +// sides with every step. So positions are fixed: option i sits i steps from the start, the ends +// are the ends, and A's wrap from the last option travels BACK across the list to the first. +// Options other than the facing one exist only while the drum is actually moving — at rest a row +// shows exactly its value (a resting neighbour under a long label rendered as overlapping, +// unreadable text). // // 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 @@ -28,9 +37,8 @@ struct GamepadOptionBand: View { @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`. + /// Where the drum rests, in option steps — always chasing `Double(selection)`; only the + /// spring's interpolation ever puts it between integers. @State private var drumPosition: Double init(options: [String], selection: Int, focused: Bool, width: CGFloat) { @@ -56,7 +64,6 @@ struct GamepadOptionBand: View { 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) @@ -77,8 +84,8 @@ struct GamepadOptionBand: View { } .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. + // controller connects, the buffer options re-derive from a new refresh rate) — re-seat + // without a travel. .onChange(of: options.count) { _, _ in snap() } // One element to VoiceOver — the neighbour texts are rendering, not content. .accessibilityElement(children: .ignore) @@ -89,22 +96,15 @@ struct GamepadOptionBand: View { 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. + /// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs + /// the drum; anything else (an external write from the touch settings, a re-derived options + /// list) re-seats it — a travel 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() } + let wrapped = options.count > 1 && old == options.count - 1 && new == 0 + guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() } withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { - drumPosition += Double(delta) + drumPosition = Double(new) } } @@ -117,51 +117,44 @@ struct GamepadOptionBand: View { /// 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 +/// and options along the travel genuinely enter and leave mid-flight. (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. + /// distance between them is "how mid-flight are we" — the neighbours exist exactly as long + /// as the drum is moving, fading continuously as it lands, so a resting row is one flat + /// Text and a long label never sits under a resting neighbour. 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 - } + var animatableData: Double { + get { rotation } + set { rotation = newValue } } /// 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.. 0.001 && abs(d) <= 2.5) { + option(i, distance: d, gate: flight) } } } #if os(tvOS) - // Flatten the transform stack while spinning — the 10-foot GPU already made these rows - // drop Liquid Glass, and five projected texts per step is the same class of cost. + // Flatten the transform stack while travelling — the 10-foot GPU already made these + // rows drop Liquid Glass, and five projected texts per step is the same class of cost. content.drawingGroup() #else content @@ -171,7 +164,7 @@ private struct Drum: View, Animatable { @ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View { let angle = d * Self.stepAngle let depth = cos(angle) - // The facing option never gates: an unfocused row still shows its value. + // The facing option never gates: a resting row still shows its value. let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) Text(options[i]) .lineLimit(1) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 6727577e..4358cddc 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -144,12 +144,14 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like a console section heading — centred read as a floating label, + // and a gamepad UI needs no close chrome next to it (B is the exit). Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) // The picker is one layer deeper — its rows aren't sections of anything, so the // strip would be a control that does nothing while it's up. if pinTarget == nil { tabStrip } @@ -191,6 +193,18 @@ struct GamepadSettingsView: View { gamepads.startDiscovery() } .onDisappear { gamepads.stopDiscovery() } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Close") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif } /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to @@ -243,10 +257,12 @@ struct GamepadSettingsView: View { .padding(.vertical, 7) .background { // One shared capsule that MOVES between pills, rather than one per pill fading - // in and out — the highlight travels the way the press did. + // in and out — the highlight travels the way the press did. A Liquid Glass + // surface (accent-tinted through consoleGlass), so the strip wears the same + // material language as the rows it sits above. if selected { - Capsule() - .fill(ink.accent(0.85)) + Color.clear + .consoleGlass(Capsule(), tint: ink.accent(0.85)) .matchedGeometryEffect(id: "tab", in: tabHighlight) } } @@ -294,24 +310,6 @@ struct GamepadSettingsView: View { 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 { performClose() } 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) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Close settings") - } - /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which /// layer the row list currently is. private var title: String { @@ -391,12 +389,10 @@ struct GamepadSettingsView: View { .font(.geist(m.valueFont, .medium, relativeTo: .callout)) .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) } 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.) + // The flat rows (profile pin counts, placeholders) keep the quiet slip: + // keyed by the value so a change slides the new string in following the + // user's motion, crossfading over ~14 pt. The ZStack is the stable home + // the removed/inserted texts transition within. let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 ZStack { Text(row.value) @@ -771,6 +767,8 @@ struct GamepadSettingsView: View { value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " + "connects with it.", + optionLabels: ["Off", "Pinned"], + selectedIndex: pinned ? 1 : 0, adjust: { delta in let target = delta > 0 guard pinned != target else { return false } @@ -868,6 +866,10 @@ struct GamepadSettingsView: View { id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + // Toggles ride the band too (field ask): Off sits left of On, matching the + // directional semantics below, so a right-step slides On in from the right. + optionLabels: ["Off", "On"], + selectedIndex: value.wrappedValue ? 1 : 0, enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary.