feat(clients): unified full-screen connect/wake takeover + iOS/tvOS Wake-on-LAN
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
android / android (push) Successful in 12m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m4s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 5m57s
deb / build-publish (push) Successful in 13m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m50s
apple / swift (push) Successful in 4m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 21m27s
ci / rust (push) Failing after 6m47s
apple / screenshots (push) Successful in 20m18s

Give instant feedback the moment a host is picked, and make the wake wait a
full-screen takeover instead of a modal card — unified into one ConnectOverlay
across every client:

- android: new ConnectOverlay (aurora backdrop; Connecting / Waking / timed-out
  phases) replaces the tiny inline "Connecting…" row and the WakeOverlay card.
  The dial phase is now cancelable and hands off to the wake wait in one frame.
- console (pf-console-ui): the connect/wake overlays become a full-screen aurora
  takeover (draw_takeover) instead of a centered card over a dim scrim; the
  Waking → Connecting handoff no longer blinks.
- apple: new ConnectOverlay mirrors it (macOS / iOS / tvOS), replacing the
  per-tile connecting spinner + the WakeOverlay card; instant "Connecting…" from
  model.phase, and the carousel is gated inactive during the dial.

Also enable Wake-on-LAN on iOS/tvOS now that the multicast entitlement is
approved: enable com.apple.developer.networking.multicast and flip
wakeOnLANAvailable to true on every platform (MACs were already learned from
mDNS, so wake works immediately).

