133e25849d
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
6.3 KiB
Swift
148 lines
6.3 KiB
Swift
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
|
// actual connection runs the trust-on-first-use fingerprint prompt.
|
|
|
|
import SwiftUI
|
|
|
|
struct AddHostSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var name = ""
|
|
@State private var address = ""
|
|
@State private var port = 9777
|
|
#if os(tvOS)
|
|
private enum EditField: String, Identifiable {
|
|
case name, address, port
|
|
var id: String { rawValue }
|
|
}
|
|
@State private var editing: EditField?
|
|
#endif
|
|
|
|
let onAdd: (StoredHost) -> Void
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
|
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
|
VStack(spacing: 24) {
|
|
TVFieldRow(
|
|
label: "Name", value: name, placeholder: "Optional"
|
|
) { editing = .name }
|
|
TVFieldRow(
|
|
label: "Address", value: address, placeholder: "IP or hostname"
|
|
) { editing = .address }
|
|
TVFieldRow(
|
|
label: "Port", value: String(port), placeholder: ""
|
|
) { editing = .port }
|
|
HStack(spacing: 32) {
|
|
Button("Cancel", role: .cancel) { dismiss() }
|
|
Button("Add Host") { add() }
|
|
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
.frame(maxWidth: 1000)
|
|
.padding(60)
|
|
.navigationTitle("Add Host")
|
|
.fullScreenCover(item: $editing) { field in
|
|
switch field {
|
|
case .name:
|
|
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
|
name = $0
|
|
editing = nil
|
|
}
|
|
case .address:
|
|
TVTextEntry(title: "IP or hostname", text: address) {
|
|
address = $0.trimmingCharacters(in: .whitespaces)
|
|
editing = nil
|
|
}
|
|
case .port:
|
|
TVTextEntry(
|
|
title: "Port", text: String(port), keyboardType: .numberPad
|
|
) {
|
|
if let value = Int($0), (1...65535).contains(value) {
|
|
port = value
|
|
}
|
|
editing = nil
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
VStack(spacing: 0) {
|
|
Form {
|
|
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
|
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
|
TextField("Port", value: $port, format: .number.grouping(.never))
|
|
#if os(tvOS)
|
|
// tvOS floats the label above a non-empty field INSIDE the pill,
|
|
// shoving the value off-center — the field is always prefilled
|
|
// here, so drop the label there.
|
|
.labelsHidden()
|
|
#endif
|
|
}
|
|
#if !os(tvOS)
|
|
.formStyle(.grouped)
|
|
#endif
|
|
#if os(iOS)
|
|
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
|
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
|
.scrollDisabled(true)
|
|
#endif
|
|
#if os(macOS)
|
|
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
|
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
|
// keeps this compact and centered.
|
|
HStack {
|
|
Button("Cancel", role: .cancel) { dismiss() }
|
|
.keyboardShortcut(.cancelAction)
|
|
Spacer()
|
|
Button("Add Host") { add() }
|
|
.glassProminentButtonStyle()
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.padding(16)
|
|
#else
|
|
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
|
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
|
// so all three are live; if anyone adds it later, restore a Cancel here or there is
|
|
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
|
|
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
|
|
// Button only widens its hit area and leaves the styled capsule hugging the text —
|
|
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
|
|
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
|
|
// hardware keyboard / iPad Return submit.
|
|
Button { add() } label: {
|
|
Text("Add Host").frame(maxWidth: .infinity)
|
|
}
|
|
.glassProminentButtonStyle()
|
|
.controlSize(.large)
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
.padding(16)
|
|
#endif
|
|
}
|
|
#if os(iOS)
|
|
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
|
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
|
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
|
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
|
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
|
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
|
.presentationDetents([.height(320)])
|
|
.presentationDragIndicator(.visible)
|
|
#endif
|
|
#if os(macOS)
|
|
.frame(width: 380)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
private func add() {
|
|
onAdd(StoredHost(
|
|
name: name.trimmingCharacters(in: .whitespaces),
|
|
address: address.trimmingCharacters(in: .whitespaces),
|
|
port: UInt16(clamping: port)))
|
|
dismiss()
|
|
}
|
|
}
|