e87fd42cee
Three client-UX changes that share the connect/present path (and settings files): - Connect/wake overlay is now mode-aware: the console/gamepad UI keeps the full-screen aurora takeover, while the default (touch/desktop) UI shows a Liquid Glass modal over the host grid — the takeover looked out of place there. - Add an auto-wake toggle (DefaultsKey.autoWake, default on) across macOS/iOS/tvOS Settings + the gamepad settings view; gate startSession/prepareWake and the gamepad "Wake & Connect" label on it. MAC-address learning stays always-on. - Windowed sessions now stream at the window's native pixels (Match-window default-on) so the picture is 1:1 pixel-exact instead of the presenter resampling a fixed-mode frame; fullscreen reports full-display px, also 1:1. Also lands the mid-resize aspect-fit tracking (decoded contentSize) that keeps the picture undistorted after a resize. swift build + swift test (121 tests) green; screenshot scenes verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
7.5 KiB
Swift
146 lines
7.5 KiB
Swift
// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the
|
|
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
|
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
|
|
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
|
|
//
|
|
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
|
|
// so a host that takes a beat to answer no longer looks like nothing happened.
|
|
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
|
|
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
|
|
// timeout.
|
|
//
|
|
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the
|
|
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
|
|
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
|
|
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
|
|
// looked out of place there.
|
|
//
|
|
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
|
|
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
|
|
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
struct ConnectOverlay: View {
|
|
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
|
|
/// passes nil here). Drives the "Connecting…" phase.
|
|
let connectingHostName: String?
|
|
@ObservedObject var waker: HostWaker
|
|
/// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid
|
|
/// Glass modal.
|
|
var gamepadUI: Bool
|
|
/// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late
|
|
/// result is discarded by SessionModel's connect guard.
|
|
var onCancelConnect: () -> Void
|
|
|
|
private enum Phase {
|
|
case connecting(name: String)
|
|
case waking(HostWaker.Waking)
|
|
}
|
|
|
|
/// Waking takes precedence — it only ever exists after a dial has already failed, so a stray
|
|
/// overlap can't strand the "Connecting…" phase over a wake in progress.
|
|
private var phase: Phase? {
|
|
if let w = waker.waking { return .waking(w) }
|
|
if let name = connectingHostName { return .connecting(name: name) }
|
|
return nil
|
|
}
|
|
|
|
var body: some View {
|
|
if let phase {
|
|
ZStack {
|
|
if gamepadUI {
|
|
// Console: an opaque, living aurora over everything.
|
|
Color.black.ignoresSafeArea()
|
|
GamepadScreenBackground().ignoresSafeArea()
|
|
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
|
content(phase).padding(40).frame(maxWidth: 460)
|
|
} else {
|
|
// Default UI: a Liquid Glass modal over a dim scrim.
|
|
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
|
|
.contentShape(Rectangle()).onTapGesture {}
|
|
content(phase)
|
|
.padding(28)
|
|
.frame(maxWidth: 380)
|
|
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
|
.padding(40)
|
|
}
|
|
}
|
|
.environment(\.colorScheme, .dark)
|
|
.transition(.opacity)
|
|
#if os(iOS) || os(macOS)
|
|
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
|
#endif
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
|
// The takeover carries larger type than the compact modal.
|
|
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
|
let bodySize: CGFloat = gamepadUI ? 14 : 13
|
|
VStack(spacing: gamepadUI ? 16 : 14) {
|
|
switch phase {
|
|
case .connecting(let name):
|
|
ProgressView().controlSize(.large).tint(.white)
|
|
Text("Connecting to \(name)")
|
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
.multilineTextAlignment(.center)
|
|
Text("Establishing a secure connection…")
|
|
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
|
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
|
case .waking(let w) where w.timedOut:
|
|
Image(systemName: "moon.zzz.fill")
|
|
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
|
Text("\(w.hostName) didn't wake")
|
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
.multilineTextAlignment(.center)
|
|
Text("It may still be booting, or it's powered off / off this network.")
|
|
.font(.geist(bodySize, 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)
|
|
case .waking(let w):
|
|
ProgressView().controlSize(.large).tint(.white)
|
|
Text("Waking \(w.hostName)…")
|
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
.multilineTextAlignment(.center)
|
|
Text("Waiting for it to come online · \(w.seconds)s")
|
|
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
|
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
|
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 whatever's in flight (a dial or the wake wait); A
|
|
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
|
|
/// correct across the Connecting ↔ Waking handoff without the view re-mounting. A zero-size backing
|
|
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
|
|
/// overlay is up, so nothing else is consuming the pad).
|
|
private struct ConnectControllerInput: View {
|
|
@ObservedObject var waker: HostWaker
|
|
var onCancelConnect: () -> Void
|
|
@State private var input = GamepadMenuInput(manager: .shared)
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.onAppear {
|
|
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
|
|
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
|
input.start()
|
|
}
|
|
.onDisappear { input.stop() }
|
|
}
|
|
}
|
|
#endif
|