diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt new file mode 100644 index 00000000..eecd8de0 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -0,0 +1,267 @@ +package io.unom.punktfunk + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Which phase of the connect takeover to draw — the pure view model [ConnectOverlay] resolves from the + * live dial/wake state, so [ConnectTakeover] can render (and be screenshot-tested) statelessly. + */ +internal sealed interface ConnectPhase { + val hostName: String + + /** The dial is in flight (shown the instant a host is picked). */ + data class Connecting(override val hostName: String) : ConnectPhase + + /** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */ + data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase + + /** The wake wait ran out — offer retry / cancel. */ + data class WakeTimedOut(override val hostName: String) : ConnectPhase +} + +/** + * The unified full-screen "getting you connected" takeover — 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: + * + * - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap, + * so a host that takes a beat to answer no longer looks like nothing happened. + * - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing + * Wake-on-LAN and waiting for it to advertise again (the old standalone `WakeOverlay`'s job), + * escalating to a retry/cancel prompt on timeout. + * + * The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` → + * `waker.start` → redial), so the overlay never blinks between them. It replaces both the touch grid's + * tiny inline "Connecting…" row and the old centred "Waking…" card with one opaque, aurora-backed + * screen — the same signature backdrop the console UI uses, so it reads as a deliberate takeover. + * + * Rendered over BOTH the touch grid and the console home; it swallows input to the screen behind it, + * and in console mode the pad drives it (B cancels via the BackHandler, A retries once a wake has timed + * out) with a hint bar spelling that out, while the touch buttons work for a pointer either way. + */ +@Composable +fun ConnectOverlay( + connectingHostName: String?, + waker: WakeController, + gamepadUi: Boolean, + onCancelConnect: () -> Unit, +) { + val waking = waker.waking + // Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand + // the "Connecting…" phase over a wake in progress. + val phase = when { + waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName) + waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter) + connectingHostName != null -> ConnectPhase.Connecting(connectingHostName) + else -> return + } + + // System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait. + val cancel = { if (waking != null) waker.cancel() else onCancelConnect() } + BackHandler { cancel() } + if (gamepadUi) { + // A retries once a wake has timed out; B falls through to the BackHandler above. + GamepadNavEffect2D( + active = true, + onDirection = {}, + onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() }, + ) + } + + ConnectTakeover(phase = phase, gamepadUi = gamepadUi, onCancel = cancel, onRetry = { waker.retry() }) +} + +/** + * The stateless view of the connect takeover: an opaque aurora backdrop with a centred spinner/title/ + * subtitle for [phase], plus its actions (touch pills, or a console hint bar in [gamepadUi] mode). + */ +@Composable +internal fun ConnectTakeover( + phase: ConnectPhase, + gamepadUi: Boolean, + onCancel: () -> Unit, + onRetry: () -> Unit, +) { + val timedOut = phase is ConnectPhase.WakeTimedOut + // Copy per phase. Titles lead with the verb + host (parallel across phases); the waking counter + // gets a monospace subtitle so its width doesn't jitter as the seconds tick. + val title: String + val subtitle: String + val monoSubtitle: Boolean + when (phase) { + is ConnectPhase.Connecting -> { + title = "Connecting to ${phase.hostName}" + subtitle = "Establishing a secure connection…" + monoSubtitle = false + } + is ConnectPhase.Waking -> { + title = "Waking ${phase.hostName}…" + subtitle = "Waiting for it to come online · ${phase.seconds}s" + monoSubtitle = true + } + is ConnectPhase.WakeTimedOut -> { + title = "${phase.hostName} didn't wake" + subtitle = "It may still be booting, or it's powered off / off this network." + monoSubtitle = false + } + } + // A wake-only wait (no dial after) says "Stop Waiting"; every other phase is a plain "Cancel". + val cancelLabel = if (phase is ConnectPhase.Waking && !phase.connectsAfter) "Stop Waiting" else "Cancel" + + Box( + Modifier + .fillMaxSize() + // Swallow taps so the screen behind can't be touched through the takeover. + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + contentAlignment = Alignment.Center, + ) { + GamepadAuroraBackground(Modifier.fillMaxSize()) + Column( + Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + if (timedOut) { + Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) { + Icon( + Icons.Filled.Bedtime, + contentDescription = null, + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(46.dp), + ) + } + } else { + PulsingSpinner() + } + Text( + title, + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + textAlign = TextAlign.Center, + ) + Text( + subtitle, + color = Color.White.copy(alpha = 0.65f), + fontSize = 14.sp, + textAlign = TextAlign.Center, + fontFamily = if (monoSubtitle) FontFamily.Monospace else FontFamily.Default, + ) + // Touch: real tappable pills. Console: the bottom hint bar owns the actions instead, so the + // look stays glyph-driven like every other console screen. + if (!gamepadUi) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + OverlayButton(cancelLabel, primary = false, onClick = onCancel) + if (timedOut) OverlayButton("Try Again", primary = true, onClick = onRetry) + } + } + } + if (gamepadUi) { + // B cancels, A retries (only once timed out). onClick keeps the cells tappable too, so a + // user without a working pad can still get out. + val hints = buildList { + add(PadGlyph.hint('B', cancelLabel, onClick = onCancel)) + if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry)) + } + GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp)) + } + } +} + +/** + * The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that + * expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not + * stalled. + */ +@Composable +private fun PulsingSpinner() { + val transition = rememberInfiniteTransition(label = "connectPulse") + val pulse by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart), + label = "pulse", + ) + Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val maxR = size.minDimension / 2f + for (i in 0..1) { + val p = (pulse + i * 0.5f) % 1f + drawCircle( + color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f), + radius = maxR * (0.42f + p * 0.58f), + style = Stroke(width = 2.dp.toPx()), + ) + } + } + CircularProgressIndicator( + color = Color.White, + strokeWidth = 3.dp, + modifier = Modifier.size(54.dp), + ) + } +} + +/** A pill action button styled for the dark aurora — filled violet when [primary], else glassy. */ +@Composable +private fun OverlayButton(label: String, primary: Boolean, onClick: () -> Unit) { + val shape = RoundedCornerShape(14.dp) + Box( + Modifier + .clip(shape) + .background(if (primary) Color(0xFF6656F2) else Color(0x1FFFFFFF)) + .border(1.dp, Color.White.copy(alpha = if (primary) 0f else 0.18f), shape) + .clickable(onClick = onClick) + .padding(horizontal = 26.dp, vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 9b8f9ff3..62b6a458 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) { val cancelled = AtomicBoolean(false) } +/** + * A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and + * [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel + * returns the UI immediately and a late-arriving handle is torn down silently rather than navigating + * into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling. + */ +private class ConnectAttempt(val hostName: String) { + val cancelled = AtomicBoolean(false) +} + @Composable fun ConnectScreen( settings: Settings, @@ -107,6 +117,9 @@ fun ConnectScreen( var port by remember { mutableStateOf("9777") } var connecting by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } + // A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null + // when idle or when the request-access / wake flows own the screen instead. + var attempt by remember { mutableStateOf(null) } // The host streams at exactly this mode; "Native" settings resolve from the device display. val (w, h, hz) = settings.effectiveMode(context) @@ -267,11 +280,20 @@ fun ConnectScreen( status = "Identity not ready yet — try again in a moment" return } + val thisAttempt = ConnectAttempt(name) + attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately connecting = true - status = "Connecting to $targetHost:$targetPort…" + status = null discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) + // Cancelled mid-dial: the UI's already been returned (and discovery restarted) by + // cancelConnect — drop the just-opened session silently rather than navigating into it. + if (thisAttempt.cancelled.get()) { + if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) } + return@launch + } + attempt = null connecting = false if (handle != 0L) { if (pinHex == null) { // TOFU: pin what we observed (unpaired) @@ -284,7 +306,9 @@ fun ConnectScreen( } else { discovery.start() if (onFailure != null) { - status = "" + // Hand off to the wake-and-wait flow — clearing `attempt` above and setting + // `waker.waking` here land in one recompose, so the overlay slides + // Connecting → Waking without a blank frame. onFailure() } else { status = "Connection failed — check host/port, PIN, and logcat" @@ -293,6 +317,16 @@ fun ConnectScreen( } } + // Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native + // connect can't be aborted, so flag this attempt (a late handle is closed silently in + // doConnectDirect) and return the UI now, resuming the discovery we paused for the dial. + fun cancelConnect() { + attempt?.cancelled?.set(true) + attempt = null + connecting = false + discovery.start() + } + // Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved // host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL // IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network — @@ -506,40 +540,21 @@ fun ConnectScreen( Spacer(Modifier.height(24.dp)) status?.let { - // While connecting it's progress (spinner, neutral); otherwise it's a - // result/error (red). Previously every status showed in error-red, so a - // normal "Connecting…" looked like a failure. - if (connecting) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - // Result/error: a filled error container reads as a real failure banner, - // not just red text lost in the layout. - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } + // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's + // job now, so `status` only ever carries a result/error here — a filled error + // container reads as a real failure banner, not just red text lost in the layout. + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) } Spacer(Modifier.height(16.dp)) } @@ -837,8 +852,15 @@ fun ConnectScreen( } } - // Topmost: the "Waking…" overlay rides over both the touch grid and the console home. - WakeOverlay(waker, gamepadUi) + // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing + // seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch + // grid and the console home. + ConnectOverlay( + connectingHostName = attempt?.hostName, + waker = waker, + gamepadUi = gamepadUi, + onCancelConnect = { cancelConnect() }, + ) } /** diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt index 070d62a3..38cc170b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.launch * [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded. */ class WakeController(private val scope: CoroutineScope) { - /** null = idle; non-null drives [WakeOverlay]. */ + /** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */ data class Waking( val hostName: String, /** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt deleted file mode 100644 index e91292e7..00000000 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt +++ /dev/null @@ -1,124 +0,0 @@ -package io.unom.punktfunk - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -/** - * The "Waking …" modal shown while [WakeController] brings a sleeping host back — a spinner + a - * live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the - * Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows - * input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once - * timed out) while the touch buttons work for a pointer. - */ -@Composable -fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) { - val w = waker.waking ?: return - - BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait - if (gamepadUi) { - // A retries once timed out; B falls through to the BackHandler above. - GamepadNavEffect2D( - active = true, - onDirection = {}, - onActivate = { if (w.timedOut) waker.retry() }, - ) - } - - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)) - // Swallow taps so the home behind can't be touched while waking. - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, - contentAlignment = Alignment.Center, - ) { - Column( - Modifier - .padding(40.dp) - .widthIn(max = 380.dp) - .clip(RoundedCornerShape(22.dp)) - .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp)) - .padding(28.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - if (w.timedOut) { - Icon( - Icons.Filled.Bedtime, - contentDescription = null, - tint = Color.White.copy(alpha = 0.85f), - modifier = Modifier.size(34.dp), - ) - Text( - "${w.hostName} didn't wake", - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 19.sp, - textAlign = TextAlign.Center, - ) - Text( - "It may still be booting, or it's powered off / off this network.", - color = Color.White.copy(alpha = 0.6f), - fontSize = 13.sp, - textAlign = TextAlign.Center, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(top = 6.dp), - ) { - OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") } - Button(onClick = { waker.retry() }) { Text("Try Again") } - } - } else { - CircularProgressIndicator(color = Color.White) - Text( - "Waking ${w.hostName}…", - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 19.sp, - textAlign = TextAlign.Center, - ) - Text( - "Waiting for it to come online · ${w.seconds}s", - color = Color.White.copy(alpha = 0.6f), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - ) - OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) { - Text(if (w.connectsAfter) "Cancel" else "Stop Waiting") - } - } - } - } -} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index b7b3b47c..749549b3 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -68,6 +68,18 @@ class ScreenshotTest { @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } + @Test + fun connecting() = shootRoot("connecting") { ConnectingScene() } + + @Test + fun waking() = shootRoot("waking") { WakingScene() } + + @Test + fun wakeTimedOut() = shootRoot("wake-timed-out") { WakeTimedOutScene() } + + @Test + fun connectingConsole() = shootRoot("connecting-console") { ConnectingScene(gamepadUi = true) } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 16b463c5..ffb55a83 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.unom.punktfunk.BrandDark +import io.unom.punktfunk.ConnectPhase +import io.unom.punktfunk.ConnectTakeover import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsScreen @@ -215,3 +217,23 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) { ) } } + +/** + * The unified full-screen connect takeover (the real [ConnectTakeover]) in each phase — instant + * "Connecting…" feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUi = true` + * renders the console variant with its bottom hint bar instead of touch pills. + */ +@Composable +internal fun ConnectingScene(gamepadUi: Boolean = false) = + ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) + +@Composable +internal fun WakingScene(gamepadUi: Boolean = false) = + ConnectTakeover( + ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true), + gamepadUi, onCancel = {}, onRetry = {}, + ) + +@Composable +internal fun WakeTimedOutScene(gamepadUi: Boolean = false) = + ConnectTakeover(ConnectPhase.WakeTimedOut("Living Room PC"), gamepadUi, onCancel = {}, onRetry = {}) diff --git a/clients/apple/Config/Punktfunk.entitlements b/clients/apple/Config/Punktfunk.entitlements index 7ef8c245..bee84560 100644 --- a/clients/apple/Config/Punktfunk.entitlements +++ b/clients/apple/Config/Punktfunk.entitlements @@ -14,19 +14,11 @@ - com.apple.developer.networking.multicast - --> diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 849b7b72..56b16525 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -60,7 +60,8 @@ struct ContentView: View { @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. + /// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS + /// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable). @StateObject private var waker = HostWaker() #if os(macOS) /// Whether the hosting window is native-fullscreen right now (reported by @@ -259,9 +260,25 @@ 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) } + // The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is + // still `home`, so it covers the whole dial → wake → online → connect sequence): instant + // "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host + // turns out to be asleep. + homeBase.overlay { + ConnectOverlay( + connectingHostName: connectingOverlayName, + waker: waker, + onCancelConnect: { model.disconnect() }) + } + } + + /// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil + /// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the + /// takeover must not stack over it) and, of course, when idle or streaming. + private var connectingOverlayName: String? { + guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost + else { return nil } + return host.displayName } @ViewBuilder private var homeBase: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift new file mode 100644 index 00000000..58c3e469 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -0,0 +1,122 @@ +// The unified full-screen "getting you connected" takeover — 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. +// +// 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. Presented over BOTH +// the touch and gamepad home; it swallows input to the screen behind it, and on iOS/macOS the pad +// drives it (B cancels, A retries once timed out) while the buttons work for a pointer / tvOS focus. + +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 + /// 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 { + // Opaque aurora — the same living backdrop the console home wears, so the takeover + // reads as a deliberate full-screen moment rather than a card popping up. + Color.black.ignoresSafeArea() + GamepadScreenBackground().ignoresSafeArea() + // Swallow taps so the home behind can't be touched through the takeover. + Color.clear.contentShape(Rectangle()).onTapGesture {} + content(phase).padding(40).frame(maxWidth: 460) + } + .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 { + VStack(spacing: 16) { + switch phase { + case .connecting(let name): + ProgressView().controlSize(.large).tint(.white) + Text("Connecting to \(name)") + .font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .multilineTextAlignment(.center) + Text("Establishing a secure connection…") + .font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65)) + Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6) + case .waking(let w) where w.timedOut: + Image(systemName: "moon.zzz.fill") + .font(.system(size: 40)).foregroundStyle(.white.opacity(0.9)) + Text("\(w.hostName) didn't wake") + .font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .multilineTextAlignment(.center) + Text("It may still be booting, or it's powered off / off this network.") + .font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65)) + .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(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .multilineTextAlignment(.center) + Text("Waiting for it to come online · \(w.seconds)s") + .font(.geistFixed(14)).foregroundStyle(.white.opacity(0.65)).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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index ef44fd44..b984a8d2 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -192,9 +192,12 @@ struct GamepadHomeView: View { onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, - // 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 + // Stop consuming the controller while another screen (or the connect/wake takeover) is on + // top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad), + // and a second A during a dial would launch a concurrent connect. `.connecting` covers the + // takeover's Connecting phase; `waker.waking` covers its Waking phase. + isActive: libraryTarget == nil && !showSettings && !showAddHost + && waker.waking == nil && model.phase != .connecting ) { tile in hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift deleted file mode 100644 index 4be79f96..00000000 --- a/clients/apple/Sources/PunktfunkClient/Home/WakeOverlay.swift +++ /dev/null @@ -1,84 +0,0 @@ -// 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 70133fa3..f1d07d01 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -49,8 +49,14 @@ enum ShotScenes { ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) { AnyView(ShotGamepadAddHost()) }, - ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) { - AnyView(ShotWaking()) + ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .connecting)) + }, + ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .waking)) + }, + ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .timedOut)) }, ] #endif @@ -137,7 +143,12 @@ private struct ShotGamepadAddHost: View { var body: some View { GamepadAddHostView(onAdd: { _ in }) } } -private struct ShotWaking: View { +/// The unified full-screen connect takeover (the real `ConnectOverlay`) in each phase — instant +/// "Connecting…" feedback, the "Waking…" wait, and the wake-timed-out prompt — over the gamepad home. +private struct ShotConnect: View { + enum Kind { case connecting, waking, timedOut } + let kind: Kind + @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() @StateObject private var discovery = HostDiscovery() @@ -149,11 +160,25 @@ private struct ShotWaking: View { libraryTarget: .constant(nil), waker: waker, connect: { _ in }, connectDiscovered: { _ in } ) - .overlay { WakeOverlay(waker: waker) } + .overlay { + ConnectOverlay( + connectingHostName: kind == .connecting ? "Battlestation" : nil, + waker: waker, + onCancelConnect: {}) + } .onAppear { - waker.debugSet(.init( - hostID: store.hosts.first?.id ?? UUID(), - hostName: "Battlestation", connectsAfter: true, seconds: 14)) + switch kind { + case .connecting: + break + case .waking: + waker.debugSet(.init( + hostID: store.hosts.first?.id ?? UUID(), + hostName: "Battlestation", connectsAfter: true, seconds: 14)) + case .timedOut: + waker.debugSet(.init( + hostID: store.hosts.first?.id ?? UUID(), + hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true)) + } } } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift b/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift index 46c23819..5fdb30f3 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift @@ -22,7 +22,7 @@ final class HostWaker: ObservableObject { var timedOut = false } - /// nil = idle; non-nil drives `WakeOverlay`. + /// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`. @Published private(set) var waking: Waking? /// How long to wait for the host to reappear before giving up. Generous — a cold boot + service diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 6975fa30..7a8e1195 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -70,19 +70,10 @@ func withOptionalCString(_ s: String?, _ body: (UnsafePointer?) -> R) public extension PunktfunkConnection { /// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always /// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed - /// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's - /// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is - /// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action. - /// The MAC-learning path stays active on every platform, so flipping this on once the - /// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to - /// `true` for iOS/tvOS too (and uncomment the entitlement). - static var wakeOnLANAvailable: Bool { - #if os(macOS) - return true - #else - return false - #endif - } + /// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see + /// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single + /// switch every call site gates on, should a future build ever need to disable it. + static var wakeOnLANAvailable: Bool { true } /// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s) /// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index b755b8e2..b417bd81 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -19,7 +19,9 @@ use anyhow::{anyhow, Result}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo}; use pf_client_core::trust; use pf_presenter::overlay::OverlayAction; -use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect}; +use skia_safe::{ + gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode, +}; use std::collections::VecDeque; use std::time::Instant; @@ -240,6 +242,11 @@ impl Shell { self.wake = None; if let Some(Some(intent)) = intent { self.start_connect(intent); + // The wake takeover was already full-screen; skip the connect fade-in so the + // Waking → Connecting handoff is seamless (no flash of the home behind). + if let Some(c) = &mut self.connecting { + c.appear = 1.0; + } } } } @@ -607,77 +614,68 @@ impl Shell { t: f64, fonts: &Fonts, ) { - if let Some(c) = &mut self.connecting { - c.appear = approach(c.appear, 1.0, dt, 0.07); - let a = c.appear; - canvas.draw_rect( - Rect::from_wh(w as f32, h as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, (0.45 * a) as f32), None), - ); - let title = if c.canceling { - "Canceling…".to_string() + // Resolve the connect/wake takeover — the two phases of reaching a host — into one + // full-screen shape (spinner, title, one detail line, its own hints). Connecting flows + // straight out of a wake (see `sync`) so they share the same backdrop and never blink + // between them. Mirrors the Android client's unified `ConnectOverlay`. + let takeover: Option<(f64, bool, String, String, Vec)> = + if let Some(c) = &mut self.connecting { + c.appear = approach(c.appear, 1.0, dt, 0.07); + if c.canceling { + Some(( + c.appear, + true, + "Canceling…".to_string(), + String::new(), + vec![], + )) + } else { + Some(( + c.appear, + true, + format!("Connecting to {}…", c.title), + "Starting the stream in this window.".to_string(), + vec![Hint::new(HintKey::Back, "Cancel")], + )) + } + } else if let Some(wk) = &self.wake { + // Service-driven, so it appears settled (no fade-in). + if wk.timed_out { + Some(( + 1.0, + false, + format!("{} didn't wake", wk.name), + "Check its power settings, or wake it manually and try again.".to_string(), + vec![ + Hint::new(HintKey::Confirm, "Try Again"), + Hint::new(HintKey::Back, "Cancel"), + ], + )) + } else { + Some(( + 1.0, + true, + format!("Waking {}…", wk.name), + format!("Waiting for it to come online · {} s", wk.seconds), + // A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect + // is a plain "Cancel". + vec![Hint::new( + HintKey::Back, + if wk.then_connect { + "Cancel" + } else { + "Stop Waiting" + }, + )], + )) + } } else { - format!("Connecting to {}…", c.title) + None }; - let hints = if c.canceling { - vec![] - } else { - vec![Hint::new(HintKey::Back, "Cancel")] - }; - card( - canvas, - fonts, - w, - h, - k, - a, - t, - self.glyphs, - true, - &title, - "Starting the stream in this window.", - &hints, + if let Some((appear, spinner, title, body, hints)) = takeover { + self.draw_takeover( + canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints, ); - } else if let Some(wk) = &self.wake { - let a = 1.0; // the wake card is service-driven; it appears settled - canvas.draw_rect( - Rect::from_wh(w as f32, h as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.45), None), - ); - if wk.timed_out { - card( - canvas, - fonts, - w, - h, - k, - a, - t, - self.glyphs, - false, - &format!("{} didn't wake", wk.name), - "Check its power settings, or wake it manually and try again.", - &[ - Hint::new(HintKey::Confirm, "Try Again"), - Hint::new(HintKey::Back, "Cancel"), - ], - ); - } else { - card( - canvas, - fonts, - w, - h, - k, - a, - t, - self.glyphs, - true, - &format!("Waking {}…", wk.name), - &format!("Waiting for it to come online · {} s", wk.seconds), - &[Hint::new(HintKey::Back, "Cancel")], - ); - } } // The toast: a transient pill above the hint bar; slides in, fades out. @@ -799,70 +797,91 @@ impl LayerEnv<'_> { } } -/// A centered modal card: spinner (or not), a title, one detail line, and its own hint -/// row — the connect/wake overlays share this one shape. -#[allow(clippy::too_many_arguments)] -fn card( - canvas: &Canvas, - fonts: &Fonts, - w: f64, - h: f64, - k: f64, - appear: f64, - t: f64, - glyphs: GlyphStyle, - spinner: bool, - title: &str, - body: &str, - hints: &[Hint], -) { - let cw = (440.0 * k).min(w * 0.86); - let ch = 190.0 * k; - let cx = w / 2.0; - let top = h / 2.0 - ch / 2.0 + (1.0 - appear) * 14.0 * k; - canvas.save_layer_alpha_f(None, appear as f32); - let rect = Rect::from_xywh((cx - cw / 2.0) as f32, top as f32, cw as f32, ch as f32); - crate::theme::drop_shadow(canvas, rect, 22.0, k as f32, 0.5); - crate::theme::panel( - canvas, - rect, - 22.0, - Some(Color4f::new(0.07, 0.06, 0.12, 0.85)), - PanelStroke::Plain(0.14), - k as f32, - ); - let mut y = top + 44.0 * k; - if spinner { - crate::theme::spinner(canvas, cx, y, 14.0 * k, t); - y += 34.0 * k; - } else { - y += 6.0 * k; - } - fonts.centered(canvas, title, W::SemiBold, 19.0 * k, WHITE, cx, y, cw * 0.9); - fonts.centered( - canvas, - body, - W::Regular, - 13.0 * k, - DIM, - cx, - y + 30.0 * k, - cw * 0.86, - ); - if !hints.is_empty() { - // Centered inside the card's bottom band. - let probe = hint_bar(canvas, fonts, hints, glyphs, -10_000.0, -10_000.0, k); - hint_bar( +impl Shell { + /// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and + /// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one + /// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home; + /// a wake that hands off to a connect passes 1.0 so the two never blink between them. The + /// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a + /// centered modal card. + #[allow(clippy::too_many_arguments)] + fn draw_takeover( + &self, + canvas: &Canvas, + w: f64, + h: f64, + k: f64, + appear: f64, + t: f64, + fonts: &Fonts, + spinner: bool, + title: &str, + body: &str, + hints: &[Hint], + ) { + let cx = w / 2.0; + canvas.save_layer_alpha_f(None, appear as f32); + // Opaque aurora — the same living backdrop the home wears, so the takeover reads as the + // console taking over rather than a card popping up. + self.draw_aurora(canvas, w, h, t); + // A soft pool of shade under the centre seats the white text against a bright aurora. + let mut vignette = Paint::default(); + vignette.set_shader(gradient_shader::radial( + Point::new(cx as f32, (h / 2.0) as f32), + (w.max(h) * 0.42) as f32, + gradient_shader::GradientShaderColors::Colors(&[ + Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(), + Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), + ]), + None, + TileMode::Clamp, + None, + None, + )); + canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette); + + // Centre the spinner + title + detail as a group around the middle of the screen. + let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 }; + if spinner { + crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t); + } + fonts.centered( canvas, - fonts, - hints, - glyphs, - cx - probe.0 / 2.0, - top + ch - 16.0 * k, - k, + title, + W::SemiBold, + 23.0 * k, + WHITE, + cx, + title_y, + w * 0.82, ); + if !body.is_empty() { + fonts.centered( + canvas, + body, + W::Regular, + 14.0 * k, + DIM, + cx, + title_y + 32.0 * k, + w * 0.66, + ); + } + if !hints.is_empty() { + // Centered near the bottom, where every console screen's legend sits. + let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k); + hint_bar( + canvas, + fonts, + hints, + self.glyphs, + cx - probe.0 / 2.0, + h - 34.0 * k, + k, + ); + } + canvas.restore(); } - canvas.restore(); } #[cfg(test)] @@ -1154,6 +1173,15 @@ mod tests { then_connect: true, })); dump(&mut s, 10, 8, "08-waking", true); + console.set_wake(Some(WakeStatus { + key: "bb22".into(), + name: "Office Tower".into(), + seconds: 90, + timed_out: true, + online: false, + then_connect: true, + })); + dump(&mut s, 10, 8, "08b-wake-timed-out", true); console.set_wake(None); s.set_connecting(Some("Elden Ring".into())); dump(&mut s, 10, 8, "09-connecting", true);