The host tile gets a menu, and the start-of-stream banner becomes an About tab #209
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "about-icon@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "about-icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -83,14 +83,6 @@ struct ContentView: View {
|
||||
/// never covers the video.
|
||||
@State private var isFullscreen = false
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||
@State private var showShortcutHint = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
||||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
||||
@@ -347,9 +339,6 @@ struct ContentView: View {
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#endif
|
||||
#if os(iOS)
|
||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
||||
#endif
|
||||
@@ -826,6 +815,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -845,6 +835,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
@@ -1069,31 +1060,15 @@ struct ContentView: View {
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// The start-of-stream shortcut banner used to sit here (macOS/tvOS): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// every session. It is now a page you can OPEN — About ▸ Shortcuts, on
|
||||
// both the touch and the controller surface (ShortcutsCatalog) — because
|
||||
// a message that shows once, over the stream you have just connected to,
|
||||
// is unavailable at the moment the question is actually asked. It also
|
||||
// put a composited overlay above the stream for those 6 seconds, which on
|
||||
// this path costs a refresh of display latency (see the iOS exit disc's
|
||||
// note below); the reference page costs nothing during a session.
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
@@ -1160,23 +1135,10 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
// The two `shortcutHintText` strings that used to live here — one per platform, told once per
|
||||
// session by the banner above — are now `ShortcutsCatalog.groups`, which both About pages
|
||||
// render. The mic line is still conditional there for the same reason it was here: teaching a
|
||||
// shortcut for a microphone that isn't on would be a lie.
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
|
||||
@@ -28,6 +28,18 @@ struct GamepadAddHostView: View {
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadSettingsView's twin).
|
||||
var controllerActive = true
|
||||
/// Non-nil ⇒ this screen is EDITING that saved host rather than registering a new one: the
|
||||
/// fields start on its values and `onAdd` receives it back with only name/address/port
|
||||
/// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A
|
||||
/// re-typed address is the whole point of the screen (a host that moved), so nothing here
|
||||
/// re-derives identity from it — that is the trust store's job, not this form's.
|
||||
///
|
||||
/// Declared after the closures for the same trailing-closure reason as `close`, and it is a
|
||||
/// plain value besides, so it can never capture one.
|
||||
var editingHost: StoredHost?
|
||||
/// One-shot seed guard: `@State` cannot be initialised from a property without a custom init,
|
||||
/// and a custom init would break every existing trailing-closure call site.
|
||||
@State private var seeded = false
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@@ -60,12 +72,15 @@ struct GamepadAddHostView: View {
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Add Host")
|
||||
Text(editingHost == nil ? "Add Host" : "Edit Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
Text(editingHost == nil
|
||||
? "Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else."
|
||||
: "Rename this host, or point it at a new address — its pairing and "
|
||||
+ "pinned cards are kept.")
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -101,6 +116,17 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
// Seed the fields from the host being edited, exactly once: re-seeding on a later appear
|
||||
// (the shell re-mounts a layer when the app returns from the background) would silently
|
||||
// throw away whatever had been typed.
|
||||
.onAppear {
|
||||
guard !seeded else { return }
|
||||
seeded = true
|
||||
guard let host = editingHost else { return }
|
||||
name = host.name
|
||||
address = host.address
|
||||
port = String(host.port)
|
||||
}
|
||||
#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.
|
||||
@@ -205,7 +231,9 @@ struct GamepadAddHostView: View {
|
||||
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
|
||||
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
|
||||
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
|
||||
Row(id: "add", label: "Add Host", isAction: true),
|
||||
Row(
|
||||
id: "add", label: editingHost == nil ? "Add Host" : "Save Changes",
|
||||
isAction: true),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -264,10 +292,21 @@ struct GamepadAddHostView: View {
|
||||
openKeyboard("address")
|
||||
return
|
||||
}
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
let typedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let typedAddress = address.trimmingCharacters(in: .whitespaces)
|
||||
let typedPort = UInt16(port) ?? 9777
|
||||
if var host = editingHost {
|
||||
// Mutate a COPY of the stored record rather than building a fresh one: everything
|
||||
// this form does not show — the pinned fingerprint, WoL MACs, pinned profile
|
||||
// cards, the default binding, `addedAt` — has to survive a rename.
|
||||
host.name = typedName
|
||||
host.address = typedAddress
|
||||
host.port = typedPort
|
||||
onAdd(host)
|
||||
} else {
|
||||
onAdd(StoredHost(
|
||||
name: typedName, address: typedAddress, port: typedPort))
|
||||
}
|
||||
performClose()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
|
||||
@@ -48,6 +48,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var onTertiary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// UP → the focused item's own menu (the launcher's host options). Wiring it takes the whole
|
||||
/// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up
|
||||
/// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical
|
||||
/// travel to spend, and the desktop and Android consoles both read the axis this way — one
|
||||
/// meaning per direction is what makes the gesture learnable across the three of them.
|
||||
/// nil leaves up/down as a second way to step (what every carousel without a menu still does).
|
||||
var onUp: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
@@ -301,6 +308,17 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
// UP is the one direction the poll may also read here, and ONLY to open the menu — it
|
||||
// never calls `step`, so it cannot double-move against the focus engine. Routing it
|
||||
// through `.onMoveCommand` instead was the obvious alternative and the wrong one: that
|
||||
// stream is 4-way and its interception is input-source-dependent on real hardware (see
|
||||
// GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it.
|
||||
// Nothing sits above the strip for the engine to move to, so this direction is free.
|
||||
if let onUp {
|
||||
input.onMove = { direction in
|
||||
if direction == .up { onUp() }
|
||||
}
|
||||
}
|
||||
#else
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
@@ -312,6 +330,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
// With a menu wired, vertical is the menu's axis, not a second scroll axis — see `onUp`.
|
||||
if let onUp {
|
||||
switch direction {
|
||||
case .up: return onUp()
|
||||
case .down: return
|
||||
case .left, .right: break
|
||||
}
|
||||
}
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ struct GamepadHomeView: View {
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (LibraryTarget, String) -> Void
|
||||
/// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) — the host menu's Wake row. The
|
||||
/// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up
|
||||
/// to look at it rather than to stream from it right now.
|
||||
let wakeOnly: (StoredHost) -> Void
|
||||
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||
@@ -122,6 +126,11 @@ struct GamepadHomeView: View {
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
/// The card whose options menu is up (UP on a saved tile) — see GamepadHostOptionsView.
|
||||
@State private var hostOptionsTarget: HostOptionsTarget?
|
||||
/// The host being edited. Set from the options menu, which closes itself as it opens this so
|
||||
/// the two are never stacked — depth stays ≤ 1, which is what `GamepadScreen` assumes.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
|
||||
/// the controller — a double-tapped A can't push two screens, and the held button that
|
||||
/// caused the change is long released before the next poller starts (whose own
|
||||
@@ -204,19 +213,37 @@ struct GamepadHomeView: View {
|
||||
// shell's layers above ARE the presentation.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store)
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
// Shorter than the forms above: a menu is five rows, and a sheet sized for a settings
|
||||
// screen would be mostly empty field under them.
|
||||
.sheet(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
.frame(width: 620, height: 460)
|
||||
}
|
||||
.sheet(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
.fullScreenCover(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
}
|
||||
.fullScreenCover(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -264,6 +291,10 @@ struct GamepadHomeView: View {
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
// Editing leads the menu that raised it: the menu clears itself on the way, so the two are
|
||||
// never both set, and if they somehow were, the screen the user asked for last should win.
|
||||
if let host = editTarget { return .editHost(host) }
|
||||
if let target = hostOptionsTarget { return .hostOptions(target) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let shelf = libraryTarget { return .library(shelf) }
|
||||
@@ -280,12 +311,17 @@ struct GamepadHomeView: View {
|
||||
GamepadSettingsView(
|
||||
store: store,
|
||||
close: { if !transitioning { showSettings = false } },
|
||||
controllerActive: active)
|
||||
controllerActive: active,
|
||||
micAvailable: model.micAvailable)
|
||||
case .addHost:
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .hostOptions(let target):
|
||||
hostOptionsView(target, active: active)
|
||||
case .editHost(let host):
|
||||
editHostView(host, active: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
@@ -417,6 +453,7 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
onUp: { openOptionsForSelected() },
|
||||
isActive: homeOwnsController
|
||||
) { tile, entrance in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
@@ -472,6 +509,14 @@ struct GamepadHomeView: View {
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||
action: { openLibraryForSelected() }))
|
||||
}
|
||||
// Only a saved card has a menu, so the cell appears only where the press does something —
|
||||
// the same honesty rule the Library cell above follows. A direction, not a button, so it
|
||||
// is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust").
|
||||
if case .saved = selected?.id {
|
||||
hints.append(.init(
|
||||
glyph: "arrow.up", text: "Options",
|
||||
action: { openOptionsForSelected() }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||
action: { showSettings = true }))
|
||||
@@ -546,6 +591,60 @@ struct GamepadHomeView: View {
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
|
||||
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
|
||||
/// is what its launches run with.
|
||||
/// The host menu, built once for all three presentations (the iOS shell layer, the macOS
|
||||
/// sheet, the tvOS cover) so the actions can't drift between them.
|
||||
///
|
||||
/// Edit REPLACES this menu rather than stacking on it — `hostOptionsTarget` is cleared as
|
||||
/// `editTarget` is set — which is the desktop console's `Nav::Replace` and what keeps the
|
||||
/// shell's "depth ≤ 1 by construction" claim true.
|
||||
@ViewBuilder
|
||||
private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View {
|
||||
let host = target.host
|
||||
GamepadHostOptionsView(
|
||||
host: host,
|
||||
pinnedProfile: target.profile,
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
onEdit: {
|
||||
guard !transitioning else { return }
|
||||
hostOptionsTarget = nil
|
||||
editTarget = host
|
||||
},
|
||||
onWake: { wakeOnly(host) },
|
||||
onForgetPairing: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onUnpin: {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
|
||||
/// The add-host form in edit mode. `store.update` writes the record back by id, so the
|
||||
/// fingerprint, MACs, pins and binding the form never shows are preserved.
|
||||
@ViewBuilder
|
||||
private func editHostView(_ host: StoredHost, active: Bool) -> some View {
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.update($0) },
|
||||
close: { if !transitioning { editTarget = nil } },
|
||||
controllerActive: active,
|
||||
editingHost: host)
|
||||
}
|
||||
|
||||
/// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but-
|
||||
/// unsaved host is not ours to rename or remove, and the two action tiles have nothing to
|
||||
/// offer — the same `HostOptionsScreen::available` gate the desktop console applies.
|
||||
private func openOptionsForSelected() {
|
||||
guard case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
hostOptionsTarget = HostOptionsTarget(
|
||||
host: host,
|
||||
profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } })
|
||||
}
|
||||
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// A saved host's own actions — Wake, Copy link, Edit…, Forget pairing, Remove — reached with UP on
|
||||
// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host
|
||||
// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen.
|
||||
//
|
||||
// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine
|
||||
// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface
|
||||
// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where
|
||||
// its actions belong.
|
||||
//
|
||||
// UP is the gesture because the carousel is horizontal — left/right are spoken for and up is free —
|
||||
// and because the desktop console and the Android console already do exactly this, so the three are
|
||||
// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and
|
||||
// offering to remove the host from it would blur precisely the distinction a pin exists to draw.
|
||||
//
|
||||
// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console
|
||||
// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls
|
||||
// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two
|
||||
// different actions cannot share a name on the surface that offers both. The touch card's words
|
||||
// win over the other consoles' here — a user meets both Apple surfaces, and only one of them is
|
||||
// cross-platform.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen
|
||||
/// does — the carousel is rebuilt on every discovery pass, and a target that re-resolved itself
|
||||
/// could hand "Remove" a different host than the one the user was looking at.
|
||||
struct HostOptionsTarget: Identifiable {
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ a pinned profile card rather than the host's own tile.
|
||||
var profile: StreamProfile?
|
||||
|
||||
/// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus,
|
||||
/// and sharing an id would let one stand in for another mid-transition (the same rule
|
||||
/// `GamepadScreen.library` follows).
|
||||
var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" }
|
||||
}
|
||||
|
||||
struct GamepadHostOptionsView: View {
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every
|
||||
/// service pass; holding an index or a live lookup would let the menu retarget itself onto
|
||||
/// whichever host slid into that slot, and "Remove" must never be able to do that.
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ opened on a pinned profile card rather than the host's own tile.
|
||||
var pinnedProfile: StreamProfile?
|
||||
/// Whether the host is reachable right now — decides whether Wake is worth offering.
|
||||
var isOnline = false
|
||||
/// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known).
|
||||
var canWake = false
|
||||
let onEdit: () -> Void
|
||||
let onWake: () -> Void
|
||||
/// Drop the pinned fingerprint — the host stays saved, and the next connect re-pairs.
|
||||
let onForgetPairing: () -> Void
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
|
||||
/// Removing is the one action here with no undo, so its row ARMS on the first press and only
|
||||
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
|
||||
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { run($0.action) },
|
||||
onBack: { performClose() },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
if !compact {
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geistFixed(metrics.detailFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(detail)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
}
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
.gamepadPaletteInk()
|
||||
// Moving the focus off the armed Remove row disarms it: an arming that outlives the row it
|
||||
// was made on is a trap, and the thumb that wandered away is exactly the hesitation the
|
||||
// two-press rule exists to catch.
|
||||
.onChange(of: focusID) { _, id in
|
||||
if id != Action.remove.rawValue { armed = false }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let action: Action
|
||||
let label: String
|
||||
var icon: String
|
||||
var isDestructive = false
|
||||
var id: String { action.rawValue }
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// A pinned card is a shortcut, not a host: everything host-level is deliberately absent.
|
||||
if pinnedProfile != nil {
|
||||
return [
|
||||
Row(action: .unpin, label: "Unpin card", icon: "pin.slash"),
|
||||
Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"),
|
||||
Row(action: .cancel, label: "Cancel", icon: "xmark"),
|
||||
]
|
||||
}
|
||||
var list: [Row] = []
|
||||
// Waking a host that is already answering would just sit there counting seconds.
|
||||
if canWake, !isOnline {
|
||||
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
action: .forgetPairing, label: "Forget pairing", icon: "lock.open"))
|
||||
}
|
||||
list.append(Row(
|
||||
action: .remove,
|
||||
label: armed ? "Remove host \u{2014} press again" : "Remove host",
|
||||
icon: "trash", isDestructive: true))
|
||||
list.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
|
||||
return list
|
||||
}
|
||||
|
||||
/// The explainer under the list — the same band the settings screen uses, and the only place a
|
||||
/// destructive action can say what it will actually do before it is pressed.
|
||||
private var detail: String {
|
||||
switch rows.first(where: { $0.id == focusID })?.action {
|
||||
case .wake:
|
||||
return "Send a Wake-on-LAN packet and wait for this host to answer."
|
||||
case .copyLink:
|
||||
return "Copy a punktfunk:// link to this host — paste it anywhere to connect."
|
||||
case .edit:
|
||||
return "Rename this host or change its address. Pairing and pinned cards are kept."
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
: "Delete this host, its pairing and its pinned cards from this device."
|
||||
case .unpin:
|
||||
return "Remove this profile's card. The profile itself and the host are untouched."
|
||||
case .cancel, .none:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
[
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
|
||||
run(row.action)
|
||||
} }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { performClose() }),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func run(_ action: Action) {
|
||||
switch action {
|
||||
case .wake:
|
||||
onWake()
|
||||
performClose()
|
||||
case .copyLink:
|
||||
LinkClipboard.copy(
|
||||
DeepLink.forHost(host, profile: pinnedProfile?.id).urlString)
|
||||
// No toast machinery on this surface — the row says so itself, which is the same
|
||||
// acknowledgement in the place the user is already looking.
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = true }
|
||||
case .edit:
|
||||
onEdit()
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
return
|
||||
}
|
||||
onRemove()
|
||||
performClose()
|
||||
case .unpin:
|
||||
onUnpin()
|
||||
performClose()
|
||||
case .cancel:
|
||||
performClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// The destructive row wears the warning colour only once ARMED: red on a row that still
|
||||
// needs a second press reads as "this already happened".
|
||||
let danger = row.isDestructive && armed
|
||||
return HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(
|
||||
danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55)))
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(danger ? GamepadInk.warningRed : ink.fg)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
.animation(.smooth(duration: 0.18), value: armed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -88,6 +88,11 @@ struct GamepadInk: Equatable, Sendable {
|
||||
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
||||
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
||||
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
||||
/// An armed destructive action (the host menu's Remove). Palette-independent for exactly the
|
||||
/// same reason as the pip above, and the more strongly so: the one colour on this UI that
|
||||
/// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm
|
||||
/// palette, or read as a highlight on a red one.
|
||||
static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26)
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
|
||||
@@ -21,6 +21,8 @@ import SwiftUI
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case hostOptions(HostOptionsTarget)
|
||||
case editHost(StoredHost)
|
||||
case pair(StoredHost)
|
||||
case library(LibraryTarget)
|
||||
|
||||
@@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
// Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on
|
||||
// the shelf — see `HostOptionsTarget.id`.
|
||||
case .hostOptions(let target): return "hostOptions-\(target.id)"
|
||||
case .editHost(let host): return "editHost-\(host.id.uuidString)"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
|
||||
// libraries, and sharing an id would let one stand in for another mid-transition.
|
||||
@@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable {
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .settings, .addHost, .hostOptions, .editHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ private struct ShotGamepadHome: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +304,8 @@ private struct ShotConnect: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ struct AboutView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
@State private var showAcknowledgements = false
|
||||
/// The in-session controls. They used to announce themselves in a 6-second banner at the start
|
||||
/// of every stream; that banner is gone, so this page is where they live now — including for
|
||||
/// touch users on a Mac, who saw it too.
|
||||
@State private var showShortcuts = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +48,9 @@ struct AboutView: View {
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
Section {
|
||||
shortcutsRow
|
||||
}
|
||||
Section {
|
||||
linkRow("Documentation", systemImage: "book", url: Destination.docs)
|
||||
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
|
||||
@@ -63,6 +70,21 @@ struct AboutView: View {
|
||||
// A SHEET, not a push — on iPad the settings detail column is deliberately not a
|
||||
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
|
||||
// pushed into a context with no back button and stranded the licenses on screen.
|
||||
// A sheet for the same reason Acknowledgements is one — see that modifier's note on the
|
||||
// iPad detail column not being a NavigationStack.
|
||||
.sheet(isPresented: $showShortcuts) {
|
||||
NavigationStack {
|
||||
ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showShortcuts = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(width: 560, height: 460)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showAcknowledgements) {
|
||||
NavigationStack {
|
||||
AcknowledgementsView()
|
||||
@@ -135,6 +157,24 @@ struct AboutView: View {
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var shortcutsRow: some View {
|
||||
Button {
|
||||
showShortcuts = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Shortcuts", systemImage: "command")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var acknowledgementsRow: some View {
|
||||
Button {
|
||||
showAcknowledgements = true
|
||||
@@ -168,6 +208,11 @@ struct AboutView: View {
|
||||
tvAddress("Community", Destination.community)
|
||||
tvAddress("Source code", Destination.source)
|
||||
}
|
||||
// Both push here: this page really is inside a navigation stack on tvOS, which is
|
||||
// the case the sheets above exist to work around elsewhere.
|
||||
NavigationLink("Shortcuts") {
|
||||
ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic
|
||||
}
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
Text("Punktfunk's source is open under MIT or Apache-2.0.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
@@ -219,21 +264,39 @@ struct AppIconView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if let icon = Self.bundleIcon {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at
|
||||
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
|
||||
// shape (and margins) into the image, and clipping that would cut into it.
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
|
||||
style: .continuous))
|
||||
// The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle
|
||||
// is not a no-op — it still clips to the layout frame, which crops any art whose
|
||||
// aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it).
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at draw
|
||||
// time, so used raw it is a hard-cornered square. macOS bakes its own shape (and
|
||||
// margins) into the image, and clipping that would cut into it.
|
||||
if icon.needsMask {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: side * Self.iOSCornerRatio, style: .continuous))
|
||||
} else {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
} else {
|
||||
monogram
|
||||
}
|
||||
}
|
||||
// tvOS's icon is a 400×240 rectangle, not a squircle — framing it square would letterbox
|
||||
// it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the
|
||||
// real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot
|
||||
// shrink when its row is tight, so it overflows and is clipped by whatever is above it
|
||||
// instead — `.fit` inside a max frame gives back the whole icon, just smaller.
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: side * (400.0 / 240.0), maxHeight: side)
|
||||
#else
|
||||
.frame(width: side, height: side)
|
||||
#endif
|
||||
.accessibilityHidden(true) // the app's name is the next line
|
||||
}
|
||||
|
||||
@@ -267,7 +330,14 @@ struct AppIconView: View {
|
||||
else { return nil }
|
||||
return (Image(uiImage: image), true)
|
||||
#else
|
||||
return nil // tvOS: layered icons have no single image to load
|
||||
// tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has
|
||||
// no single image to load — which is why this used to return nil and every About page on
|
||||
// the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those
|
||||
// four layers flattened into one asset, generated from the SAME art the stack uses so it
|
||||
// cannot drift into being a second, subtly different icon. Already masked and composited,
|
||||
// so it needs no rounding of ours.
|
||||
guard let image = UIImage(named: "AboutAppIcon") else { return nil }
|
||||
return (Image(uiImage: image), false)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
/// Trailing, like Profiles: both are built from something other than the settings store, and
|
||||
/// About is where the strip ends because it is the one section that changes nothing.
|
||||
case about = "About"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@@ -53,6 +56,8 @@ struct GamepadSettingsView: View {
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The About section's link rows (never used on tvOS, which has no browser).
|
||||
@Environment(\.openURL) private var openURL
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@@ -64,6 +69,9 @@ struct GamepadSettingsView: View {
|
||||
/// console's input drop) and while the connect takeover is up; a system presentation never
|
||||
/// needs the gate and keeps the default.
|
||||
var controllerActive = true
|
||||
/// Whether this device has a microphone at all — passed through to the About page's shortcuts
|
||||
/// reference, which must not list a mute key on a device that can't mute anything.
|
||||
var micAvailable = true
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -135,6 +143,14 @@ struct GamepadSettingsView: View {
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
/// A reading surface opened from the About tab, replacing the row list the way the pin picker
|
||||
/// does. Depth is 1: neither page opens anything further.
|
||||
private enum AboutPage: Equatable {
|
||||
case shortcuts
|
||||
case licenses
|
||||
}
|
||||
|
||||
@State private var aboutPage: AboutPage?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -160,9 +176,9 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(ink.fg)
|
||||
.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 }
|
||||
// The picker and the About reading pages are one layer deeper — their rows aren't
|
||||
// sections of anything, so the strip would be a control that does nothing.
|
||||
if pinTarget == nil, aboutPage == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
@@ -329,16 +345,62 @@ struct GamepadSettingsView: View {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// Where the product actually lives — kept together so the three can be checked against the
|
||||
/// README in one glance (the touch `AboutView` holds the same three).
|
||||
private enum Destination {
|
||||
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
|
||||
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
|
||||
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
|
||||
}
|
||||
|
||||
/// "Version 0.29.0 (100000)" — the build number only when it says something the version does
|
||||
/// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it.
|
||||
private static var versionLine: String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
|
||||
return "Version \(short) (\(build))"
|
||||
}
|
||||
|
||||
/// "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 {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
if let profile = pinTarget { return "Pin “\(profile.name)”" }
|
||||
switch aboutPage {
|
||||
case .shortcuts: return "Shortcuts"
|
||||
case .licenses: return "Acknowledgements"
|
||||
case nil: return "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
// A reading page is scrolled, not operated: offering A would be the same lie a dimmed row
|
||||
// used to tell. Only Back remains.
|
||||
if aboutPage != nil {
|
||||
return [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() })]
|
||||
}
|
||||
// The About rows open things rather than change them, so A reads "Open" and there is no
|
||||
// Adjust cell — left/right genuinely does nothing there.
|
||||
if pinTarget == nil, tab == .about {
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section", action: { step(tabBy: 1) })]
|
||||
: []
|
||||
return sections + [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() }),
|
||||
]
|
||||
}
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
@@ -386,6 +448,9 @@ struct GamepadSettingsView: View {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else if let page = aboutPage {
|
||||
aboutPage = nil
|
||||
focusID = page == .shortcuts ? "shortcuts" : "licenses"
|
||||
} else {
|
||||
performClose()
|
||||
}
|
||||
@@ -393,7 +458,53 @@ struct GamepadSettingsView: View {
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
@ViewBuilder
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
switch row.kind {
|
||||
case .control: controlRow(row, focused: focused)
|
||||
case .footer:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.detailFont, .medium, relativeTo: .caption))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(ink.fg(focused ? 0.7 : 0.45))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 18)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
case .heading:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.labelFont, .bold, relativeTo: .headline))
|
||||
.foregroundStyle(ink.fg(0.75))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 2)
|
||||
case .prose:
|
||||
// Focus here means "this is the part you are scrolled to", not "press A" — so it is a
|
||||
// quiet wash rather than the control rows' full glass.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.label)
|
||||
.font(.geistFixed(metrics.valueFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.95))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !row.value.isEmpty {
|
||||
Text(row.value)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.6))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.vertical, metrics.rowVPad * 0.7)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous)
|
||||
.fill(ink.fg(focused ? 0.08 : 0))
|
||||
}
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlRow(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
@@ -505,10 +616,23 @@ struct GamepadSettingsView: View {
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// How this row DRAWS. Every tab but About is `.control` — the glass row with a label and
|
||||
/// a value. About is a reading surface as much as a menu, so it also has a heading and a
|
||||
/// block of prose, which are rows only so the focus list can scroll them (the same trick
|
||||
/// `Licenses.chunked` plays for tvOS focus).
|
||||
var kind: Kind = .control
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
let activate: () -> Void
|
||||
|
||||
enum Kind {
|
||||
case control
|
||||
case heading
|
||||
case prose
|
||||
/// Quiet, centred trailing text — the About tab's version line.
|
||||
case footer
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||
@@ -530,9 +654,133 @@ struct GamepadSettingsView: View {
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
if let page = aboutPage {
|
||||
switch page {
|
||||
case .shortcuts: return shortcutRows
|
||||
case .licenses: return licenseRows
|
||||
}
|
||||
}
|
||||
if tab == .about { return aboutRows }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
/// The About section: the ways out, plus the two reading surfaces. The identity itself (icon,
|
||||
/// name, version, tagline) is the HEADER while this tab is up — see `aboutIdentity` — not a
|
||||
/// row, so the list holds no focus stop that does nothing when pressed.
|
||||
private var aboutRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutAction(
|
||||
id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming",
|
||||
detail: "What to press during a session on this device — and on a controller.",
|
||||
open: .shortcuts),
|
||||
aboutAction(
|
||||
id: "licenses", icon: "text.document", label: "Acknowledgements",
|
||||
value: "MIT or Apache-2.0",
|
||||
detail: "Punktfunk's own licence and the third-party components it uses.",
|
||||
open: .licenses),
|
||||
]
|
||||
list.append(contentsOf: [
|
||||
aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs),
|
||||
aboutLink(
|
||||
id: "community", icon: "bubble.left.and.bubble.right", label: "Community",
|
||||
url: Destination.community),
|
||||
aboutLink(
|
||||
id: "source", icon: "chevron.left.forwardslash.chevron.right",
|
||||
label: "Source code", url: Destination.source),
|
||||
])
|
||||
// The version sits UNDER the rows rather than in a header card above them. The card that
|
||||
// used to head this tab carried the app icon, and on tvOS that icon is a 400x240
|
||||
// rectangle that would not survive contact with a layout built for square art — three
|
||||
// attempts at framing it were still cropping it on the real TV. A version string answers
|
||||
// the only question anyone actually opens About to ask, and has no aspect ratio to get
|
||||
// wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you
|
||||
// failed to press.
|
||||
list.append(Row(
|
||||
id: "version", tab: .about, icon: "", label: Self.versionLine, value: "",
|
||||
detail: "", adjustable: false, enabled: true, kind: .footer,
|
||||
adjust: { _ in false }, activate: {}))
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutAction(
|
||||
id: String, icon: String, label: String, value: String, detail: String, open: AboutPage
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: value, detail: detail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the page's first row — the focus list's reconcile follows this
|
||||
// id when the row set swaps underneath it (the pin picker's pattern).
|
||||
focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id
|
||||
aboutPage = open
|
||||
})
|
||||
}
|
||||
|
||||
/// tvOS has no browser and no `openURL`, so an address there is text to read off the screen
|
||||
/// rather than a link to nowhere — the same call the touch About page makes.
|
||||
private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row {
|
||||
let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "")
|
||||
#if os(tvOS)
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Open this address on a phone or computer.",
|
||||
adjustable: false, adjust: { _ in false }, activate: {})
|
||||
#else
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Opens in your browser.",
|
||||
adjustable: false, adjust: { _ in false }, activate: { openURL(url) })
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The shortcuts reference — the same `ShortcutsCatalog` the touch About page renders, so the
|
||||
/// two can never drift.
|
||||
private var shortcutRows: [Row] {
|
||||
ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in
|
||||
[aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)]
|
||||
+ group.items.map { item in
|
||||
aboutText(
|
||||
id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text,
|
||||
kind: .prose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS
|
||||
/// can page it by focus steps) — so it scrolls with the stick and needs no machinery here.
|
||||
private var licenseRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading),
|
||||
aboutText(
|
||||
id: "lic-summary",
|
||||
label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist "
|
||||
+ "typeface under the SIL Open Font License 1.1, and uses the third-party "
|
||||
+ "components below, each under its own license.",
|
||||
kind: .prose),
|
||||
]
|
||||
for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() {
|
||||
list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
list.append(aboutText(
|
||||
id: "lic-third-heading", label: "Third-party software", kind: .heading))
|
||||
for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() {
|
||||
list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutText(
|
||||
id: String, label: String, value: String = "", kind: Row.Kind
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: "", label: label, value: value, detail: "",
|
||||
adjustable: false, enabled: true, kind: kind,
|
||||
adjust: { _ in false }, activate: {})
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// The in-session controls, written down once and read by every surface that shows them.
|
||||
//
|
||||
// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill
|
||||
// that told you the controls exactly once, while you were busy looking at the thing you had just
|
||||
// connected to, and then never again. A reference you can OPEN answers the question at the moment
|
||||
// it is actually asked — which is the second session, not the first.
|
||||
//
|
||||
// The catalog is data rather than a view so both About pages render the same words: the touch
|
||||
// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner
|
||||
// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys
|
||||
// were written down — hence the touch surface gets this too, not just the gamepad UI.
|
||||
//
|
||||
// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and
|
||||
// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to
|
||||
// explain mouse capture. A controller's chords are the one section common to all three — they are
|
||||
// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the
|
||||
// whole point of a cross-client chord.
|
||||
|
||||
import AVFoundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One line of the reference: what you press, and what it does.
|
||||
struct ShortcutItem: Identifiable {
|
||||
/// Stable within its group — the keys are unique per group by construction.
|
||||
var id: String { keys }
|
||||
/// The chord itself, rendered monospaced so ⌃⌥⇧-style runs stay legible.
|
||||
let keys: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct ShortcutGroup: Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let items: [ShortcutItem]
|
||||
}
|
||||
|
||||
enum ShortcutsCatalog {
|
||||
/// Whether a mute key is worth listing when no session is running, for the About page reached
|
||||
/// from settings. `SessionModel.micAvailable` is the authority DURING a session — it also
|
||||
/// consults the profile the session actually resolved — but a reference page opened between
|
||||
/// sessions has no session to ask, so it answers the device-level half of the same question:
|
||||
/// a platform with an app-accessible input, the mic setting on, and the OS not refusing.
|
||||
/// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending.
|
||||
static var micPlausible: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone
|
||||
#else
|
||||
guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true
|
||||
else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// `micAvailable` gates the mute row — a device with no microphone would otherwise be told
|
||||
/// about a key that does nothing, which is the failure the old banner already avoided.
|
||||
static func groups(micAvailable: Bool) -> [ShortcutGroup] {
|
||||
var groups: [ShortcutGroup] = []
|
||||
#if os(macOS)
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "Click", text: "Capture the mouse and keyboard for the stream"),
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Keyboard", items: keyboard))
|
||||
#elseif os(iOS)
|
||||
// iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands
|
||||
// publishes it either way); a phone simply never sees a keyboard to press it on.
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Hardware keyboard", items: keyboard))
|
||||
groups.append(.init(title: "Touch", items: [
|
||||
.init(keys: "Three-finger tap", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#elseif os(tvOS)
|
||||
// The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during
|
||||
// a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold
|
||||
// gesture is genuinely stuck — which is why this was the one banner that could not simply
|
||||
// be deleted without putting the words somewhere findable first.
|
||||
groups.append(.init(title: "Siri Remote", items: [
|
||||
.init(keys: "Hold Back", text: "Disconnect"),
|
||||
.init(keys: "Touch surface", text: "Move the pointer"),
|
||||
.init(keys: "Press", text: "Click"),
|
||||
.init(keys: "Play/Pause", text: "Right-click"),
|
||||
.init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#endif
|
||||
// Every client's controller speaks these two chords — see GamepadCapture.escapeChord and
|
||||
// .statsChord, which a test pins against their GameController element lists.
|
||||
groups.append(.init(title: "Controller", items: [
|
||||
.init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"),
|
||||
.init(keys: "Select + X", text: "Cycle the statistics overlay"),
|
||||
.init(keys: "Hold Select", text: "Press the host's guide button"),
|
||||
]))
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard-interface reference — a sheet from `AboutView` on iOS/macOS, a pushed page on
|
||||
/// tvOS — so the keys the start-of-stream banner used to carry are still one press away.
|
||||
/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.)
|
||||
struct ShortcutsView: View {
|
||||
let micAvailable: Bool
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not
|
||||
// operated — a plain scrolling column at TV sizes says the same thing with less chrome.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 30) {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(group.title)
|
||||
.font(.geist(28, .semibold, relativeTo: .headline))
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 20) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(22, .medium))
|
||||
.frame(minWidth: 300, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(22, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 1000, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("Shortcuts")
|
||||
#else
|
||||
form
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(13, .medium))
|
||||
.foregroundStyle(.primary)
|
||||
// A fixed column keeps the descriptions aligned; the chords vary
|
||||
// from "Click" to "L1 + R1 + Start + Select".
|
||||
.frame(minWidth: 132, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(13, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Shortcuts")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user