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
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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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.
|
||||
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,27 +540,9 @@ 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.
|
||||
// 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,
|
||||
@@ -540,7 +556,6 @@ fun ConnectScreen(
|
||||
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() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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")
|
||||
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()
|
||||
|
||||
@@ -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 = {})
|
||||
|
||||
@@ -14,19 +14,11 @@
|
||||
<!-- 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
|
||||
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
|
||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||
|
||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||
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). -->
|
||||
<!--
|
||||
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||
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
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() },
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> 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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
// 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 {
|
||||
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()
|
||||
} 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,
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
&title,
|
||||
"Starting the stream in this window.",
|
||||
&hints,
|
||||
);
|
||||
"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 {
|
||||
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),
|
||||
);
|
||||
// Service-driven, so it appears settled (no fade-in).
|
||||
if wk.timed_out {
|
||||
card(
|
||||
canvas,
|
||||
fonts,
|
||||
w,
|
||||
h,
|
||||
k,
|
||||
a,
|
||||
t,
|
||||
self.glyphs,
|
||||
Some((
|
||||
1.0,
|
||||
false,
|
||||
&format!("{} didn't wake", wk.name),
|
||||
"Check its power settings, or wake it manually and try again.",
|
||||
&[
|
||||
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 {
|
||||
card(
|
||||
canvas,
|
||||
fonts,
|
||||
w,
|
||||
h,
|
||||
k,
|
||||
a,
|
||||
t,
|
||||
self.glyphs,
|
||||
Some((
|
||||
1.0,
|
||||
true,
|
||||
&format!("Waking {}…", wk.name),
|
||||
&format!("Waiting for it to come online · {} s", wk.seconds),
|
||||
&[Hint::new(HintKey::Back, "Cancel")],
|
||||
);
|
||||
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 {
|
||||
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.
|
||||
@@ -799,71 +797,92 @@ 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.
|
||||
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 card(
|
||||
fn draw_takeover(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
fonts: &Fonts,
|
||||
w: f64,
|
||||
h: f64,
|
||||
k: f64,
|
||||
appear: f64,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
fonts: &Fonts,
|
||||
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;
|
||||
// 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, y, 14.0 * k, t);
|
||||
y += 34.0 * k;
|
||||
} else {
|
||||
y += 6.0 * k;
|
||||
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
|
||||
}
|
||||
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(
|
||||
canvas,
|
||||
body,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
y + 30.0 * k,
|
||||
cw * 0.86,
|
||||
title_y + 32.0 * k,
|
||||
w * 0.66,
|
||||
);
|
||||
}
|
||||
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);
|
||||
// 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,
|
||||
glyphs,
|
||||
self.glyphs,
|
||||
cx - probe.0 / 2.0,
|
||||
top + ch - 16.0 * k,
|
||||
h - 34.0 * k,
|
||||
k,
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user