feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
release / apple (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
release / apple (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
The console UI now runs on tvOS through the NATIVE focus engine: carousel cards and settings rows are focusable Buttons (Siri Remote and pads both navigate; imperative scrollTo replaces the drop-prone scrollPosition binding), while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what focus has no concept of: X/Y screen actions and left/right value adjust with the poll's dominant-axis feel (onMoveCommand proved input-source-dependent: keyboard intercepted, pad dpad not -> double steps). Text entry uses the system fullscreen keyboard (TVTextEntry); pairing + library present as covers under the launcher; the game library defaults ON; settings values slide a quiet 14 pt in the step's direction. Session controls: controller/remote input routes EXCLUSIVELY through GameController during a stream (GCEventViewController, interaction disabled) - a pad's B no longer doubles as a UIKit menu press that ended sessions mid-game. Deliberate exits only: the cross-client escape chord (hold L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream banner (now also on tvOS) teaches both. The Siri Remote's touch surface drives the host pointer - press = left click, Play/Pause = right click, release-tail jumps gated so motion stays truly relative. tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root font shrank the whole platform (29 pt there), plus 10-foot sizing across host cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu" hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops instead of suspending; full-width focusSections make the home actions reachable from any column. The presenter defaults to stage-3 glass pacing on tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind the 50 ms display stage) and is pickable from the gamepad settings; HDR capability advertises from AVPlayer.eligibleForHDRPlayback instead of the current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) — the controller counterpart of
|
||||
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
||||
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
||||
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
||||
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
||||
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
||||
// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike
|
||||
// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
|
||||
isActive: editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
Text("Add Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 440)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
}
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
#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
|
||||
// tray, just a different presentation; done (or Menu, edits-stick) commits and returns.
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { editing != nil },
|
||||
set: { if !$0 { editing = nil } })
|
||||
) {
|
||||
if let field = editing {
|
||||
TVTextEntry(
|
||||
title: fieldTitle(field),
|
||||
text: editingBinding(field).wrappedValue,
|
||||
keyboardType: keyboardType(field)
|
||||
) { value in
|
||||
commitEntry(field, value)
|
||||
editing = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The keyboard tray while editing, the controls legend otherwise.
|
||||
/// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the
|
||||
/// tray — `editing` presents the system keyboard cover instead — so it's legend-only there.)
|
||||
@ViewBuilder private var bottomTray: some View {
|
||||
#if os(tvOS)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#else
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
@@ -111,14 +142,16 @@ struct GamepadAddHostView: View {
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Cancel")
|
||||
}
|
||||
|
||||
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
let m = GamepadFormMetrics.self
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(row.label)
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
Spacer(minLength: 12)
|
||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||
.font(.geistFixed(15, .medium))
|
||||
.font(.geistFixed(m.valueFont, .medium))
|
||||
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||
@@ -162,20 +196,20 @@ struct GamepadAddHostView: View {
|
||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||
Rectangle()
|
||||
.fill(Color.brand)
|
||||
.frame(width: 2, height: 18)
|
||||
.frame(width: 2, height: m.labelFont + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
// MARK: - System keyboard plumbing (see the fullScreenCover on `body`)
|
||||
|
||||
private func fieldTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "name": return "Name (optional)"
|
||||
case "port": return "Port"
|
||||
default: return "Address (IP or hostname)"
|
||||
}
|
||||
}
|
||||
|
||||
/// .URL for the address (dots on the primary layer, no autocapitalize) — the closest tvOS
|
||||
/// keyboard to "hostname or IP".
|
||||
private func keyboardType(_ id: String) -> UIKeyboardType {
|
||||
switch id {
|
||||
case "port": return .numberPad
|
||||
case "address": return .URL
|
||||
default: return .default
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke
|
||||
/// on the other platforms (the system keyboard will type anything).
|
||||
private func commitEntry(_ id: String, _ value: String) {
|
||||
switch id {
|
||||
case "port":
|
||||
editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5))
|
||||
case "address":
|
||||
editingBinding(id).wrappedValue = value
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
default:
|
||||
editingBinding(id).wrappedValue = value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||
// by a controller (iOS/iPadOS/macOS).
|
||||
// by a controller (iOS/iPadOS/macOS) — or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
|
||||
// focusable Button, so the Siri Remote and a game controller both navigate through the system
|
||||
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
|
||||
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
|
||||
// carries ONLY the Y/X actions (library/settings) — buttons the focus engine has no concept of.
|
||||
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
|
||||
//
|
||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||
@@ -24,7 +29,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
#if os(tvOS)
|
||||
/// tvOS: the focus engine is the navigation authority — `cursor`/`scrolledID` chase this,
|
||||
/// never the other way around (mirroring the poll's cursor-first discipline).
|
||||
@FocusState private var focusedID: Item.ID?
|
||||
#endif
|
||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||
/// while the gamepad is driving — that's the whole desync fix.
|
||||
@State private var cursor = 0
|
||||
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { 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)
|
||||
.frame(width: itemWidth)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
|
||||
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
|
||||
// the poll's cursor design exists to avoid — see the header), and on tvOS the
|
||||
// focus engine's own reveal-scrolls are always in flight, so drops were routine
|
||||
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
|
||||
// below instead — scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
|
||||
#if !os(tvOS)
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
#endif
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||
// and paints a scroller across the console strip.
|
||||
.scrollIndicators(.never)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
#if os(tvOS)
|
||||
// Land initial focus on the first card (the launcher's first host / the coverflow's
|
||||
// first title) instead of wherever the engine guesses.
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
|
||||
// controller detent, and an imperative center scroll.
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = newValue
|
||||
withAnimation(.easeOut(duration: scrollAnim)) {
|
||||
proxy.scrollTo(newValue, anchor: .center)
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
// The list changed under a stable focus (discovered hosts prepend tiles): the
|
||||
// content shifted but no focus change fires above — re-center the focused card.
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||
// and paints a scroller across the console strip.
|
||||
.scrollIndicators(.never)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||
// — which never sets `isScrolling` — moves the cursor here.
|
||||
// — which never sets `isScrolling` — moves the cursor here. Not on tvOS: there is no touch
|
||||
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
|
||||
#if !os(tvOS)
|
||||
.onChange(of: scrolledID) { _, newValue in
|
||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
selection = newValue
|
||||
}
|
||||
#endif
|
||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||
// stale in the closures stored on `input`).
|
||||
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
#if os(tvOS)
|
||||
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
|
||||
// working on this screen — and what routes Menu through the system's back semantics).
|
||||
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
#else
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activate(items[cursor])
|
||||
}
|
||||
|
||||
/// Shared confirm tail — the poll activates the cursor's item, a tvOS Button its own.
|
||||
private func activate(_ item: Item) {
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
onActivate(item)
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
scrolledID = id
|
||||
selection = id
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Keep real focus on the reconciled item when its old target vanished from the list —
|
||||
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
|
||||
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
|
||||
focusedID = items[cursor].id
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||||
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||||
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
|
||||
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||
/// in-hand compact-aware sizes elsewhere.
|
||||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
compact ? 20 : 30
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||||
/// sized for the couch on tvOS and for the hand elsewhere.
|
||||
enum GamepadFormMetrics {
|
||||
#if os(tvOS)
|
||||
static let headerFont: CGFloat = 17
|
||||
static let labelFont: CGFloat = 23
|
||||
static let valueFont: CGFloat = 21
|
||||
static let iconFont: CGFloat = 24
|
||||
static let iconWidth: CGFloat = 40
|
||||
static let chevronFont: CGFloat = 16
|
||||
static let rowHPad: CGFloat = 24
|
||||
static let rowVPad: CGFloat = 19
|
||||
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
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
static let valueFont: CGFloat = 15
|
||||
static let iconFont: CGFloat = 17
|
||||
static let iconWidth: CGFloat = 28
|
||||
static let chevronFont: CGFloat = 12
|
||||
static let rowHPad: CGFloat = 16
|
||||
static let rowVPad: CGFloat = 13
|
||||
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
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One glyph + label cell in a hint bar.
|
||||
struct GamepadHint: Identifiable {
|
||||
let glyph: String
|
||||
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
|
||||
struct GamepadHintBar: View {
|
||||
let hints: [GamepadHint]
|
||||
|
||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let glyphFont: CGFloat = 27
|
||||
private static let textFont: CGFloat = 20
|
||||
private static let pad: CGFloat = 18
|
||||
#else
|
||||
private static let glyphFont: CGFloat = 19
|
||||
private static let textFont: CGFloat = 14
|
||||
private static let pad: CGFloat = 13
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(hints) { hint in
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: 19))
|
||||
.font(.system(size: Self.glyphFont))
|
||||
.foregroundStyle(.white)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.padding(13)
|
||||
.padding(Self.pad)
|
||||
.consoleGlass(Capsule())
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
}
|
||||
@@ -297,30 +354,56 @@ struct GamepadFormBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
|
||||
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
|
||||
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
|
||||
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
|
||||
/// interactive-glass feel elsewhere.
|
||||
struct ConsoleBareButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||
/// when the pad or its battery state changes.
|
||||
struct ControllerStatusChip: View {
|
||||
let controller: GamepadManager.DiscoveredController
|
||||
|
||||
// Legible from the couch on tvOS, quiet in hand elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let font: CGFloat = 17
|
||||
private static let hPad: CGFloat = 16
|
||||
private static let vPad: CGFloat = 10
|
||||
#else
|
||||
private static let font: CGFloat = 12
|
||||
private static let hPad: CGFloat = 12
|
||||
private static let vPad: CGFloat = 7
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: controller.hasTouchpadAndMotion
|
||||
? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Self.font))
|
||||
Text(controller.name)
|
||||
.lineLimit(1)
|
||||
if let level = controller.batteryLevel {
|
||||
Image(systemName: batterySymbol(level))
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Self.font))
|
||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||
}
|
||||
}
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.font(.geist(Self.font, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.padding(.horizontal, Self.hPad)
|
||||
.padding(.vertical, Self.vPad)
|
||||
.background(Capsule().fill(.white.opacity(0.08)))
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
||||
// The gamepad-driven home screen: a distinct, "10-foot" console-style host
|
||||
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
||||
@@ -14,11 +14,13 @@
|
||||
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
||||
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
||||
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
||||
// (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
|
||||
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
|
||||
// mirrors X for Settings since the focus engine has no concept of that button.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
@@ -60,8 +62,9 @@ struct GamepadHomeView: View {
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||
/// Settings "Game library" toggle opts out).
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@@ -104,6 +107,12 @@ struct GamepadHomeView: View {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
#if os(tvOS)
|
||||
.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.
|
||||
@@ -128,11 +137,17 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
#if os(tvOS)
|
||||
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
|
||||
let cardWidth = min(560, size.width * 0.34)
|
||||
let cardHeight = min(350, max(240, size.height - 260))
|
||||
#else
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||
#endif
|
||||
VStack(spacing: compact ? 8 : 10) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
@@ -145,7 +160,7 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
@@ -158,6 +173,14 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
30
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||
@@ -165,7 +188,7 @@ struct GamepadHomeView: View {
|
||||
items: tiles,
|
||||
selection: $selection,
|
||||
itemWidth: cardWidth,
|
||||
spacing: 30,
|
||||
spacing: cardSpacing,
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
@@ -272,6 +295,31 @@ private struct GamepadHostTile: View {
|
||||
let tile: HomeTile
|
||||
let size: CGSize
|
||||
|
||||
// 10-foot metrics on tvOS, in-hand metrics elsewhere — one tile, two viewing distances.
|
||||
#if os(tvOS)
|
||||
private static let titleFont: CGFloat = 33
|
||||
private static let subtitleFont: CGFloat = 19
|
||||
private static let statusFont: CGFloat = 15
|
||||
private static let pipSide: CGFloat = 12
|
||||
private static let badgeSide: CGFloat = 70
|
||||
private static let badgeCorner: CGFloat = 19
|
||||
private static let monogramFont: CGFloat = 34
|
||||
private static let iconFont: CGFloat = 32
|
||||
private static let pad: CGFloat = 28
|
||||
private static let corner: CGFloat = 30
|
||||
#else
|
||||
private static let titleFont: CGFloat = 23
|
||||
private static let subtitleFont: CGFloat = 13
|
||||
private static let statusFont: CGFloat = 11
|
||||
private static let pipSide: CGFloat = 9
|
||||
private static let badgeSide: CGFloat = 52
|
||||
private static let badgeCorner: CGFloat = 15
|
||||
private static let monogramFont: CGFloat = 25
|
||||
private static let iconFont: CGFloat = 24
|
||||
private static let pad: CGFloat = 20
|
||||
private static let corner: CGFloat = 26
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
@@ -282,38 +330,38 @@ private struct GamepadHostTile: View {
|
||||
HStack(spacing: 7) {
|
||||
if tile.isPaired {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.system(size: Self.statusFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.title)
|
||||
.font(.geist(23, .bold, relativeTo: .title2))
|
||||
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(20)
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||
@@ -324,7 +372,7 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
@@ -335,15 +383,15 @@ private struct GamepadHostTile: View {
|
||||
ProgressView().tint(.white)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(25, .bold))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||
.overlay {
|
||||
if !tile.filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
|
||||
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
||||
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
||||
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
||||
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
|
||||
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
|
||||
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
|
||||
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
|
||||
// focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||
//
|
||||
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
|
||||
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu — via
|
||||
// onExitCommand — backs out). Left/right value-adjust isn't wired there; select cycles a value
|
||||
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
|
||||
// poll-driven behavior is untouched by the tvOS mode.
|
||||
//
|
||||
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
||||
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||
@@ -16,7 +22,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
#if os(tvOS)
|
||||
/// tvOS: the focus engine is the navigation authority for UP/DOWN — `cursor` chases this, so
|
||||
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
|
||||
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
|
||||
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
|
||||
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware — keyboard arrows were
|
||||
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
|
||||
@FocusState private var focusedID: Item.ID?
|
||||
#endif
|
||||
/// Authoritative focus cursor (index into `items`).
|
||||
@State private var cursor = 0
|
||||
/// A short vertical recoil when a move is refused at a list end.
|
||||
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(spacing: 6) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
#if os(tvOS)
|
||||
// A focusable Button per row: the engine moves between them, select
|
||||
// activates (`tap` keeps the cursor in step before firing). The row's
|
||||
// own `focused` styling is the focus treatment — the bare style adds
|
||||
// no system chrome on top of it.
|
||||
Button { tap(idx) } label: {
|
||||
row(item, focusedID == item.id)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
row(item, idx == cursor && isActive)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(idx) }
|
||||
.id(item.id)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Focus moved (remote swipe / pad dpad) — keep the cursor, the caller's focusID mirror,
|
||||
// and the controller detent in step. Menu = the list's back action (both tvOS callers
|
||||
// pass one; the screen behind would otherwise catch the press and peel too far).
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
|
||||
idx != cursor else { return }
|
||||
cursor = idx
|
||||
focusID = id
|
||||
haptics.move()
|
||||
}
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
.onExitCommand { onBack?() }
|
||||
#endif
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
#if os(tvOS)
|
||||
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) — the
|
||||
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
|
||||
// are exactly the adjust feel the other platforms have, and where the focus engine has
|
||||
// nothing to move to in a vertical list. Vertical poll directions are deliberately
|
||||
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
|
||||
// never reaches this poll — no extended profile — so remote users cycle values with
|
||||
// select instead, which `activate` already does.)
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .left: adjust(by: -1)
|
||||
case .right: adjust(by: 1)
|
||||
case .up, .down: break
|
||||
}
|
||||
}
|
||||
#else
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
#endif
|
||||
}
|
||||
|
||||
private func step(by delta: Int) {
|
||||
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
|
||||
private func adjust(by delta: Int) {
|
||||
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||
if onAdjust(items[cursor], delta) {
|
||||
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
cursor = min(max(cursor, 0), items.count - 1)
|
||||
focusID = items[cursor].id
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Keep real focus on the reconciled row when its old target vanished from the list.
|
||||
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
|
||||
focusedID = items[cursor].id
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
|
||||
@@ -29,8 +29,9 @@ struct HomeView: View {
|
||||
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||
let wake: (StoredHost) -> Void
|
||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// Game-library browser (default ON; the Settings toggle opts out) — the host-card
|
||||
/// "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
|
||||
@@ -48,6 +49,13 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Mirror of the action row's focusSection below: an UPWARD move from
|
||||
// the centered buttons must land back in the grid even when no card
|
||||
// sits in the buttons' columns (a lone top-left card, say). The grid
|
||||
// spans the row, so the section catches every upward ray.
|
||||
#if os(tvOS)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
@@ -67,6 +75,14 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
// focusSection alone is not enough: the engine tests the section's
|
||||
// FRAME, and a content-hugging centered HStack only overlaps the middle
|
||||
// columns — a swipe down from an outer card dead-ends and the actions
|
||||
// are unreachable by remote. Stretching the section across the row means
|
||||
// every column's downward ray hits it.
|
||||
.frame(maxWidth: .infinity)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -198,6 +214,10 @@ struct HomeView: View {
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
// Same reachability contract as the saved grid above — see its focusSection comment.
|
||||
#if os(tvOS)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||
@@ -259,7 +279,9 @@ struct HomeView: View {
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
// Tracks CardMetrics' 10-foot sizes — at the 30pt name a 320pt column truncates
|
||||
// every hostname longer than ~10 characters.
|
||||
[GridItem(.adaptive(minimum: 460), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
|
||||
@@ -22,8 +22,9 @@ private struct CardMetrics {
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
|
||||
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
|
||||
padding: 24, spacing: 22, radius: 18)
|
||||
#else
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
|
||||
@@ -21,9 +21,9 @@ struct LibraryView: View {
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
#if os(iOS) || os(macOS)
|
||||
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
||||
// its existing plain-grid presentation of this same view unchanged.
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
@@ -69,7 +69,6 @@ struct LibraryView: View {
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
#if os(iOS) || os(macOS)
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
@@ -77,9 +76,6 @@ struct LibraryView: View {
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
#else
|
||||
grid
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user