The store's four marketing frames become screenshot scenes on both platforms #233

Merged
enricobuehler merged 7 commits from worktree-store-marketing-scenes into main 2026-08-14 19:49:34 +00:00
11 changed files with 660 additions and 113 deletions
+1 -1
View File
@@ -755,7 +755,7 @@ jobs:
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
# tvOS shoots only the scenes that exist there — the 0609 gamepad-console scenes are
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
SCENES="01-stream 02-hosts 05-settings 03-pair" \
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
echo "Produced:"; ls -la screenshots || true
+4
View File
@@ -142,6 +142,10 @@ dependencies {
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
// animation clock.
testImplementation("io.coil-kt:coil-test:2.7.0")
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
testImplementation("junit:junit:4.13.2")
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
@@ -69,7 +69,7 @@ import kotlinx.coroutines.delay
* to be the same one whichever interface asked.
*/
@Composable
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
BackHandler(onBack = onBack)
var testing by remember { mutableStateOf(false) }
ControllersBody(
@@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
scroll = rememberScrollState(),
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
// while the test is off, which is what keeps the "Last input" line live while browsing.
// Nothing else here wants the pad, so there is no one to hand them to.
@@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
*/
@Composable
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
internal fun ConsoleControllersScreen(
gamepadSetting: Int,
onBack: () -> Unit,
navActive: Boolean = true,
padsOverride: List<PadInfo>? = null,
) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
@@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive:
scroll = scroll,
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// Only while testing: the rest of the time the screen's own nav holds the
// probes, so the "Last input" line is a test-time readout here rather than
// an always-on one. A pad that reaches this screen at all has already
@@ -200,14 +207,17 @@ private fun ControllersBody(
onTestingChange: (Boolean) -> Unit,
observeInput: Boolean,
contentPadding: PaddingValues,
padsOverride: List<PadInfo>? = null,
heading: @Composable () -> Unit,
) {
val context = LocalContext.current
val activity = context as? MainActivity
// Device list, re-read on every hot-plug event.
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
// point of that shot.
var generation by remember { mutableIntStateOf(0) }
val pads = remember(generation) { Gamepad.pads() }
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
val others = remember(generation) {
InputDevice.getDeviceIds()
.toList()
@@ -392,8 +402,8 @@ private fun ControllersBody(
// Every real controller is forwarded now (Automatic forwards them all, each on its own
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
pads.forEach { dev ->
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
pads.forEach { info ->
PadRow(info, gamepadSetting = gamepadSetting)
}
}
@@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (forwarded) {
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (info.forwarded) {
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
// user can tell which physical pad is which. The stream's wire pad index is
// assigned separately (lowest-free per device) once streaming starts.
val number = dev.controllerNumber
val number = info.controllerNumber
Text(
if (number > 0) "forwarded · player $number" else "forwarded to host",
style = MaterialTheme.typography.labelSmall,
@@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
}
}
Text(
deviceDetail(dev),
info.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val resolved = Gamepad.prefFor(dev)
val resolved = info.resolvedPref
Text(
if (gamepadSetting == Gamepad.PREF_AUTO) {
"Streams as: ${prefLabel(resolved)} (automatic)"
@@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val canRumble = deviceHasVibrator(dev)
if (canRumble) {
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
Text(
"No rumble motors reported — host rumble will be silent",
@@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
private fun isForwarded(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/**
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
* the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
*/
internal data class PadInfo(
val name: String,
val detail: String,
val forwarded: Boolean,
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
val dev: InputDevice? = null,
)
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
name = dev.name,
detail = deviceDetail(dev),
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
canRumble = deviceHasVibrator(dev),
dev = dev,
)
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) {
@@ -254,8 +254,10 @@ private fun MessageState(text: String) {
)
}
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
// the library screen itself can't be shot, its state comes off the network.
@Composable
private fun Coverflow(
internal fun Coverflow(
games: List<GameEntry>,
loader: ImageLoader,
navActive: Boolean,
@@ -34,19 +34,34 @@ class ScreenshotTest {
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
/**
* Full-screen content scenes: the compose root fills the device, so a root capture is the
* shot. [statusBar] draws the fake system bar and pushes content below it (see
* [ShotStatusFrame]) off for the immersive surfaces (stream, console shell), which hide
* the real bar too.
*/
private fun shootRoot(
name: String,
statusBar: Boolean = true,
content: @androidx.compose.runtime.Composable () -> Unit,
) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
compose.mainClock.advanceTimeBy(800)
compose.onRoot().captureRoboImage("$out/phone-$name.png")
}
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
private fun shootScreen(
name: String,
statusBar: Boolean = true,
content: @androidx.compose.runtime.Composable () -> Unit,
) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.mainClock.advanceTimeBy(800)
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
// add-host sheet's Connect button was captured half below the frame.
compose.mainClock.advanceTimeBy(1600)
captureScreenRoboImage("$out/phone-$name.png")
}
@@ -73,25 +88,25 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
// aspect is load-bearing.
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
@Test
@@ -114,15 +129,15 @@ class ScreenshotTest {
// The console flow is the full-screen aurora takeover (a root capture).
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
/**
* Landscape the orientation the console actually runs in, and a DIFFERENT layout since the
@@ -132,16 +147,16 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleSettingsLandscape() =
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
// renderings of the same palette can be compared rather than assumed equivalent.
@Test
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
@Test
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
/**
* Landscape the orientation the console UI actually runs in, and the only one wide enough to
@@ -149,7 +164,7 @@ class ScreenshotTest {
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
/**
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
@@ -158,24 +173,46 @@ class ScreenshotTest {
*/
@Test
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
// pale palette, because the console draws them through a ColorScheme derived from the palette's
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
@Test
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
@Test
fun consoleLicensesLight() =
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
/**
* The touch presentation, pads connected landscape, like every store frame: the app is
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun controllers() = shootRoot("controllers") { ControllersScene() }
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleControllersLandscape() =
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
/**
* The library coverflow with a mock shelf the store's PICK & PLAY frame. Landscape: the
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
@Test
fun consoleControllersLight() =
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
@@ -197,4 +234,13 @@ class ScreenshotTest {
HostsScene()
PairDialog()
}
/**
* The add-host sheet (separate window whole-screen capture). Pixel-like geometry, not the
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
* sheet's Connect button the row that carries the resolution promise fit in frame.
*/
@Test
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
fun addHost() = shootScreen("add-host") { AddHostScene() }
}
@@ -1,14 +1,32 @@
package io.unom.punktfunk.screenshots
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryFull
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -35,8 +53,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadHome
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import coil.ImageLoader
import coil.test.FakeImageLoaderEngine
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.AddHostSheet
import io.unom.punktfunk.ConsoleControllersScreen
import io.unom.punktfunk.ConsoleHeader
import io.unom.punktfunk.ConsoleLegendInset
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.ControllersScreen
import io.unom.punktfunk.Coverflow
import io.unom.punktfunk.GamepadAuroraBackground
import io.unom.punktfunk.GamepadHintBar
import io.unom.punktfunk.PadGlyph
import io.unom.punktfunk.PadInfo
import io.unom.punktfunk.consoleLegendInsets
import io.unom.punktfunk.consoleSafeArea
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.library.Artwork
import io.unom.punktfunk.kit.library.GameEntry
import androidx.compose.ui.platform.LocalConfiguration
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.HomeTile
import io.unom.punktfunk.LocalGamepadInk
@@ -70,6 +107,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = BrandDark, content = content)
}
/**
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
* where the bar belongs on the Pixel render the app title collided with the camera punch-hole.
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px 40 dp).
*/
@Composable
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Row(
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"21:47",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Wifi, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp),
)
Icon(
Icons.Filled.SignalCellular4Bar, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(14.dp),
)
Icon(
Icons.Filled.BatteryFull, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(16.dp),
)
}
}
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
}
}
private data class MockHost(
val name: String,
val address: String,
@@ -510,8 +592,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") {
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
* before this could catch light-grey body text stranded on a pastel field.
*
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
* "nothing connected" state.
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] the
* deterministic connected-pads state the store listing needs.
*/
@Composable
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
@@ -520,14 +602,147 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") =
@Composable
internal fun ConsoleControllersScene(paletteId: String = "violet") =
ConsolePalette(paletteId) {
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
// Robolectric enumerates no input devices, so the shot injects the two pads the store
// listing talks about — the empty "no controller detected" state proves the palette but
// sells nothing.
ConsoleControllersScreen(
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
)
}
/**
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
* [Surface]: the activity provides the dark ground in the app, and without one here the content
* color falls back to black-on-white while the cards stay dark.
*/
@Composable
internal fun ControllersScene() =
Surface(color = MaterialTheme.colorScheme.background) {
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
}
/**
* The "Add a host" bottom sheet over the host grid the store's onboarding frame. State is
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
*/
@Composable
internal fun AddHostScene() {
HostsScene()
AddHostSheet(
hostName = "Living Room PC", onHostNameChange = {},
host = "192.168.1.42", onHostChange = {},
port = "9777", onPortChange = {},
connecting = false, modeLabel = "2992×1344@120",
onDismiss = {}, onConnect = { _, _, _ -> },
)
}
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
internal fun shotPads() = listOf(
PadInfo(
name = "DualSense Wireless Controller",
detail = "054C:0CE6 · gamepad · joystick",
forwarded = true, controllerNumber = 1,
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
),
PadInfo(
name = "Xbox Wireless Controller",
detail = "045E:0B13 · gamepad · joystick",
forwarded = true, controllerNumber = 2,
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
),
)
/**
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
* prove nothing at all.
*/
/**
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
* The library screen itself can't be shot its state comes off the network so the scene rebuilds
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
* posters, so the frozen animation clock never races an async load.
*/
@Composable
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
val context = LocalContext.current
val loader = remember { shotLibraryLoader(context) }
val games = remember { shotGames() }
val hazeState = remember { HazeState() }
val landscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("Living Room PC — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Coverflow(games, loader, navActive = false, onLaunch = {})
}
}
}
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
hazeState = hazeState,
)
}
}
}
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
private fun shotGames() = listOf(
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
)
private fun shotLibraryLoader(context: Context): ImageLoader {
val engine = FakeImageLoaderEngine.Builder()
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
.default(ColorDrawable(0xFF221E44.toInt()))
.build()
return ImageLoader.Builder(context).components { add(engine) }.build()
}
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
val w = 600
val h = 900
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
canvas.drawRect(
0f, 0f, w.toFloat(), h.toFloat(),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
)
},
)
canvas.drawText(
mark, w / 2f, h / 2f + 110f,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xD9FFFFFF.toInt()
textSize = 320f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textAlign = Paint.Align.CENTER
},
)
return BitmapDrawable(context.resources, bmp)
}
@Composable
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
val palette = GamepadPalette.named(paletteId)
@@ -50,6 +50,10 @@ class TvScreenshotTest {
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
@Test
fun library() = shootRoot("library") { LibraryScene() }
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
}
@@ -16,6 +16,7 @@
// can wait for layout instead of guessing with a fixed sleep.
#if DEBUG
import PunktfunkKit
import SwiftUI
#if os(macOS)
import AppKit
@@ -43,6 +44,17 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
init(scene: ShotScene) {
self.scene = scene
// Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default,
// and a reused Simulator (or a dev Mac) carries whatever was last picked there the
// Apple TV set once shipped out on a sunset palette that a test device had persisted.
// Idempotent, and only ever runs in shot mode (this view exists behind that gate).
UserDefaults.standard.set(
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet",
forKey: DefaultsKey.uiPalette)
}
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
@@ -35,6 +35,11 @@ enum ShotScenes {
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotSettings())
},
// 0610 are the iOS/macOS console-shell block below; the library is cross-platform
// (tvOS renders the same coverflow), hence the number above that range.
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotLibrary())
},
]
#if os(iOS) || os(macOS)
// The gamepad-mode console screens (no tvOS native focus engine there). Dev-only shots
@@ -68,6 +73,13 @@ enum ShotScenes {
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
},
// FEEL THE GAME the controller test panel with injected pads. Gated with the
// console block because ControllerTestView doesn't build on tvOS, not because it
// is a console screen. Landscape like the rest of the store set: the app is built
// for horizontal use, so the two pads sit as side-by-side columns (see the scene).
ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotControllers())
},
]
#endif
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
@@ -193,6 +205,24 @@ enum ShotMock {
#endif
}
/// A believable shelf for the library coverflow. Decoded rather than constructed:
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
/// construction surface. No art URLs the posters render their deterministic fallback
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
static let games: [GameEntry] = {
let json = """
[
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
"role": "launcher", "icon": "steam"}
]
"""
return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? []
}()
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = hostFingerprint(0)
@@ -230,6 +260,19 @@ private struct ShotHome: View {
}
}
// MARK: - Library
/// The library coverflow with the mock shelf the store listing's PICK & PLAY frame. The real
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
private struct ShotLibrary: View {
var body: some View {
LibraryCoverflowView(
games: ShotMock.games, artLoader: nil,
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
}
}
// MARK: - Gamepad-mode console screens (dev-only glass preview)
#if os(iOS) || os(macOS)
@@ -311,6 +354,61 @@ private struct ShotConnect: View {
}
}
}
// MARK: - Controllers (the pads the store listing names)
/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks
/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s the
/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar
/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game.
private struct ShotControllers: View {
var body: some View {
#if os(macOS)
// The panel is a window-modal sheet in the app float it at sheet width over the
// dimmed host grid, the way the other mac sheet shots read.
ZStack {
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
ControllerTestView(shotPads: Self.pads)
.frame(width: 500, height: 840)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 40, y: 16)
}
#else
// Landscape canvas: one column per pad, so neither story is cut by the short height
// the DualSense feedback surface left, the Xbox live-input readout right.
HStack(spacing: 0) {
ControllerTestView(shotPads: [Self.pads[0]])
ControllerTestView(shotPads: [Self.pads[1]])
}
#endif
}
/// Transport/battery/player ride in `detail` the panel has no dedicated battery row.
/// Each pad shows a different half of the panel: the DualSense skips the input card (the
/// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout.
static let pads: [ControllerTestView.ShotPad] = [
.init(
name: "DualSense Wireless Controller",
detail: "Bluetooth · 85% · Player 1",
isDualSense: true, hasAdaptiveTriggers: true, hasLight: true,
rumbleBackend: "DualSense HID · Bluetooth"),
.init(
name: "Xbox Wireless Controller",
detail: "Bluetooth · 60% · Player 2",
isDualSense: false, hasAdaptiveTriggers: false, hasLight: false,
input: .init(
leftStick: .init(x: -0.31, y: 0.54),
rightStick: .init(x: 0.72, y: -0.16),
leftTrigger: 0.08, rightTrigger: 0.62,
buttons: [
("A", true), ("B", false), ("X", false), ("Y", false),
("LB", false), ("RB", true), ("L3", false), ("R3", false),
("Menu", false), ("Opts", false),
("", false), ("", false), ("", false), ("", false),
])),
]
}
#endif
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
@@ -4,6 +4,11 @@
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
//
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
// screenshot harness needs this panel with pads the capture machine doesn't have ShotScenes
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
//
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
// layout); macOS + iOS/iPadOS cover the validation need.
@@ -14,10 +19,63 @@ import SwiftUI
@MainActor
struct ControllerTestView: View {
/// What one panel section says about a pad, as plain values. The live path flattens the
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
/// that card) the live path always shows both, fed from the live pad and tester.
struct ShotPad: Identifiable {
let name: String
/// The header's second line. Production shows the GC product category; a shot packs
/// transport/battery/player facts into it (the panel has no dedicated battery row).
let detail: String
let isDualSense: Bool
let hasAdaptiveTriggers: Bool
let hasLight: Bool
var input: InputSnapshot? = nil
var rumbleBackend: String? = nil
var id: String { name }
}
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
struct InputSnapshot {
struct Stick {
var x: Float
var y: Float
var pressed = false
}
struct Touch {
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
var primary: CGPoint?
var secondary: CGPoint?
var clicked = false
}
struct Motion {
var gyro: SIMD3<Double>
var accel: SIMD3<Double>
}
var leftStick: Stick
var rightStick: Stick
var leftTrigger: Float = 0
var rightTrigger: Float = 0
/// Grid order; label pressed.
var buttons: [(String, Bool)]
var touchpad: Touch?
var motion: Motion?
}
@Environment(\.dismiss) private var dismiss
@ObservedObject private var gamepads = GamepadManager.shared
@StateObject private var tester = ControllerTester()
/// Screenshot-harness injection nil (the app) renders the live active pad.
private let shotPads: [ShotPad]?
init(shotPads: [ShotPad]? = nil) {
self.shotPads = shotPads
}
@State private var heavyOn = false
@State private var lightOn = false
@State private var intensity = 0.75
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let active = gamepads.active {
header(active)
inputCard
rumbleCard()
triggerCard(active)
extrasCard(active)
if let shotPads {
ForEach(shotPads) { pad in
shotPanel(pad)
}
} else if let active = gamepads.active {
livePanel(active)
} else {
ContentUnavailableView(
"No controller",
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
}
}
.frame(minWidth: 420, minHeight: 540)
.onAppear { tester.target(gamepads.active?.controller) }
.onDisappear { tester.stop() }
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
.onDisappear { if shotPads == nil { tester.stop() } }
.onChange(of: gamepads.active?.id) { _, _ in
guard shotPads == nil else { return }
heavyOn = false
lightOn = false
playerLED = -1
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
}
}
// MARK: Panels
@ViewBuilder
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
let pad = Self.describe(active)
header(pad)
liveInputCard
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
triggerCard(pad)
extrasCard(pad)
}
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
/// outright for a pad without them the live path's "needs a DualSense" hint is a diagnosis,
/// and a capture has nothing to diagnose.
@ViewBuilder
private func shotPanel(_ pad: ShotPad) -> some View {
header(pad)
if let input = pad.input {
card("Input") { inputReadout(input) }
}
if let backend = pad.rumbleBackend {
rumbleCard(backend: backend, health: nil)
}
if pad.hasAdaptiveTriggers {
triggerCard(pad)
}
extrasCard(pad)
}
/// The live pad, flattened to what the panel renders about it.
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
ShotPad(
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
}
// MARK: Header
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
private func header(_ pad: ShotPad) -> some View {
HStack(spacing: 10) {
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
.font(.title2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
Spacer()
}
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
// MARK: Input
private var inputCard: some View {
private var liveInputCard: some View {
card("Input") {
// Poll the live controller at 30 Hz no handlers installed, so nothing else's
// capture is disturbed.
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
if let gp = gamepads.active?.controller.extendedGamepad {
inputReadout(gp, controller: gamepads.active?.controller)
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
} else {
Text("Not an extended gamepad").foregroundStyle(.secondary)
}
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
}
}
/// One readout frame off the live pad.
private static func snapshot(
_ g: GCExtendedGamepad, controller: GCController?
) -> InputSnapshot {
var buttons: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
let tp = touchpad(g)
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
return InputSnapshot(
leftStick: .init(
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false),
rightStick: .init(
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false),
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
buttons: buttons,
touchpad: tp.map {
.init(primary: finger($0.primary), secondary: finger($0.secondary),
clicked: $0.button.isPressed)
},
motion: controller?.motion.map { m -> InputSnapshot.Motion in
let a = totalAccel(m)
return .init(
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
accel: .init(a.0, a.1, a.2))
})
}
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
let x = pad.xAxis.value, y = pad.yAxis.value
// GC snaps a lifted finger to exactly (0, 0).
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
}
@ViewBuilder
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
private func inputReadout(_ s: InputSnapshot) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 20) {
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false)
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false)
stick("L", s.leftStick)
stick("R", s.rightStick)
VStack(spacing: 8) {
triggerBar("L2", value: g.leftTrigger.value)
triggerBar("R2", value: g.rightTrigger.value)
triggerBar("L2", value: s.leftTrigger)
triggerBar("R2", value: s.rightTrigger)
}
}
buttonGrid(g)
if let tp = Self.touchpad(g) {
buttonGrid(s.buttons)
if let tp = s.touchpad {
touchpadView(tp)
}
if let m = controller?.motion {
if let m = s.motion {
motionReadout(m)
}
}
}
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
VStack(spacing: 4) {
ZStack {
Circle().stroke(Color.secondary.opacity(0.3))
Circle()
.fill(pressed ? Color.accentColor : Color.secondary)
.fill(s.pressed ? Color.accentColor : Color.secondary)
.frame(width: 12, height: 12)
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
}
.frame(width: 56, height: 56)
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
.frame(width: 150)
}
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
var items: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
return LazyVGrid(
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
) {
ForEach(items.indices, id: \.self) { i in
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
}
}
private func touchpadView(
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)
) -> some View {
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
Text("Touchpad\(tp.clicked ? " — click" : "")")
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
ZStack {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
}
}
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
let x = pad.xAxis.value, y = pad.yAxis.value
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
return Circle().fill(color).frame(width: 10, height: 10)
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
.opacity(active ? 1 : 0)
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
Circle().fill(color).frame(width: 10, height: 10)
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
.opacity(p == nil ? 0 : 1)
}
private func motionReadout(_ m: GCMotion) -> some View {
let a = Self.totalAccel(m)
return VStack(alignment: .leading, spacing: 2) {
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
Text(String(format: "gyro %+.2f %+.2f %+.2f",
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
.font(.caption2.monospaced())
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
.font(.caption2.monospaced())
}
}
// MARK: Rumble
private func rumbleCard() -> some View {
private func rumbleCard(backend: String, health: String?) -> some View {
card("Rumble") {
VStack(alignment: .leading, spacing: 12) {
Picker("Strength", selection: $intensity) {
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
.pickerStyle(.segmented)
Toggle("Heavy motor (left)", isOn: $heavyOn)
Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
Label("Backend: \(backend)", systemImage: "waveform")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
if let problem = tester.rumbleHealth {
if let problem = health {
Label(problem, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
}
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
// MARK: Adaptive triggers
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
private func triggerCard(_ pad: ShotPad) -> some View {
card("Adaptive triggers") {
if c.hasAdaptiveTriggers {
if pad.hasAdaptiveTriggers {
VStack(alignment: .leading, spacing: 12) {
Picker("Apply to", selection: $triggerTarget) {
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
// MARK: Lightbar + player LED
@ViewBuilder
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
if c.hasLight {
private func extrasCard(_ pad: ShotPad) -> some View {
if pad.hasLight {
card("Lightbar & player LED") {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
+16 -4
View File
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
# The App Store set, in listing order — the first three are what most people ever see, so they are
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
@@ -63,9 +63,13 @@ require_xcode() {
# ---------------------------------------------------------------------------- macOS
shoot_macos() {
log "macOS — building (swift build -c release)…"
swift build -c release >/dev/null
local bin=".build/release/PunktfunkClient"
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
# pixels — SwiftUI has no release-only visuals.
log "macOS — building (swift build)…"
swift build >/dev/null
local bin=".build/debug/PunktfunkClient"
[ -x "$bin" ] || die "build produced no $bin"
for scene in "${SCENES[@]}"; do
@@ -142,6 +146,14 @@ shoot_sim() {
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
# (apple.yml); local runs keep the self-cleaning mktemp default.
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
CODE_SIGNING_ALLOWED=NO build >/dev/null \