Verified: Android compileDebugKotlin + screenshot renders; console clippy +
36 tests + rendered phases on Linux; Apple swift build + 121 tests + rendered
phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:28:27 +02:00
parent 2271f67202
commit dd02e1f402
15 changed files with 710 additions and 417 deletions
@@ -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,
)
}
}
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
val cancelled = AtomicBoolean(false) 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 @Composable
fun ConnectScreen( fun ConnectScreen(
settings: Settings, settings: Settings,
@@ -107,6 +117,9 @@ fun ConnectScreen(
var port by remember { mutableStateOf("9777") } var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) } var status by remember { mutableStateOf<String?>(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<ConnectAttempt?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display. // The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context) val (w, h, hz) = settings.effectiveMode(context)
@@ -267,11 +280,20 @@ fun ConnectScreen(
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
return return
} }
val thisAttempt = ConnectAttempt(name)
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
connecting = true connecting = true
status = "Connecting to $targetHost:$targetPort" status = null
discovery.stop() // free the Wi-Fi radio before the stream session discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch { scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) 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 connecting = false
if (handle != 0L) { if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed (unpaired) if (pinHex == null) { // TOFU: pin what we observed (unpaired)
@@ -284,7 +306,9 @@ fun ConnectScreen(
} else { } else {
discovery.start() discovery.start()
if (onFailure != null) { 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() onFailure()
} else { } else {
status = "Connection failed — check host/port, PIN, and logcat" 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 // 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 // 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 — // IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
@@ -506,27 +540,9 @@ fun ConnectScreen(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
status?.let { status?.let {
// While connecting it's progress (spinner, neutral); otherwise it's a // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// result/error (red). Previously every status showed in error-red, so a // job now, so `status` only ever carries a result/error here — a filled error
// normal "Connecting…" looked like a failure. // container reads as a real failure banner, not just red text lost in the layout.
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( Surface(
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
@@ -540,7 +556,6 @@ fun ConnectScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
) )
} }
}
Spacer(Modifier.height(16.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. // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
WakeOverlay(waker, gamepadUi) // 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() },
)
} }
/** /**
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded. * [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
*/ */
class WakeController(private val scope: CoroutineScope) { 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( data class Waking(
val hostName: String, val hostName: String,
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */ /** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
@@ -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 <host>…" 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")
}
}
}
}
}
@@ -68,6 +68,18 @@ class ScreenshotTest {
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } 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 @Test
fun trust() = shootScreen("trust") { fun trust() = shootScreen("trust") {
HostsScene() HostsScene()
@@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.Settings import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen 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 = {})
+4 -12
View File
@@ -14,19 +14,11 @@
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP <!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
broadcast/multicast addresses unless the app carries this managed entitlement — it must broadcast/multicast addresses unless the app carries this managed entitlement — it must
be requested from and approved by Apple for the App ID, then enabled in the provisioning be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
profile. macOS is not gated by this (its App Sandbox network.client/server cover it). gated by this (its App Sandbox network.client/server cover it), hence its separate file.
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
GATED pending Apple's approval of the request (form filed) — an unauthorized managed is true on iOS/tvOS too. -->
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
file, no multicast entitlement needed). -->
<!--
<key>com.apple.developer.networking.multicast</key> <key>com.apple.developer.networking.multicast</key>
<true/> <true/>
-->
</dict> </dict>
</plist> </plist>
@@ -60,7 +60,8 @@ struct ContentView: View {
@State private var speedTestTarget: StoredHost? @State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost? @State private var libraryTarget: StoredHost?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the /// 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() @StateObject private var waker = HostWaker()
#if os(macOS) #if os(macOS)
/// Whether the hosting window is native-fullscreen right now (reported by /// Whether the hosting window is native-fullscreen right now (reported by
@@ -259,9 +260,25 @@ struct ContentView: View {
} }
private var home: some View { private var home: some View {
// The "Waking" overlay rides over BOTH home UIs (and the pre-connect window is still // The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
// `home`, so it covers the whole wakeonlineconnect sequence). // still `home`, so it covers the whole dial wake online connect sequence): instant
homeBase.overlay { WakeOverlay(waker: waker) } // "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 { @ViewBuilder private var homeBase: some View {
@@ -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
@@ -192,9 +192,12 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() }, onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() }, onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true }, onTertiary: { showSettings = true },
// Stop consuming the controller while another screen (or the wake overlay) is on top // Stop consuming the controller while another screen (or the connect/wake takeover) is on
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad). // top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil // 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 ) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
} }
@@ -1,84 +0,0 @@
// The "Waking <host>" 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
@@ -49,8 +49,14 @@ enum ShotScenes {
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) { ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadAddHost()) AnyView(ShotGamepadAddHost())
}, },
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) { ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
AnyView(ShotWaking()) 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 #endif
@@ -137,7 +143,12 @@ private struct ShotGamepadAddHost: View {
var body: some View { GamepadAddHostView(onAdd: { _ in }) } 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 store = ShotMock.hostStore()
@StateObject private var model = SessionModel() @StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery() @StateObject private var discovery = HostDiscovery()
@@ -149,11 +160,25 @@ private struct ShotWaking: View {
libraryTarget: .constant(nil), waker: waker, libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in } connect: { _ in }, connectDiscovered: { _ in }
) )
.overlay { WakeOverlay(waker: waker) } .overlay {
ConnectOverlay(
connectingHostName: kind == .connecting ? "Battlestation" : nil,
waker: waker,
onCancelConnect: {})
}
.onAppear { .onAppear {
switch kind {
case .connecting:
break
case .waking:
waker.debugSet(.init( waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(), hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14)) 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))
}
} }
} }
} }
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
var timedOut = false 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? @Published private(set) var waking: Waking?
/// How long to wait for the host to reappear before giving up. Generous a cold boot + service /// How long to wait for the host to reappear before giving up. Generous a cold boot + service
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
public extension PunktfunkConnection { public extension PunktfunkConnection {
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always /// 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 /// 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 /// `com.apple.developer.networking.multicast` entitlement now approved and enabled (see
/// approval (see `Config/Punktfunk.entitlements`) until it's granted, sending a broadcast is /// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action. /// switch every call site gates on, should a future build ever need to disable it.
/// The MAC-learning path stays active on every platform, so flipping this on once the static var wakeOnLANAvailable: Bool { true }
/// 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
}
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s) /// 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 /// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
+121 -93
View File
@@ -19,7 +19,9 @@ use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
use pf_client_core::trust; use pf_client_core::trust;
use pf_presenter::overlay::OverlayAction; 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::collections::VecDeque;
use std::time::Instant; use std::time::Instant;
@@ -240,6 +242,11 @@ impl Shell {
self.wake = None; self.wake = None;
if let Some(Some(intent)) = intent { if let Some(Some(intent)) = intent {
self.start_connect(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, t: f64,
fonts: &Fonts, fonts: &Fonts,
) { ) {
// 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<Hint>)> =
if let Some(c) = &mut self.connecting { if let Some(c) = &mut self.connecting {
c.appear = approach(c.appear, 1.0, dt, 0.07); c.appear = approach(c.appear, 1.0, dt, 0.07);
let a = c.appear; if c.canceling {
canvas.draw_rect( Some((
Rect::from_wh(w as f32, h as f32), c.appear,
&Paint::new(Color4f::new(0.0, 0.0, 0.0, (0.45 * a) as f32), None),
);
let title = if c.canceling {
"Canceling…".to_string()
} else {
format!("Connecting to {}", c.title)
};
let hints = if c.canceling {
vec![]
} else {
vec![Hint::new(HintKey::Back, "Cancel")]
};
card(
canvas,
fonts,
w,
h,
k,
a,
t,
self.glyphs,
true, true,
&title, "Canceling…".to_string(),
"Starting the stream in this window.", String::new(),
&hints, 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 { } else if let Some(wk) = &self.wake {
let a = 1.0; // the wake card is service-driven; it appears settled // Service-driven, so it appears settled (no fade-in).
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 { if wk.timed_out {
card( Some((
canvas, 1.0,
fonts,
w,
h,
k,
a,
t,
self.glyphs,
false, false,
&format!("{} didn't wake", wk.name), format!("{} didn't wake", wk.name),
"Check its power settings, or wake it manually and try again.", "Check its power settings, or wake it manually and try again.".to_string(),
&[ vec![
Hint::new(HintKey::Confirm, "Try Again"), Hint::new(HintKey::Confirm, "Try Again"),
Hint::new(HintKey::Back, "Cancel"), Hint::new(HintKey::Back, "Cancel"),
], ],
); ))
} else { } else {
card( Some((
canvas, 1.0,
fonts,
w,
h,
k,
a,
t,
self.glyphs,
true, true,
&format!("Waking {}", wk.name), format!("Waking {}", wk.name),
&format!("Waiting for it to come online · {} s", wk.seconds), format!("Waiting for it to come online · {} s", wk.seconds),
&[Hint::new(HintKey::Back, "Cancel")], // 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 {
None
};
if let Some((appear, spinner, title, body, hints)) = takeover {
self.draw_takeover(
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
);
} }
// The toast: a transient pill above the hint bar; slides in, fades out. // 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 impl Shell {
/// row — the connect/wake overlays share this one shape. /// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
#[allow(clippy::too_many_arguments)] /// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
fn card( /// 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, canvas: &Canvas,
fonts: &Fonts,
w: f64, w: f64,
h: f64, h: f64,
k: f64, k: f64,
appear: f64, appear: f64,
t: f64, t: f64,
glyphs: GlyphStyle, fonts: &Fonts,
spinner: bool, spinner: bool,
title: &str, title: &str,
body: &str, body: &str,
hints: &[Hint], hints: &[Hint],
) { ) {
let cw = (440.0 * k).min(w * 0.86);
let ch = 190.0 * k;
let cx = w / 2.0; 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); 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); // Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
crate::theme::drop_shadow(canvas, rect, 22.0, k as f32, 0.5); // console taking over rather than a card popping up.
crate::theme::panel( self.draw_aurora(canvas, w, h, t);
canvas, // A soft pool of shade under the centre seats the white text against a bright aurora.
rect, let mut vignette = Paint::default();
22.0, vignette.set_shader(gradient_shader::radial(
Some(Color4f::new(0.07, 0.06, 0.12, 0.85)), Point::new(cx as f32, (h / 2.0) as f32),
PanelStroke::Plain(0.14), (w.max(h) * 0.42) as f32,
k as f32, gradient_shader::GradientShaderColors::Colors(&[
); Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
let mut y = top + 44.0 * k; 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 { if spinner {
crate::theme::spinner(canvas, cx, y, 14.0 * k, t); crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.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,
title,
W::SemiBold,
23.0 * k,
WHITE,
cx,
title_y,
w * 0.82,
);
if !body.is_empty() {
fonts.centered( fonts.centered(
canvas, canvas,
body, body,
W::Regular, W::Regular,
13.0 * k, 14.0 * k,
DIM, DIM,
cx, cx,
y + 30.0 * k, title_y + 32.0 * k,
cw * 0.86, w * 0.66,
); );
}
if !hints.is_empty() { if !hints.is_empty() {
// Centered inside the card's bottom band. // Centered near the bottom, where every console screen's legend sits.
let probe = hint_bar(canvas, fonts, hints, glyphs, -10_000.0, -10_000.0, k); let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
hint_bar( hint_bar(
canvas, canvas,
fonts, fonts,
hints, hints,
glyphs, self.glyphs,
cx - probe.0 / 2.0, cx - probe.0 / 2.0,
top + ch - 16.0 * k, h - 34.0 * k,
k, k,
); );
} }
canvas.restore(); canvas.restore();
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1154,6 +1173,15 @@ mod tests {
then_connect: true, then_connect: true,
})); }));
dump(&mut s, 10, 8, "08-waking", 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); console.set_wake(None);
s.set_connecting(Some("Elden Ring".into())); s.set_connecting(Some("Elden Ring".into()));
dump(&mut s, 10, 8, "09-connecting", true); dump(&mut s, 10, 8, "09-connecting", true);