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,40 +540,21 @@ fun ConnectScreen(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
status?.let {
|
||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
||||
// result/error (red). Previously every status showed in error-red, so a
|
||||
// normal "Connecting…" looked like a failure.
|
||||
if (connecting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Result/error: a filled error container reads as a real failure banner,
|
||||
// not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -837,8 +852,15 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||
WakeOverlay(waker, gamepadUi)
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = attempt?.hostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = { cancelConnect() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {})
|
||||
|
||||
Reference in New Issue
Block a user