diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 59cd5a3..97c89ba 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -52,6 +52,9 @@ struct ContentView: View { @State private var awaitingApproval: ApprovalRequest? @State private var speedTestTarget: StoredHost? @State private var libraryTarget: StoredHost? + /// Wakes a sleeping host and waits for it to come back online before connecting (drives the + /// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS. + @StateObject private var waker = HostWaker() #if !os(macOS) @State private var showSettings = false #endif @@ -212,12 +215,18 @@ struct ContentView: View { } private var home: some View { + // The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still + // `home`, so it covers the whole wake→online→connect sequence). + homeBase.overlay { WakeOverlay(waker: waker) } + } + + @ViewBuilder private var homeBase: some View { #if os(macOS) Group { if gamepadUIActive { GamepadHomeView( store: store, model: model, discovery: discovery, - libraryTarget: $libraryTarget, + libraryTarget: $libraryTarget, waker: waker, connect: { connect($0) }, connectDiscovered: connectDiscovered) } else { HomeView( @@ -225,7 +234,7 @@ struct ContentView: View { showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, connect: { connect($0) }, connectDiscovered: connectDiscovered, - onPaired: handlePaired, onLaunchTitle: launchTitle) + onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } #elseif os(iOS) @@ -233,7 +242,7 @@ struct ContentView: View { if gamepadUIActive { GamepadHomeView( store: store, model: model, discovery: discovery, - libraryTarget: $libraryTarget, + libraryTarget: $libraryTarget, waker: waker, connect: { connect($0) }, connectDiscovered: connectDiscovered) } else { HomeView( @@ -242,7 +251,7 @@ struct ContentView: View { speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, showSettings: $showSettings, connect: { connect($0) }, connectDiscovered: connectDiscovered, - onPaired: handlePaired, onLaunchTitle: launchTitle) + onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } #else @@ -252,7 +261,7 @@ struct ContentView: View { speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, showSettings: $showSettings, connect: { connect($0) }, connectDiscovered: connectDiscovered, - onPaired: handlePaired, onLaunchTitle: launchTitle) + onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) #endif } @@ -406,9 +415,37 @@ struct ContentView: View { /// delegated-approval connect (host parks it until the operator approves). private func startSession( _ host: StoredHost, launchID: String? = nil, - allowTofu: Bool, requestAccess: Bool = false + allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil + ) { + let go = { + startSessionDirect( + host, launchID: launchID, allowTofu: allowTofu, + requestAccess: requestAccess, approvalReq: approvalReq) + } + // Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come + // back online — a cold box takes far longer to boot than a connect will sit — showing the + // "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away. + if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) { + discovery.start() // so we can observe it reappear + waker.start( + host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address, + isOnline: { discovery.advertises(host) }, onOnline: go) + } else { + go() + } + } + + /// The actual dial — reached directly when the host is awake, or from the waker once a woken + /// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host + /// is advertising (and is a harmless no-op otherwise). + private func startSessionDirect( + _ host: StoredHost, launchID: String? = nil, + allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil ) { prepareWake(for: host) + // The delegated-approval wait prompt only makes sense once we're actually dialing — set it + // here (after any wake), not before, so it never stacks under the "Waking…" overlay. + if let approvalReq { awaitingApproval = approvalReq } model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), @@ -452,12 +489,24 @@ struct ContentView: View { /// as paired (see the `.streaming` branch of `onChange`). private func requestAccess(_ req: ApprovalRequest) { guard !model.isBusy else { return } - awaitingApproval = req // Pin the advertised certificate for a discovered host (impostor defence during the long // wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. var host = req.host host.pinnedSHA256 = req.advertisedFingerprint - startSession(host, allowTofu: false, requestAccess: true) + // `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks + // under the "Waking…" overlay. + startSession(host, allowTofu: false, requestAccess: true, approvalReq: req) + } + + /// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire + /// the packet and wait for the host to come online, but don't connect — the user then sees it + /// go online and can connect. + private func wakeOnly(_ host: StoredHost) { + guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return } + discovery.start() + waker.start( + host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address, + isOnline: { discovery.advertises(host) }, onOnline: {}) } /// Picked a title in the (experimental) library: dismiss the browser and start a session that diff --git a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift index d039ccb..6d75c50 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift @@ -1,67 +1,87 @@ -// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first -// actual connection runs the trust-on-first-use fingerprint prompt. +// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid. +// The MAC prefills from what we already know — the host's stored MAC, or the live mDNS advert's if +// it hasn't been learned yet — so it's usually already correct; type/paste it for a host we've +// never seen advertise. The first actual connection still runs the trust-on-first-use prompt. +import PunktfunkKit import SwiftUI struct AddHostSheet: View { @Environment(\.dismiss) private var dismiss - @State private var name = "" - @State private var address = "" - @State private var port = 9777 + + /// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved). + let existing: StoredHost? + /// MAC(s) to offer when the host has none stored yet — the live advert's, so the field is + /// prefilled the moment the host is on the network, even before a connect has learned it. + let suggestedMacs: [String] + let onSave: (StoredHost) -> Void + + @State private var name: String + @State private var address: String + @State private var port: Int + @State private var mac: String #if os(tvOS) private enum EditField: String, Identifiable { - case name, address, port + case name, address, port, mac var id: String { rawValue } } - @State private var editing: EditField? + @State private var editingField: EditField? #endif - let onAdd: (StoredHost) -> Void + private var isEditing: Bool { existing != nil } + private var actionTitle: String { isEditing ? "Save" : "Add Host" } + private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty } + + init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) { + self.existing = existing + self.suggestedMacs = suggestedMacs + self.onSave = onSave + _name = State(initialValue: existing?.name ?? "") + _address = State(initialValue: existing?.address ?? "") + _port = State(initialValue: Int(existing?.port ?? 9777)) + let stored = existing?.macAddresses ?? [] + _mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", ")) + } 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 } + TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name } + TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address } + TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port } + TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac } HStack(spacing: 32) { Button("Cancel", role: .cancel) { dismiss() } - Button("Add Host") { add() } - .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) + Button(actionTitle) { save() }.disabled(!canSave) } .padding(.top, 12) } .frame(maxWidth: 1000) .padding(60) - .navigationTitle("Add Host") - .fullScreenCover(item: $editing) { field in + .navigationTitle(isEditing ? "Edit Host" : "Add Host") + .fullScreenCover(item: $editingField) { field in switch field { case .name: TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) { name = $0 - editing = nil + editingField = nil } case .address: TVTextEntry(title: "IP or hostname", text: address) { address = $0.trimmingCharacters(in: .whitespaces) - editing = nil + editingField = nil } case .port: - TVTextEntry( - title: "Port", text: String(port), keyboardType: .numberPad - ) { - if let value = Int($0), (1...65535).contains(value) { - port = value - } - editing = nil + TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) { + if let value = Int($0), (1...65535).contains(value) { port = value } + editingField = nil + } + case .mac: + TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) { + mac = $0.trimmingCharacters(in: .whitespaces) + editingField = nil } } } @@ -71,77 +91,77 @@ struct AddHostSheet: View { 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() + TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known")) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) #endif } #if !os(tvOS) - .formStyle(.grouped) - #endif + .formStyle(.grouped) + // The grouped form's default system text is oversized next to the app's Geist + // typography — bring it down and on-brand so the panel doesn't read out of place. + .font(.geist(12, relativeTo: .callout)) + .controlSize(.small) + #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() } + Button(actionTitle) { save() } .glassProminentButtonStyle() .keyboardShortcut(.defaultAction) - .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(!canSave) } .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) + Button { save() } label: { + Text(actionTitle).frame(maxWidth: .infinity) } .glassProminentButtonStyle() .controlSize(.large) .keyboardShortcut(.defaultAction) - .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(!canSave) .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)]) + // Four fields + the action row — a touch taller than the 3-field add sheet used to be. + .presentationDetents([.height(392)]) .presentationDragIndicator(.visible) #endif #if os(macOS) - .frame(width: 380) + .frame(width: 400) .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))) + private func save() { + var host = existing ?? StoredHost(name: "", address: "") + host.name = name.trimmingCharacters(in: .whitespaces) + host.address = address.trimmingCharacters(in: .whitespaces) + host.port = UInt16(clamping: port) + host.macAddresses = Self.parseMacs(mac) + onSave(host) dismiss() } + + /// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex + /// octets, normalized lower-case); nil when none are valid, so clearing the field clears the + /// stored MAC. + static func parseMacs(_ s: String) -> [String]? { + let macs = s + .split(whereSeparator: { ",; \n\t".contains($0) }) + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { m in + let parts = m.split(separator: ":") + return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil } + } + return macs.isEmpty ? nil : macs + } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 8a37775..dd8c686 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -58,16 +58,19 @@ struct GamepadAddHostView: View { .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, compact ? 4 : 8) .frame(maxWidth: .infinity) - .overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) } + .overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) } .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { bottomTray - .padding(.horizontal, 22) - .padding(.vertical, compact ? 6 : 10) + // Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView). + .padding(.horizontal, compact ? 12 : 18) + .padding(.bottom, compact ? 12 : 18) + .padding(.top, compact ? 6 : 10) .background { GamepadTrayScrim(edge: .bottom) } } - .background { GamepadScreenBackground() } + // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. + .background { GamepadFormBackground() } // A port can't exceed 5 digits — cap while typing so the row can't grow absurd. .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } @@ -165,14 +168,16 @@ struct GamepadAddHostView: View { } .padding(.horizontal, 16) .padding(.vertical, 13) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.white.opacity(focused || editing == row.id ? 0.1 : 0)) - } + // 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), + tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, + interactive: focused) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder( - editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0), + editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 90b2074..ae0745f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -39,7 +39,9 @@ struct GamepadHint: Identifiable { } /// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`). -/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration. +/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration — +/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over +/// the backdrop instead of dissolving into it. struct GamepadHintBar: View { let hints: [GamepadHint] @@ -57,39 +59,141 @@ struct GamepadHintBar: View { } .font(.geist(14, .semibold, relativeTo: .subheadline)) .foregroundStyle(.white.opacity(0.85)) + .padding(13) + .consoleGlass(Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) } } -/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs -/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player -/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it -/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a -/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode -/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the -/// two — radial gradients driven by a TimelineView give the same look with none of that risk. +/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black +/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an +/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow, +/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks +/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting +/// radial-blob field, unchanged, so nothing regresses. /// -/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here -/// can't inflate the caller's layout past the safe area (see the layout discipline note in -/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase. +/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/ +/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably +/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that +/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here +/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's +/// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular - /// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes. + var body: some View { + Group { + if reduceMotion { + composite(at: 0) + } else { + // 30 Hz is plenty for a field that drifts centimetres per minute, and halves the + // redraw cost of a battery-fed couch device vs. the display's native rate. + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + composite(at: context.date.timeIntervalSinceReferenceDate) + } + } + } + .ignoresSafeArea() + } + + /// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the + /// title/hints legibility scrim. + private func composite(at t: TimeInterval) -> some View { + ZStack { + Color.black + colorField(at: t) + // ±8° over ~5 min — the whole field very slowly warms and cools. + .hueRotation(.degrees(sin(t * 0.021) * 8)) + // Cinematic vignette: darker toward the edges so the cards sit in the pooled light. + // Soft (extends past the frame) so the corners deepen rather than crush to black. + EllipticalGradient( + colors: [.clear, .black.opacity(0.42)], + center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15) + // Legibility grounding for the pinned title (top) and hint pill (bottom). This one + // darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to + // blur), so it stays a gradient, just a light one now. + LinearGradient( + stops: [ + .init(color: .black.opacity(0.38), location: 0), + .init(color: .black.opacity(0.06), location: 0.32), + .init(color: .black.opacity(0.08), location: 0.68), + .init(color: .black.opacity(0.40), location: 1), + ], + startPoint: .top, endPoint: .bottom) + } + } + + @ViewBuilder private func colorField(at t: TimeInterval) -> some View { + if #available(iOS 18, macOS 15, tvOS 18, *) { + MeshGradient( + width: 4, height: 4, + points: Self.meshPoints(at: t), + colors: Self.meshColors, + smoothsColors: true) + } else { + LegacyBlobField(t: t) + } + } + + // MARK: - MeshGradient aurora (iOS 18 / macOS 15+) + + /// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry + /// mid-tone violets, and the four interior points hold the bright brand family — a violet and a + /// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on + /// the right, and the silk shifts temperature as those interior points drift. + private static let meshColors: [Color] = { + let corner = Color(red: 0.075, green: 0.060, blue: 0.160) + return [ + corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner, + Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64), + Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70), + corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner, + ] + }() + + /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh + /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black + /// behind it), while only the four interior points wander on slow, out-of-phase sinusoids + /// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop. + private static func meshPoints(at t: TimeInterval) -> [SIMD2] { + func wob(_ bx: Float, _ by: Float, _ a: Float, + _ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2 { + SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3))) + } + return [ + SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0), + SIMD2(0, 0.333), + wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4), + wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1), + SIMD2(1, 0.333), + SIMD2(0, 0.667), + wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6), + wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0), + SIMD2(1, 0.667), + SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1), + ] + } +} + +/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four +/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see +/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+). +private struct LegacyBlobField: View { + let t: TimeInterval + + /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds + /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. private struct Blob { let color: Color let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) let phase: (x: Double, y: Double) - /// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed). let radius: CGFloat let breathe: (amount: CGFloat, speed: Double) let opacity: Double } - /// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the - /// field shifts within one temperature instead of strobing through the rainbow. private static let blobs: [Blob] = [ Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), @@ -110,49 +214,18 @@ struct GamepadScreenBackground: View { ] var body: some View { - Group { - if reduceMotion { - field(at: 0) - } else { - // 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost - // of a battery-fed couch device vs. the default display rate. - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in - field(at: context.date.timeIntervalSinceReferenceDate) - } - } - } - .ignoresSafeArea() - } - - private func field(at t: TimeInterval) -> some View { GeometryReader { geo in let side = max(geo.size.width, geo.size.height) ZStack { - Color.black - ZStack { - ForEach(Self.blobs.indices, id: \.self) { i in - blobView(Self.blobs[i], at: t, in: geo.size, side: side) - } + ForEach(Self.blobs.indices, id: \.self) { i in + blobView(Self.blobs[i], in: geo.size, side: side) } - // ±10° over ~5 min — the whole field very slowly warms and cools. - .hueRotation(.degrees(sin(t * 0.021) * 10)) - // Composite the additive blobs offscreen once instead of per-layer. - .drawingGroup() - // Legibility scrim: the title (top) and detail/hints (bottom) always sit on - // near-black, whatever the blobs are doing behind them. - LinearGradient( - stops: [ - .init(color: .black.opacity(0.55), location: 0), - .init(color: .black.opacity(0.15), location: 0.35), - .init(color: .black.opacity(0.20), location: 0.65), - .init(color: .black.opacity(0.60), location: 1), - ], - startPoint: .top, endPoint: .bottom) } + .drawingGroup() } } - private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View { + private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View { let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x)) let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) let r = side * blob.radius @@ -168,28 +241,62 @@ struct GamepadScreenBackground: View { } } -/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard -/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row -/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar. +/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray): +/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath +/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so +/// the tray's text sits on a softly blurred backdrop that dissolves into the rows. struct GamepadTrayScrim: View { let edge: VerticalEdge var body: some View { - LinearGradient( - stops: [ - .init(color: .black.opacity(0.92), location: 0), - .init(color: .black.opacity(0.85), location: 0.55), - .init(color: .black.opacity(0), location: 1), - ], - startPoint: edge == .top ? .top : .bottom, - endPoint: edge == .top ? .bottom : .top) + let fromEdge: UnitPoint = edge == .top ? .top : .bottom + let toContent: UnitPoint = edge == .top ? .bottom : .top + Rectangle() + .fill(.ultraThinMaterial) + // These trays always sit on the dark console UI; force dark so the material frosts dark + // (white text stays legible) regardless of the system appearance. + .environment(\.colorScheme, .dark) + // Fade the whole blur out toward the content so it dissolves rather than ending on a line. + .mask { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black.opacity(0.9), location: 0.5), + .init(color: .clear, location: 1), + ], + startPoint: fromEdge, endPoint: toContent) + } // Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own - // text always sits on the near-opaque part, rows dim before they reach it. + // text always sits on the strong part, rows blur out before they reach it. .padding(edge == .top ? .bottom : .top, -32) .ignoresSafeArea() } } +/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's +/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid +/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo +/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens, +/// so the rows read as glass while the screen stays restful. +struct GamepadFormBackground: View { + var body: some View { + ZStack { + Color(red: 0.075, green: 0.062, blue: 0.150) + // Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent + // (fraction radii) so the glow scale tracks the window on any screen. + EllipticalGradient( + colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear], + center: UnitPoint(x: 0.26, y: 0.14), + startRadiusFraction: 0, endRadiusFraction: 0.78) + EllipticalGradient( + colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear], + center: UnitPoint(x: 0.82, y: 0.9), + startRadiusFraction: 0, endRadiusFraction: 0.78) + } + .ignoresSafeArea() + } +} + /// "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. diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 4bcf613..5296076 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -44,8 +44,8 @@ private struct HomeTile: Identifiable { var hasLibrary = false /// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile). var icon: String? - /// Whether the detail panel shows the online/paired pill (hosts yes, actions no). - var showsStatus = true + /// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first. + var canWake = false let activate: () -> Void } @@ -54,6 +54,9 @@ struct GamepadHomeView: View { @ObservedObject var model: SessionModel @ObservedObject var discovery: HostDiscovery @Binding var libraryTarget: StoredHost? + /// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's + /// activate routes an offline+wakeable host through it (see ContentView.startSession). + @ObservedObject var waker: HostWaker let connect: (StoredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void @@ -84,8 +87,11 @@ struct GamepadHomeView: View { } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { GamepadHintBar(hints: hints) - .padding(.leading, 22) - .padding(.vertical, compact ? 6 : 10) + // Equal distance from the left and bottom edges — the pill's corner inset was the + // real asymmetry (leading 22 vs bottom 10), not its internal padding. + .padding(.leading, compact ? 12 : 18) + .padding(.bottom, compact ? 12 : 18) + .padding(.top, compact ? 4 : 8) } .background { GamepadScreenBackground() } .onAppear { discovery.start() } @@ -115,13 +121,13 @@ struct GamepadHomeView: View { @ViewBuilder private func hero(for size: CGSize) -> some View { let cardWidth = min(340, size.width * 0.84) - // 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so - // the strip + detail always fit the region the safe-area insets leave. - let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96)) + // 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)) VStack(spacing: compact ? 8 : 10) { Spacer(minLength: 0) carousel(cardWidth: cardWidth, cardHeight: cardHeight) - detailPanel Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -155,9 +161,9 @@ struct GamepadHomeView: View { onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, - // Stop consuming the controller while another screen is presented on top — otherwise - // the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet). - isActive: libraryTarget == nil && !showSettings && !showAddHost + // Stop consuming the controller while another screen (or the wake overlay) is on top — + // otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad). + isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil ) { tile in hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) } @@ -186,49 +192,14 @@ struct GamepadHomeView: View { } } - /// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout - /// doesn't jump as the selection changes. - @ViewBuilder private var detailPanel: some View { - let tile = tiles.first { $0.id == selection } - VStack(spacing: 6) { - Text(tile?.title ?? " ") - .font(.geist(22, .bold, relativeTo: .title2)) - .foregroundStyle(.white) - .lineLimit(1) - HStack(spacing: 10) { - Text(tile?.subtitle ?? " ") - .font(.geist(13, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.6)) - if let tile, tile.showsStatus { - statusPill(online: tile.isOnline, paired: tile.isPaired) - } - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 24) - .animation(.smooth(duration: 0.25), value: selection) - } - - private func statusPill(online: Bool, paired: Bool) -> some View { - HStack(spacing: 6) { - Circle() - .fill(online ? Color.green : Color.white.opacity(0.35)) - .frame(width: 6, height: 6) - Text(online ? "ONLINE" : "OFFLINE") - if paired { Text("· PAIRED") } - } - .font(.geist(11, .medium, relativeTo: .caption2)) - .tracking(0.8) - .foregroundStyle(.white.opacity(0.55)) - } - // MARK: - Hint bar (pinned bottom-leading via safeAreaInset) private var hints: [GamepadHint] { let selected = tiles.first { $0.id == selection } var hints = [GamepadHint( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), - text: selected?.id == .addHost ? "Add Host" : "Connect")] + text: selected?.id == .addHost ? "Add Host" + : (selected?.canWake == true ? "Wake & Connect" : "Connect"))] if libraryEnabled, selected?.hasLibrary == true { hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) } @@ -252,6 +223,8 @@ struct GamepadHomeView: View { isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, filled: true, hasLibrary: true, + canWake: PunktfunkConnection.wakeOnLANAvailable + && !discovery.advertises(host) && !host.wakeMacs.isEmpty, activate: { connect(host) }) } let discovered = discovery.unsaved(among: store.hosts).map { d in @@ -267,7 +240,6 @@ struct GamepadHomeView: View { title: "Add Host", subtitle: "Register a host by address", icon: "plus", - showsStatus: false, activate: { showAddHost = true }) return saved + discovered + [add] } @@ -291,14 +263,23 @@ private struct GamepadHostTile: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 8) { monogramBadge Spacer(minLength: 0) - if tile.isOnline { - Circle() - .fill(Color.green) - .frame(width: 9, height: 9) - .shadow(color: .green.opacity(0.7), radius: 5) + // The status the removed detail panel used to spell out, now on the card itself: a + // lock for a paired (pinned-identity) host + a green pip when it's live on the LAN. + HStack(spacing: 7) { + if tile.isPaired { + Image(systemName: "lock.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.5)) + } + if tile.isOnline { + Circle() + .fill(Color.green) + .frame(width: 9, height: 9) + .shadow(color: .green.opacity(0.7), radius: 5) + } } } Spacer(minLength: 0) @@ -315,11 +296,11 @@ private struct GamepadHostTile: View { } .padding(20) .frame(width: size.width, height: size.height, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill(.ultraThinMaterial) - .environment(\.colorScheme, .dark) - } + // 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), + tint: tile.filled ? Color.brand.opacity(0.20) : nil) .overlay { RoundedRectangle(cornerRadius: 26, style: .continuous) .strokeBorder( @@ -328,7 +309,6 @@ private struct GamepadHostTile: View { startPoint: .top, endPoint: .bottom), style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) } - .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 20, y: 14) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 4a10e3a..47b4f79 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -26,8 +26,13 @@ struct HomeView: View { let onPaired: (StoredHost, Data) -> Void /// Picked a title in the (experimental) library — start a session that launches it. let onLaunchTitle: (StoredHost, String) -> Void + /// 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 + /// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet. + @State private var editTarget: StoredHost? var body: some View { NavigationStack { @@ -126,6 +131,13 @@ struct HomeView: View { .sheet(isPresented: $showAddHost) { AddHostSheet { store.add($0) } } + .sheet(item: $editTarget) { host in + // Prefill the MAC from the live advert when the host hasn't stored one yet. + AddHostSheet( + existing: host, + suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [], + onSave: { store.update($0) }) + } #if os(iOS) // SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it // is presented directly — wrapping it in a NavigationStack here would nest a split view in @@ -155,13 +167,8 @@ struct HomeView: View { onForget: { store.forgetIdentity(host) }, onRemove: { store.remove(host) }, onBrowseLibrary: onBrowseLibrary, - onWake: { - let macs = host.wakeMacs - let ip = host.address - DispatchQueue.global(qos: .userInitiated).async { - PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip) - } - }) + onWake: { wake(host) }, + onEdit: { editTarget = host }) } private var discoveredSection: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index 28bfd32..1ac5574 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -89,6 +89,8 @@ struct HostCardView: View { /// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored /// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it"). var onWake: (() -> Void)? = nil + /// Open the edit sheet (name / address / port / Wake-on-LAN MAC). + var onEdit: (() -> Void)? = nil var body: some View { let m = CardMetrics.current @@ -136,6 +138,9 @@ struct HostCardView: View { #endif .disabled(isBusy) .contextMenu { + if let onEdit { + Button("Edit…", systemImage: "pencil", action: onEdit) + } Button("Pair with PIN…", action: onPair) Button("Test Network Speed…", action: onSpeedTest) if let onBrowseLibrary { diff --git a/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift new file mode 100644 index 0000000..4be79f9 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift @@ -0,0 +1,84 @@ +// The "Waking …" modal shown while HostWaker brings a sleeping host back — a spinner + a +// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the +// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it +// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out). + +import PunktfunkKit +import SwiftUI + +struct WakeOverlay: View { + @ObservedObject var waker: HostWaker + + var body: some View { + if let w = waker.waking { + ZStack { + // Dim + swallow input to the home behind it. + Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture {} + card(w) + .frame(maxWidth: 380) + .padding(28) + .consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .padding(40) + } + .environment(\.colorScheme, .dark) + .transition(.opacity) + #if os(iOS) || os(macOS) + .background { WakeControllerInput(waker: waker) } + #endif + } + } + + @ViewBuilder private func card(_ w: HostWaker.Waking) -> some View { + VStack(spacing: 14) { + if w.timedOut { + Image(systemName: "moon.zzz.fill") + .font(.system(size: 34)).foregroundStyle(.white.opacity(0.85)) + Text("\(w.hostName) didn't wake") + .font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white) + Text("It may still be booting, or it's powered off / off this network.") + .font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6)) + .multilineTextAlignment(.center) + HStack(spacing: 12) { + Button("Cancel") { waker.cancel() }.buttonStyle(.bordered) + Button("Try Again") { waker.retry() }.glassProminentButtonStyle() + } + .padding(.top, 6) + } else { + ProgressView().controlSize(.large).tint(.white) + Text("Waking \(w.hostName)…") + .font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white) + Text("Waiting for it to come online · \(w.seconds)s") + .font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6)) + .monospacedDigit() + Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() } + .buttonStyle(.bordered) + .padding(.top, 6) + } + } + } +} + +#if os(iOS) || os(macOS) +/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size +/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is +/// gated inactive while a wake is up, so nothing else is consuming the pad). +private struct WakeControllerInput: View { + @ObservedObject var waker: HostWaker + @State private var input = GamepadMenuInput(manager: .shared) + + var body: some View { + Color.clear + .onAppear { + input.onBack = { waker.cancel() } + input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } } + input.start() + } + .onDisappear { input.stop() } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 3b33efa..70133fa 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -18,23 +18,47 @@ struct ShotScene { @MainActor enum ShotScenes { - static let all: [ShotScene] = [ - ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) { - AnyView(ShotStreamHero()) - }, - ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) { - AnyView(ShotHome()) - }, - ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) { - AnyView(ShotPair()) - }, - ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) { - AnyView(ShotTrust()) - }, - ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) { - AnyView(ShotSettings()) - }, - ] + static var all: [ShotScene] { + var scenes: [ShotScene] = [ + ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) { + AnyView(ShotStreamHero()) + }, + ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) { + AnyView(ShotHome()) + }, + ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) { + AnyView(ShotPair()) + }, + ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) { + AnyView(ShotTrust()) + }, + ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) { + AnyView(ShotSettings()) + }, + ] + #if os(iOS) || os(macOS) + // The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots + // for eyeballing the Liquid Glass host tiles + settings rows. + scenes += [ + ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) { + AnyView(ShotGamepadHome()) + }, + ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) { + AnyView(ShotGamepadSettings()) + }, + ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) { + AnyView(ShotGamepadAddHost()) + }, + ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) { + AnyView(ShotWaking()) + }, + ] + #endif + scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) { + AnyView(ShotEditHost()) + }) + return scenes + } } // MARK: - Mock data @@ -75,7 +99,7 @@ private struct ShotHome: View { showAddHost: .constant(false), pairingTarget: .constant(nil), speedTestTarget: .constant(nil), libraryTarget: .constant(nil), connect: { _ in }, connectDiscovered: { _ in }, - onPaired: { _, _ in }, onLaunchTitle: { _, _ in }) + onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in }) #else HomeView( store: store, model: model, discovery: discovery, @@ -83,11 +107,77 @@ private struct ShotHome: View { speedTestTarget: .constant(nil), libraryTarget: .constant(nil), showSettings: .constant(false), connect: { _ in }, connectDiscovered: { _ in }, - onPaired: { _, _ in }, onLaunchTitle: { _, _ in }) + onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in }) #endif } } +// MARK: - Gamepad-mode console screens (dev-only glass preview) + +#if os(iOS) || os(macOS) +private struct ShotGamepadHome: View { + @StateObject private var store = ShotMock.hostStore() + @StateObject private var model = SessionModel() + @StateObject private var discovery = HostDiscovery() + @StateObject private var waker = HostWaker() + + var body: some View { + GamepadHomeView( + store: store, model: model, discovery: discovery, + libraryTarget: .constant(nil), waker: waker, + connect: { _ in }, connectDiscovered: { _ in }) + } +} + +private struct ShotGamepadSettings: View { + var body: some View { GamepadSettingsView() } +} + +private struct ShotGamepadAddHost: View { + var body: some View { GamepadAddHostView(onAdd: { _ in }) } +} + +private struct ShotWaking: View { + @StateObject private var store = ShotMock.hostStore() + @StateObject private var model = SessionModel() + @StateObject private var discovery = HostDiscovery() + @StateObject private var waker = HostWaker() + + var body: some View { + GamepadHomeView( + store: store, model: model, discovery: discovery, + libraryTarget: .constant(nil), waker: waker, + connect: { _ in }, connectDiscovered: { _ in } + ) + .overlay { WakeOverlay(waker: waker) } + .onAppear { + waker.debugSet(.init( + hostID: store.hosts.first?.id ?? UUID(), + hostName: "Battlestation", connectsAfter: true, seconds: 14)) + } + } +} +#endif + +// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field) + +private struct ShotEditHost: View { + var body: some View { + ZStack { + ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45)) + AddHostSheet( + existing: StoredHost( + name: "Battlestation", address: "192.168.1.20", port: 9777, + pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]), + onSave: { _ in }) + #if os(macOS) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 40, y: 16) + #endif + } + } +} + // MARK: - Settings private struct ShotSettings: View { diff --git a/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift b/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift new file mode 100644 index 0000000..46c2381 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift @@ -0,0 +1,112 @@ +// Wake a sleeping host and WAIT for it to come back before proceeding. +// +// A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start +// advertising on mDNS again — far longer than a connect attempt will sit. The old path fired a +// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible +// "Waking…" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second, +// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit +// wake-only); on timeout it parks in a retry/cancel state. One wake at a time. + +import Foundation +import PunktfunkKit +import SwiftUI + +@MainActor +final class HostWaker: ObservableObject { + struct Waking: Equatable { + let hostID: UUID + let hostName: String + /// Whether coming online chains into a connect (Wake & Connect) vs. just stopping. + let connectsAfter: Bool + var seconds = 0 + var timedOut = false + } + + /// nil = idle; non-nil drives `WakeOverlay`. + @Published private(set) var waking: Waking? + + /// How long to wait for the host to reappear before giving up. Generous — a cold boot + service + /// start can be a minute-plus. + private let timeoutSeconds = 90 + /// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh + /// packet after dropping into a deeper sleep state. + private let resendEverySeconds = 6 + + private var loop: Task? + /// Captured so "Try Again" replays the exact same wait. + private var replay: (() -> Void)? + + /// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target + /// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host + /// is already up (a race between the caller's check and here). + func start( + host: StoredHost, connectsAfter: Bool, + macs: [String], lastIP: String?, + isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void + ) { + guard !macs.isEmpty, !isOnline() else { + cancel() + onOnline() + return + } + replay = { [weak self] in + self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP, + isOnline: isOnline, onOnline: onOnline) + } + replay?() + } + + /// Stop waiting and dismiss the overlay (B / Cancel). + func cancel() { + loop?.cancel() + loop = nil + replay = nil + waking = nil + } + + /// Restart the wait after a timeout (A / Try Again). + func retry() { replay?() } + + private func run( + host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?, + isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void + ) { + loop?.cancel() + waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter) + let timeout = timeoutSeconds + let resend = resendEverySeconds + loop = Task { [weak self] in + var elapsed = 0 + while !Task.isCancelled { + if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) } + if isOnline() { + guard let self, !Task.isCancelled else { return } + self.waking = nil + self.loop = nil + onOnline() + return + } + if elapsed >= timeout { + self?.waking?.timedOut = true + self?.loop = nil + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + elapsed += 1 + self?.waking?.seconds = elapsed + } + } + } + + /// Blocking sends (see PunktfunkConnection.wakeOnLAN) — off the main thread. + private static func sendPacket(macs: [String], lastIP: String?) { + DispatchQueue.global(qos: .userInitiated).async { + PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP) + } + } + + #if DEBUG + /// Force a static waking state for the screenshot harness (no timers, no packets). + func debugSet(_ w: Waking) { waking = w } + #endif +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 5536d40..0eb6469 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -81,13 +81,17 @@ struct GamepadSettingsView: View { .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), ]) } - .padding(.leading, 22) + // Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView). + .padding(.leading, compact ? 12 : 18) .padding(.trailing, 22) - .padding(.vertical, compact ? 6 : 10) + .padding(.bottom, compact ? 12 : 18) + .padding(.top, compact ? 6 : 10) .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .bottom) } } - .background { GamepadScreenBackground() } + // No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the + // glass rows are the only material on the screen. + .background { GamepadFormBackground() } .onAppear { gamepads.refresh() gamepads.startDiscovery() @@ -148,13 +152,14 @@ struct GamepadSettingsView: View { } .padding(.horizontal, 16) .padding(.vertical, 13) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.white.opacity(focused ? 0.1 : 0)) - } + // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. + .consoleGlass( + RoundedRectangle(cornerRadius: 14, style: .continuous), + tint: focused ? Color.brand.opacity(0.30) : nil, + interactive: focused) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1) + .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) .animation(.smooth(duration: 0.18), value: focused) diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 81778d6..0bb8e03 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -98,6 +98,13 @@ final class HostStore: ObservableObject { hosts.removeAll { $0.id == host.id } } + /// Replace a saved host in place (the edit sheet) — matched by id, so identity/pin/last-connected + /// carried on the passed value are preserved. + func update(_ host: StoredHost) { + guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return } + hosts[i] = host + } + func markConnected(_ hostID: UUID) { guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } hosts[i].lastConnected = Date() diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index 2f3f467..d025159 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -67,3 +67,41 @@ extension View { modifier(GlassProminentButton()) } } + +// MARK: - Console glass (gamepad host tiles + settings rows) + +/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and +/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately +/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the +/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes +/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces +/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance. +private struct ConsoleGlass: ViewModifier { + let shape: S + var tint: Color? + var interactive = false + + func body(content: Content) -> some View { + if #available(iOS 26, macOS 26, tvOS 26, *) { + content.glassEffect(glass, in: shape) + } else { + content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } + } + } + + @available(iOS 26, macOS 26, tvOS 26, *) + private var glass: Glass { + var g: Glass = .regular + if let tint { g = g.tint(tint) } + if interactive { g = g.interactive() } + return g + } +} + +extension View { + /// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial` + /// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule. + func consoleGlass(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View { + modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive)) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 9f46a18..4238761 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -103,7 +103,7 @@ struct PairSheet: View { TextField( "PIN", text: $pin, prompt: Text("Shown in the host's web console")) - .font(.system(.title3, design: .monospaced)) + .font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3) #if os(iOS) .keyboardType(.numberPad) #endif @@ -134,6 +134,11 @@ struct PairSheet: View { } #if !os(tvOS) .formStyle(.grouped) + // Bring the grouped form's default system text down to the app's Geist scale so the sheet + // doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own + // explicit Geist Mono font. + .font(.geist(12, relativeTo: .callout)) + .controlSize(.small) #endif HStack { Button("Cancel", role: .cancel) {