diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index d9b2248..e708a91 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -1,5 +1,5 @@ # Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via -# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml +# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml # but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed. # # Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle @@ -40,7 +40,7 @@ jobs: fi RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" dirname "$RUSTUP" >> "$GITHUB_PATH" - "$RUSTUP" target add aarch64-linux-android x86_64-linux-android + "$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - name: Android SDK uses: android-actions/setup-android@v3 @@ -98,7 +98,7 @@ jobs: RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: | echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks - # AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key. + # AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key. ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. diff --git a/clients/android/README.md b/clients/android/README.md index ce8b4d2..8ba01ce 100644 --- a/clients/android/README.md +++ b/clients/android/README.md @@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation). pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity. - **Compose UI** — Connect / Settings / Stream screens with Material You theming. -Built for `arm64-v8a` + `x86_64`. +Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the +app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with +Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible". ## Get it @@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K **Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`, `build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not -a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and +a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and `cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM 2026.05.01 · compileSdk 37 · minSdk 31). diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index a732f57..9c388a7 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -22,14 +22,34 @@ android { } applicationId = "io.unom.punktfunk" - minSdk = 31 + // Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11); + // the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble + // → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28. + minSdk = 28 targetSdk = 36 val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE")) versionCode = vCode?.toInt() ?: 1 // versionName is the single project version, threaded from CI (a vX.Y.Z release or a // canary string). versionCode stays the monotonic run number (Play rejects regressions). - versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2" - ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + // Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml — + // the single source of truth — so an on-device build shows the real current version, not a + // stale placeholder. + val workspaceVersion = runCatching { + project.rootProject.file("../../Cargo.toml").readLines() + .dropWhile { !it.trim().startsWith("[workspace.package]") } + .firstOrNull { it.trim().startsWith("version") } + ?.substringAfter('=')?.trim()?.trim('"') + }.getOrNull() + versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) + ?: workspaceVersion ?: "0.0.0" + // Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers + // (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android + // userspace, and because this app carries native code, Google Play (and a sideload installer) + // filters it as "not compatible" on those devices unless an armeabi-v7a variant is present. + // x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug + // 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the + // required half; the 32-bit lib is what actually reaches the boxes people report failing. + ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } } signingConfigs { @@ -97,9 +117,18 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons + implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons + implementation("androidx.compose.material:material-icons-extended") // settings-category icons debugImplementation("androidx.compose.ui:ui-tooling") + // Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we + // feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy). + implementation("io.coil-kt:coil-compose:2.7.0") + + // Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent + // scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them. + implementation("dev.chrisbanes.haze:haze:1.6.0") + // Android TV components (we target phone + TV) land in the TV-UI milestone: // implementation("androidx.tv:tv-material:1.1.0") // The manifest already declares leanback so the scaffold installs on TV. diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 6edf87d..d51e90b 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,7 @@ - NavigationBarItem( - selected = tab == t, - onClick = { tab = t }, - icon = { Icon(t.icon, contentDescription = t.label) }, - label = { Text(t.label) }, - ) - } - } - }, - ) { innerPadding -> - Box(Modifier.fillMaxSize().padding(innerPadding)) { - AnimatedContent( - targetState = tab, - transitionSpec = { - if (targetState.ordinal > initialState.ordinal) { + // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail + // with its items centred vertically (the common Android tablet idiom, mirroring iPad's + // side navigation). A short landscape phone keeps the bottom bar (rail needs height too). + // Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone), + // vertically with the side rail (tablet), so the motion tracks the direction you moved. + val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical -> + AnimatedContent( + targetState = tab, + transitionSpec = { + val forward = targetState.ordinal > initialState.ordinal + when { + vertical && forward -> + slideInVertically { it } + fadeIn() togetherWith + slideOutVertically { -it } + fadeOut() + vertical -> + slideInVertically { -it } + fadeIn() togetherWith + slideOutVertically { it } + fadeOut() + forward -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - } else { + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + } + }, + label = "TabTransition" + ) { targetTab -> + when (targetTab) { + Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it }) + Tab.Settings -> SettingsScreen( + initial = settings, + onChange = { settings = it; settingsStore.save(it) }, + onBack = { tab = Tab.Connect }, + ) + } + } + } + + BoxWithConstraints(Modifier.fillMaxSize()) { + if (maxWidth >= 600.dp && maxHeight >= 480.dp) { + Row(Modifier.fillMaxSize()) { + NavigationRail(Modifier.fillMaxHeight()) { + Spacer(Modifier.weight(1f)) // centre the rail items vertically + Tab.entries.forEach { t -> + NavigationRailItem( + selected = tab == t, + onClick = { tab = t }, + icon = { Icon(t.icon, contentDescription = t.label) }, + label = { Text(t.label) }, + ) + } + Spacer(Modifier.weight(1f)) + } + // The rail handles its own insets; the content pane insets itself (the screens + // don't, since they used to rely on the Scaffold's padding). + Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) } + } + } else { + Scaffold( + bottomBar = { + NavigationBar { + Tab.entries.forEach { t -> + NavigationBarItem( + selected = tab == t, + onClick = { tab = t }, + icon = { Icon(t.icon, contentDescription = t.label) }, + label = { Text(t.label) }, + ) + } } }, - label = "TabTransition" - ) { targetTab -> - when (targetTab) { - Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it }) - Tab.Settings -> SettingsScreen( - initial = settings, - onChange = { settings = it; settingsStore.save(it) }, - onBack = { tab = Tab.Connect }, - ) - } + ) { innerPadding -> + Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) } } } } } } } + +/** Which console screen the gamepad shell is showing. */ +private enum class GamepadScreen { Home, Settings, Library } + +/** + * The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch: + * a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing + * [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven. + */ +@Composable +fun GamepadShell( + settings: Settings, + onSettingsChange: (Settings) -> Unit, + onConnected: (Long) -> Unit, +) { + val context = LocalContext.current + var screen by remember { mutableStateOf(GamepadScreen.Home) } + var libraryHost by remember { mutableStateOf(null) } + + // On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the + // effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the + // panel reports fewer dp than that; a low-density TV that's already spacious, and every phone / + // tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout + // wanted — one uniform factor across text, cards, spacing, and insets. + val isTv = remember { isTvDevice(context) } + val baseDensity = LocalDensity.current + val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density + val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP + val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density + + CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) { + // Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated + // on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives + // the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while + // the content behind it fades. + Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s -> + when (s) { + GamepadScreen.Home -> ConnectScreen( + settings = settings, + onConnected = onConnected, + gamepadUi = true, + onOpenSettings = { screen = GamepadScreen.Settings }, + onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library }, + navGate = s == screen, + ) + GamepadScreen.Settings -> GamepadSettingsScreen( + initial = settings, + onChange = onSettingsChange, + onBack = { screen = GamepadScreen.Home }, + navActive = s == screen, + ) + GamepadScreen.Library -> libraryHost?.let { host -> + LibraryScreen( + host = host, + onBack = { screen = GamepadScreen.Home; libraryHost = null }, + navActive = s == screen, + ) + } ?: run { screen = GamepadScreen.Home } + } + } + } +} + +/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */ +private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index f6f4be1..8a3bc3f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -9,7 +9,9 @@ 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.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.PendingTrust import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -320,32 +323,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { } /** - * Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a - * friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field. + * Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the + * host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a + * host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert + * when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the + * Apple client's edit form. */ @Composable -internal fun RenameHostDialog( +internal fun EditHostDialog( target: KnownHost, - onRename: (String) -> Unit, + suggestedMacs: List, + onSave: (KnownHost) -> Unit, onDismiss: () -> Unit, ) { - var newName by remember(target) { mutableStateOf(target.name) } + var name by remember(target) { mutableStateOf(target.name) } + var address by remember(target) { mutableStateOf(target.address) } + var port by remember(target) { mutableStateOf(target.port.toString()) } + var mac by remember(target) { + mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", ")) + } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Rename host") }, + title = { Text("Edit host") }, text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text("Name") }, - placeholder = { Text(target.address) }, - singleLine = true, - ) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text(target.address) }, + singleLine = true, + ) + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text("Address") }, + singleLine = true, + ) + OutlinedTextField( + value = port, + onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) }, + label = { Text("Port") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + OutlinedTextField( + value = mac, + onValueChange = { mac = it }, + label = { Text("Wake-on-LAN MAC") }, + placeholder = { Text("auto-filled when the host is seen") }, + singleLine = true, + ) + } }, confirmButton = { TextButton( - enabled = newName.isNotBlank(), - onClick = { onRename(newName.trim()) }, + enabled = address.isNotBlank(), + onClick = { + onSave( + target.copy( + name = name.trim().ifEmpty { target.address }, + address = address.trim(), + port = port.toIntOrNull() ?: target.port, + mac = KnownHostStore.parseMacs(mac), + ), + ) + }, ) { Text("Save") } }, dismissButton = { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 8b81305..2be7467 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) { } @Composable -fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { +fun ConnectScreen( + settings: Settings, + onConnected: (Long) -> Unit, + // Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this + // screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the + // gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button). + gamepadUi: Boolean = false, + onOpenSettings: () -> Unit = {}, + onOpenLibrary: (KnownHost) -> Unit = {}, + navGate: Boolean = true, // false while the console home is cross-fading out +) { val scope = rememberCoroutineScope() val context = LocalContext.current var host by remember { mutableStateOf("") } @@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { val identityStore = remember { IdentityStore(context) } val knownHostStore = remember { KnownHostStore(context) } var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay + // rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can + // take a minute-plus to advertise again. + val waker = remember { WakeController(scope) } // Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients), // so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the // prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC @@ -156,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { var pendingTrust by remember { mutableStateOf(null) } // A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog). var awaiting by remember { mutableStateOf(null) } - // A saved host whose label is being edited (the Rename dialog). - var renameTarget by remember { mutableStateOf(null) } + // A saved host being edited (name / address / port / MAC). + var editTarget by remember { mutableStateOf(null) } + // A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the + // carousel (the console counterpart of the touch host card's overflow menu). + var optionsTarget by remember { mutableStateOf(null) } // Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts", // not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so @@ -184,25 +201,16 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } - // Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null), - // pin the fingerprint the host presented (as an unpaired known host) so the next connect goes - // straight through and it appears in the saved-hosts list. - fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { - val id = identity - if (id == null) { + // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint + // the host presented (as an unpaired known host) so the next connect goes straight through and it + // appears in the saved-hosts list. + fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { + val id = identity ?: run { status = "Identity not ready yet — try again in a moment" return } connecting = true status = "Connecting to $targetHost:$targetPort…" - // Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it - // was online and it isn't currently advertising, fire a magic packet first — the connect's - // own timeout gives a woken host time to come up (harmless if it's already awake). - knownHostStore.get(targetHost, targetPort)?.mac - ?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } } - ?.let { macs -> - scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) } - } discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) @@ -222,6 +230,47 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } + // Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently + // advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the + // "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come + // back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise + // dial straight through. + fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { + if (identity == null) { + status = "Identity not ready yet — try again in a moment" + return + } + val kh = knownHostStore.get(targetHost, targetPort) + val macs = kh?.mac ?: emptyList() + // "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP + // address change on a cold boot), else by address:port. Returns the CURRENT advert so we can + // dial its live address rather than the stale saved one. + fun liveAdvert(): DiscoveredHost? = + if (kh != null) discovered.firstOrNull { kh.matches(it) } + else discovered.firstOrNull { it.host == targetHost && it.port == targetPort } + if (macs.isNotEmpty() && liveAdvert() == null) { + waker.start( + hostName = name, + connectsAfter = true, + macs = macs, + lastIp = targetHost, + isOnline = { liveAdvert() != null }, + onOnline = { + val live = liveAdvert() + // Woke back on a new address? Re-key the saved record so it (and future connects) + // point at the live one, then dial there. + if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) { + knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port)) + savedHosts = knownHostStore.all() + } + doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex) + }, + ) + } else { + doConnectDirect(targetHost, targetPort, name, pinHex) + } + } + // The no-PIN "request access" path (delegated approval): open a normal identified connect that // the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable // "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no @@ -304,7 +353,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { var showManualSheet by remember { mutableStateOf(false) } - Box(Modifier.fillMaxSize()) { + if (gamepadUi) { + // Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares + // every action above; the trailing Add Host tile opens the same manual-entry sheet. + val tiles = buildList { + savedHosts.forEach { kh -> + add( + HomeTile( + id = "saved-${kh.address}:${kh.port}", + title = kh.name, + subtitle = "${kh.address}:${kh.port}", + filled = true, + online = discovered.any { it.host == kh.address && it.port == kh.port }, + paired = kh.paired, + knownHost = kh, + activate = { connect(kh.address, kh.port) }, + ), + ) + } + discoveredUnsaved.forEach { dh -> + add( + HomeTile( + id = "disc-${dh.host}:${dh.port}", + title = dh.name, + subtitle = "${dh.host}:${dh.port}", + online = true, + activate = { connect(dh.host, dh.port, dh) }, + ), + ) + } + add( + HomeTile( + id = "add", + title = "Add Host", + subtitle = "Register a host by address", + isAdd = true, + activate = { showManualSheet = true }, + ), + ) + } + GamepadHome( + tiles = tiles, + libraryEnabled = settings.libraryEnabled, + controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name, + // Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen, + // while a connect is in flight (else a second A launches a concurrent connect that leaks a + // handle — the touch grid guards the same way with enabled=!connecting), or while the whole + // console home is cross-fading out. + navActive = navGate && !connecting && !showManualSheet && pendingTrust == null && + awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null, + onActivate = { it.activate() }, + onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, + onOpenSettings = onOpenSettings, + onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } }, + ) + } else { + Box(Modifier.fillMaxSize()) { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 160.dp), modifier = Modifier.fillMaxSize(), @@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { knownHostStore.remove(kh.address, kh.port) savedHosts = knownHostStore.all() }, - onRename = { renameTarget = kh }, - // Explicit wake: offered only when the host is offline and we have a MAC to - // target (a tap-to-connect already auto-wakes an offline saved host). - onWake = if (kh.mac.isNotEmpty() && - discovered.none { it.host == kh.address && it.port == kh.port } - ) { - { scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } } + onEdit = { editTarget = kh }, + // Explicit wake-only: offered when the host is offline and we have a MAC. Runs + // through the WakeController so it shows the "Waking…" overlay and waits for + // the host to come online (matched by fingerprint, so a new DHCP address on a + // cold boot still counts as "up") rather than firing a single silent packet. + onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) { + { + waker.start( + hostName = kh.name, + connectsAfter = false, + macs = kh.mac, + lastIp = kh.address, + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) + } } else { null }, @@ -451,80 +564,134 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { .align(Alignment.BottomEnd) .padding(20.dp), ) + } } if (showManualSheet) { - AddHostSheet( - hostName = hostName, - onHostNameChange = { hostName = it }, - host = host, - onHostChange = { host = it }, - port = port, - onPortChange = { port = it }, - connecting = connecting, - modeLabel = "$w×$h@$hz", - onDismiss = { showManualSheet = false }, - onConnect = { h2, p, n -> connect(h2, p, manualName = n) }, - ) - } - - pendingTrust?.let { pt -> - when (pt.kind) { - PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog( - pt = pt, - onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, - onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }, - onDismiss = { pendingTrust = null }, - ) - PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog( - pt = pt, - onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }, - onDismiss = { pendingTrust = null }, - ) - PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog( - pt = pt, - onRequestAccess = { pendingTrust = null; requestAccess(pt) }, - onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }, - onDismiss = { pendingTrust = null }, - ) - PendingTrust.Kind.PAIR -> PairPinDialog( - pt = pt, - identity = identity, - onPaired = { fp -> - // Verified host fp — save as a paired known host, then connect pinned. - knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true)) - savedHosts = knownHostStore.all() - pendingTrust = null - doConnect(pt.host, pt.port, pt.name, fp) + if (gamepadUi) { + // Console add-host: field list + on-screen controller keyboard. "Add" connects (which + // saves the host on TOFU/pair), exactly like the touch sheet's Connect. + GamepadAddHostScreen( + onAdd = { n, addr, p -> + showManualSheet = false + connect(addr, p, manualName = n) }, - onDismiss = { pendingTrust = null }, + onDismiss = { showManualSheet = false }, + ) + } else { + AddHostSheet( + hostName = hostName, + onHostNameChange = { hostName = it }, + host = host, + onHostChange = { host = it }, + port = port, + onPortChange = { port = it }, + connecting = connecting, + modeLabel = "$w×$h@$hz", + onDismiss = { showManualSheet = false }, + onConnect = { h2, p, n -> connect(h2, p, manualName = n) }, ) } } + pendingTrust?.let { pt -> + // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. + val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) } + val onSavePaired = { fp: String -> + knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true)) + savedHosts = knownHostStore.all() + pendingTrust = null + doConnect(pt.host, pt.port, pt.name, fp) + } + when (pt.kind) { + PendingTrust.Kind.TRUST_NEW -> + if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) + else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) + PendingTrust.Kind.FP_CHANGED -> + if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null }) + else FingerprintChangedDialog(pt, onPair, { pendingTrust = null }) + PendingTrust.Kind.REQUEST_ACCESS -> + if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null }) + else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null }) + PendingTrust.Kind.PAIR -> + if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null }) + else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null }) + } + } + awaiting?.let { req -> - AwaitingApprovalDialog( - hostLabel = req.target.name, - onCancel = { - req.cancelled.set(true) - awaiting = null - connecting = false - discovery.start() // the request may still be pending on the host; keep scanning + val onCancel = { + req.cancelled.set(true) + awaiting = null + connecting = false + discovery.start() // the request may still be pending on the host; keep scanning + } + if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel) + else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel) + } + + // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. + optionsTarget?.let { kh -> + val offline = discovered.none { kh.matches(it) } + GamepadHostOptionsDialog( + hostName = kh.name, + canWake = kh.mac.isNotEmpty() && offline, + onWake = { + optionsTarget = null + waker.start( + hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) }, + // A saved host always has a library (it's a knownHost) → offer it when the setting's on, + // so a TV remote reaches the library here instead of via the Y face button. + onLibrary = if (settings.libraryEnabled) { + { optionsTarget = null; onOpenLibrary(kh) } + } else { + null + }, + onEdit = { optionsTarget = null; editTarget = kh }, + onForget = { + knownHostStore.remove(kh.address, kh.port) + savedHosts = knownHostStore.all() + optionsTarget = null + }, + onDismiss = { optionsTarget = null }, ) } - renameTarget?.let { kh -> - RenameHostDialog( - target = kh, - onRename = { newName -> - knownHostStore.rename(kh.address, kh.port, newName) - savedHosts = knownHostStore.all() - renameTarget = null - }, - onDismiss = { renameTarget = null }, - ) + editTarget?.let { kh -> + // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's + // `discovery.hosts.first { host.matches($0) }?.macAddresses`. + val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList() + val onSaveHost: (KnownHost) -> Unit = { updated -> + knownHostStore.update(kh.address, kh.port, updated) + savedHosts = knownHostStore.all() + editTarget = null + } + if (gamepadUi) { + // Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the + // host with an extra MAC row; the action SAVES instead of connecting. + GamepadAddHostScreen( + onAdd = { _, _, _ -> }, + onDismiss = { editTarget = null }, + editHost = kh, + suggestedMacs = suggested, + onSave = onSaveHost, + ) + } else { + EditHostDialog( + target = kh, + suggestedMacs = suggested, + onSave = onSaveHost, + onDismiss = { editTarget = null }, + ) + } } + + // Topmost: the "Waking…" overlay rides over both the touch grid and the console home. + WakeOverlay(waker, gamepadUi) } /** diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index fcbcb62..e567b18 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.hardware.input.InputManager +import android.os.Build import android.os.CombinedVibration import android.os.Handler import android.os.Looper @@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty() + val canRumble = deviceHasVibrator(dev) if (canRumble) { OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") } } else { @@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) { } } +/** 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) { + dev.vibratorManager.vibratorIds.isNotEmpty() + } else { + @Suppress("DEPRECATION") + dev.vibrator.hasVibrator() + } + private fun testRumble(dev: InputDevice) { - val vm = dev.vibratorManager - if (vm.vibratorIds.isEmpty()) return runCatching { - vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200))) + if (Build.VERSION.SDK_INT >= 31) { + val vm = dev.vibratorManager + if (vm.vibratorIds.isEmpty()) return + vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200))) + } else { + @Suppress("DEPRECATION") + val v = dev.vibrator + if (!v.hasVibrator()) return + v.vibrate(VibrationEffect.createOneShot(300, 200)) + } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt new file mode 100644 index 0000000..e00a2d6 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -0,0 +1,467 @@ +package io.unom.punktfunk + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.graphicsLayer +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.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView +// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the +// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to +// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight +// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen. + +// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done. +private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:") +private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row +private const val KB_ROWS = 5 + +private class Field(val id: String, val label: String, val value: String, val placeholder: String) + +@Composable +fun GamepadAddHostScreen( + onAdd: (name: String, address: String, port: Int) -> Unit, + onDismiss: () -> Unit, + // Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the + // edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC. + editHost: KnownHost? = null, + suggestedMacs: List = emptyList(), + onSave: ((KnownHost) -> Unit)? = null, +) { + val context = LocalContext.current + val isTv = remember { isTvDevice(context) } + val isEdit = editHost != null + val title = if (isEdit) "Edit Host" else "Add Host" + val actionLabel = if (isEdit) "Save" else "Add Host" + var name by remember { mutableStateOf(editHost?.name ?: "") } + var address by remember { mutableStateOf(editHost?.address ?: "") } + var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") } + var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") } + val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0 + fun commit() { + if (isEdit && editHost != null && onSave != null) { + onSave( + editHost.copy( + name = name.trim().ifEmpty { editHost.address }, + address = address.trim(), + port = port.toIntOrNull() ?: editHost.port, + mac = KnownHostStore.parseMacs(mac), + ), + ) + } else { + onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777) + } + } + + // On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable) + // text fields + the system IME there. Our controller keyboard is for a phone-with-controller, + // where the phone's own soft keyboard needs a touch a pad can't provide. + if (isTv) { + TvAddHostForm( + title = title, actionLabel = actionLabel, + name = name, onName = { name = it }, + address = address, onAddress = { address = it }, + port = port, onPort = { port = it.filter(Char::isDigit).take(5) }, + mac = if (isEdit) mac else null, onMac = { mac = it }, + canAdd = canAdd, + onAdd = { commit() }, + onDismiss = onDismiss, + ) + return + } + + var focus by remember { mutableIntStateOf(1) } // start on Address + var editing by remember { mutableStateOf(null) } // field id being typed, or null + var kbRow by remember { mutableIntStateOf(1) } + var kbCol by remember { mutableIntStateOf(0) } + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val hazeState = remember { HazeState() } + + val fields = buildList { + add(Field("name", "Name", name, "Optional — e.g. Living Room")) + add(Field("address", "Address", address, "IP or hostname")) + add(Field("port", "Port", port, "9777")) + if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen")) + } + val actionIndex = fields.size // the Save/Add action sits just after the last field + + fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 } + fun closeKeyboard() { editing = null } + fun editField(id: String, transform: (String) -> String) { + when (id) { + "name" -> name = transform(name) + "address" -> address = transform(address) + "port" -> port = transform(port).take(5) + "mac" -> mac = transform(mac) + } + } + fun allowed(id: String, c: Char): Boolean = when (id) { + "port" -> c.isDigit() + "address" -> c != ' ' + else -> true + } + fun activateField() { + if (focus == actionIndex) { + if (canAdd) commit() else { focus = 1; openKeyboard("address") } + } else { + openKeyboard(fields[focus].id) + } + } + fun pressKey() { + val id = editing ?: return + if (kbRow < KB_ACTIONS_ROW) { + val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)] + if (allowed(id, c)) editField(id) { it + c } + } else when (kbCol) { + 0 -> if (allowed(id, ' ')) editField(id) { "$it " } + 1 -> editField(id) { it.dropLast(1) } + else -> closeKeyboard() + } + } + + BackHandler { if (editing != null) closeKeyboard() else onDismiss() } + GamepadNavEffect2D( + active = true, + onDirection = { dir -> + if (editing == null) { + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < actionIndex) focus++ + else -> {} + } + } else { + when (dir) { + NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) } + NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) } + NavDir.LEFT -> if (kbCol > 0) kbCol-- + NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++ + } + } + }, + onActivate = { if (editing == null) activateField() else pressKey() }, + onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } }, + onSecondary = { if (editing != null) closeKeyboard() }, + ) + + val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i } + val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex } + // Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller. + val typeHints = listOf( + PadGlyph.hint('A', "Type") { pressKey() }, + PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } }, + PadGlyph.hint('B', "Done") { closeKeyboard() }, + ) + val sideBySide = landscape && editing != null + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadFormBackground(Modifier.fillMaxSize()) + + if (sideBySide) { + // Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays + // visible (stacked, the keyboard covered the whole short screen). The legend is NOT put + // under the keyboard here — it floats at the same fixed bottom-left spot as everywhere. + Row( + Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column( + Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConsoleHeader(title, horizontalInset = false) + fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } } + AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() } + Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left + } + Column( + Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() } + } + } + } else { + // Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never + // compressed by the keyboard; the keyboard sits below it; the legend floats (fixed). + Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) { + Column( + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConsoleHeader(title, horizontalInset = false) + if (editing == null && !landscape) { + Text( + "Hosts on this network appear automatically — add one by address for everything else.", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.55f), + modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp), + ) + } + fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } } + AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() } + Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled + } + if (editing != null) { + Spacer(Modifier.height(8.dp)) + // The keyboard fills to the bottom; its bottom frame is padded so the fixed + // legend sits OVER that frame (bottom-left corner) rather than in a gap below. + KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() } + } + } + } + } + + // Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard + // open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred. + Box( + Modifier.align(Alignment.BottomStart) + .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + if (editing != null) { + typeHints + } else { + listOf( + PadGlyph.hint('A', "Select") { activateField() }, + PadGlyph.hint('B', "Cancel", onClick = onDismiss), + ) + }, + hazeState = hazeState, + ) + } + } +} + +/** + * Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No + * custom keyboard or input probes — the native focus engine moves between fields and the Add button, + * and focusing a field pops the OS keyboard. B backs out. + */ +@Composable +private fun TvAddHostForm( + title: String, + actionLabel: String, + name: String, + onName: (String) -> Unit, + address: String, + onAddress: (String) -> Unit, + port: String, + onPort: (String) -> Unit, + mac: String?, // non-null only in edit mode + onMac: (String) -> Unit, + canAdd: Boolean, + onAdd: () -> Unit, + onDismiss: () -> Unit, +) { + BackHandler(onBack = onDismiss) + val firstFocus = remember { FocusRequester() } + Box(Modifier.fillMaxSize()) { + GamepadFormBackground(Modifier.fillMaxSize()) + Column( + Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(horizontal = 56.dp, vertical = 36.dp) + .widthIn(max = 720.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White) + Text( + "Hosts on this network appear automatically — add one by address for everything else.", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.55f), + ) + OutlinedTextField( + value = name, onValueChange = onName, singleLine = true, + label = { Text("Name (optional)") }, + modifier = Modifier.fillMaxWidth().focusRequester(firstFocus), + ) + OutlinedTextField( + value = address, onValueChange = onAddress, singleLine = true, + label = { Text("Address") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = port, onValueChange = onPort, singleLine = true, + label = { Text("Port") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + if (mac != null) { + OutlinedTextField( + value = mac, onValueChange = onMac, singleLine = true, + label = { Text("Wake-on-LAN MAC") }, + placeholder = { Text("auto-filled when the host is seen") }, + modifier = Modifier.fillMaxWidth(), + ) + } + Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) { + Text(actionLabel) + } + } + } + LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } +} + +private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3 + +@Composable +private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale") + val shape = RoundedCornerShape(14.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape) + .background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF)) + .border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White) + Spacer(Modifier.weight(1f)) + Text( + f.value.ifEmpty { f.placeholder }, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (editing) Text(" |", color = Color(0xFF8678F5)) + } +} + +@Composable +private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale") + val shape = RoundedCornerShape(14.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape) + .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) + .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + ) + } +} + +@Composable +private fun KeyboardGrid( + cursorRow: Int, + cursorCol: Int, + compact: Boolean, + bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over + onKey: (Int, Int) -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + val gap = if (compact) 5.dp else 7.dp + Column( + Modifier + .fillMaxWidth() + .widthIn(max = 640.dp) + .clip(shape) + .background(Color(0x1FFFFFFF)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape) + .padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset), + verticalArrangement = Arrangement.spacedBy(gap), + ) { + KB_CHAR_ROWS.forEachIndexed { r, chars -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) { + chars.forEachIndexed { c, ch -> + Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) } + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) { + Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) } + Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) } + Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) } + } + } +} + +@Composable +private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + Box( + modifier = modifier + .height(if (compact) 34.dp else 44.dp) + .clip(RoundedCornerShape(9.dp)) + .background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = if (focused) Color.Black else Color.White, + textAlign = TextAlign.Center, + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt new file mode 100644 index 0000000..61c8943 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -0,0 +1,345 @@ +package io.unom.punktfunk + +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SportsEsports +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's +// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a +// connected-controller status chip. One look across every screen is what makes the console UI read +// as a coherent mode rather than a set of themed pages. + +/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */ +private class AuroraBlob( + val color: Color, + val baseX: Float, + val baseY: Float, + val driftX: Float, + val driftY: Float, + val sx: Int, + val sy: Int, + val phase: Float, + val radiusFrac: Float, + val alpha: Float, +) + +private val auroraBlobs = listOf( + AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet + AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo + AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum + AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue +) + +/** + * The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops, + * finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation + * of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role. + */ +@Composable +fun GamepadAuroraBackground(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "aurora") + // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap + // so the field never visibly jumps when the animation restarts. + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = (2 * PI).toFloat(), + animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart), + label = "angle", + ) + Canvas(modifier) { + drawRect(Color.Black) + val span = max(size.width, size.height) + for (b in auroraBlobs) { + val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width + val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height + val r = span * b.radiusFrac + drawCircle( + brush = Brush.radialGradient( + colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent), + center = Offset(cx, cy), + radius = r, + ), + center = Offset(cx, cy), + radius = r, + blendMode = BlendMode.Plus, + ) + } + // Cinematic vignette: pool light centre, sink the corners. + drawRect( + Brush.radialGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)), + center = Offset(size.width / 2, size.height / 2), + radius = span * 0.92f, + ), + ) + // Top/bottom legibility scrim for the pinned title + hint bar. + drawRect( + Brush.verticalGradient( + 0.0f to Color.Black.copy(alpha = 0.40f), + 0.30f to Color.Black.copy(alpha = 0.05f), + 0.70f to Color.Black.copy(alpha = 0.06f), + 1.0f to Color.Black.copy(alpha = 0.42f), + ), + ) + } +} + +/** + * The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet + * (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass + * rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground. + */ +@Composable +fun GamepadFormBackground(modifier: Modifier = Modifier) { + Canvas(modifier) { + val span = max(size.width, size.height) + drawRect(Color(0xFF131126)) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color(0xE6635AAE), Color.Transparent), + center = Offset(size.width * 0.24f, size.height * 0.12f), + radius = span * 0.7f, + ), + center = Offset(size.width * 0.24f, size.height * 0.12f), + radius = span * 0.7f, + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color(0xBF343E96), Color.Transparent), + center = Offset(size.width * 0.82f, size.height * 0.9f), + radius = span * 0.7f, + ), + center = Offset(size.width * 0.82f, size.height * 0.9f), + radius = span * 0.7f, + ) + } +} + +/** + * The exact inset every console screen places its floating legend at (bottom-start), so the legend + * sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind + * it cross-fades between screens. + */ +val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp) + +/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */ +val ConsoleEdgeInset = 24.dp + +/** + * The heading every console screen uses — one style, one inset, so titles line up across Home / + * Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home). + */ +@Composable +fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) { + // `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a + // LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way. + val h = if (horizontalInset) ConsoleEdgeInset else 0.dp + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp), + ) +} + +/** + * One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue. + * [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working + * controller can still drive the console UI (and reach Settings to switch it off). + */ +class GamepadHint( + val glyph: Char, + val color: Color, + val text: String, + val onClick: (() -> Unit)? = null, + // Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc — + // for a TV remote, which has no A/B/X/Y. + val select: Boolean = false, + // Render as the gamepad Select/View button (a small capsule). + val viewButton: Boolean = false, +) + +/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */ +object PadGlyph { + val A = Color(0xFF6BBE45) + val B = Color(0xFFD14B4B) + val X = Color(0xFF4B7BD1) + val Y = Color(0xFFE0B23C) + fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint( + glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick, + ) +} + +/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ +@Composable +fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center, + ) { + Text( + glyph.toString(), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.52f).sp, + textAlign = TextAlign.Center, + ) + } +} + +/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */ +@Composable +private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + Box( + modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape)) + } +} + +/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */ +@Composable +private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + GamepadButtonGlyph('↩', PadGlyph.B, size) +} + +/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */ +@Composable +private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + Box(Modifier.size(size), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(width = size * 0.74f, height = size * 0.46f) + .clip(RoundedCornerShape(50)) + .border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)), + ) + } +} + +/** + * The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained + * translucent pill so it floats over the aurora rather than dissolving into it. + */ +@Composable +fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { + // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: + // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the + // home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests). + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + val shape = RoundedCornerShape(50) + // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent + // scrim below) + a light tint; otherwise fall back to a solid frosted fill. + val frosted = if (hazeState != null) { + modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A)) + } else { + modifier.clip(shape).background(Color(0x8C14122A)) + } + Row( + modifier = frosted + .border(1.dp, Color.White.copy(alpha = 0.14f), shape) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + for (h in hints) { + val cb = h.onClick + val cell = if (cb != null) { + Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp) + } else { + Modifier + } + Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) { + when { + h.viewButton -> ViewButtonGlyph() + h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph() + !padIsGamepad && h.glyph == 'B' -> BackGlyph() + else -> GamepadButtonGlyph(h.glyph, h.color) + } + Spacer(Modifier.width(6.dp)) + Text( + h.text, + style = MaterialTheme.typography.labelLarge, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + softWrap = false, // never char-wrap a label when several hints crowd a narrow pill + ) + } + } + } +} + +/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */ +@Composable +fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.SportsEsports, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(7.dp)) + Text( + name, + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.75f), + maxLines = 1, + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt new file mode 100644 index 0000000..0f87d2c --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -0,0 +1,357 @@ +package io.unom.punktfunk + +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +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.ColumnScope +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +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 +import io.unom.punktfunk.kit.NativeBridge +import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.models.PendingTrust +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch +// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a +// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses. + +/** One dialog action button. */ +class DialogAction( + val label: String, + val primary: Boolean = false, + val enabled: Boolean = true, + val onClick: () -> Unit, +) + +/** + * The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable + * [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is + * up, via ConnectScreen's `navActive`). B → [onDismiss]. + */ +@Composable +fun GamepadDialog( + title: String, + onDismiss: () -> Unit, + actions: List, + body: @Composable ColumnScope.() -> Unit, +) { + // Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels + // like "Request access" without the cramped-row wrapping a horizontal layout caused). + var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) } + BackHandler(onBack = onDismiss) + GamepadNavEffect2D( + active = true, + onDirection = { dir -> + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < actions.lastIndex) focus++ + else -> {} + } + }, + onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() }, + ) + // Cap the card to most of the screen and let the BODY scroll — in a short landscape window the + // title + body + buttons would otherwise overflow and compress/clip the bottom button. + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(24.dp) + .widthIn(max = 520.dp) + .heightIn(max = maxCardHeight) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + // The body scrolls; the title above and the buttons below stay pinned + always visible. + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + body() + } + Spacer(Modifier.size(4.dp)) + actions.forEachIndexed { i, a -> + DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) + } + } + } +} + +@Composable +private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale") + val shape = RoundedCornerShape(14.dp) + val bg = when { + focused -> Color(0xFF6656F2) + primary -> Color(0x336656F2) + else -> Color(0x14FFFFFF) + } + val fg = when { + !enabled -> Color.White.copy(alpha = 0.35f) + focused -> Color.White + primary -> Color(0xFF8678F5) + else -> Color.White.copy(alpha = 0.85f) + } + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape) + .background(bg) + .border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape) + .clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 20.dp, vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1) + } +} + +/** Body text helper — a dimmed paragraph. */ +@Composable +private fun DialogText(text: String) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f)) +} + +/** + * Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit, + * Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of + * the touch host card's overflow menu. + */ +@Composable +fun GamepadHostOptionsDialog( + hostName: String, + canWake: Boolean, + onWake: () -> Unit, + onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y + onEdit: () -> Unit, + onForget: () -> Unit, + onDismiss: () -> Unit, +) { + GamepadDialog( + title = hostName, + onDismiss = onDismiss, + actions = buildList { + if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary)) + if (canWake) add(DialogAction("Wake host", onClick = onWake)) + add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit)) + add(DialogAction("Forget", onClick = onForget)) + add(DialogAction("Cancel", onClick = onDismiss)) + }, + ) { + DialogText("Manage this saved host.") + } +} + +@Composable +fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) { + GamepadDialog( + title = "Trust this host?", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Cancel", onClick = onDismiss), + DialogAction("Pair with PIN", onClick = onPairInstead), + DialogAction("Trust (TOFU)", primary = true, onClick = onTrust), + ), + ) { + DialogText("First connection to ${pt.host}:${pt.port}.") + pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") } + DialogText( + "This host allows trust-on-first-use, but that can't tell an impostor from the real host. " + + "Pairing with a PIN is stronger — it proves both sides.", + ) + } +} + +@Composable +fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) { + GamepadDialog( + title = "Host identity changed", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Cancel", onClick = onDismiss), + DialogAction("Re-pair", primary = true, onClick = onRepair), + ), + ) { + DialogText( + "The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " + + "mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.", + ) + } +} + +@Composable +fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) { + GamepadDialog( + title = "Pairing required", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Cancel", onClick = onDismiss), + DialogAction("Use a PIN", onClick = onUsePin), + DialogAction("Request access", primary = true, onClick = onRequestAccess), + ), + ) { + DialogText("${pt.host}:${pt.port} requires pairing before it will stream.") + DialogText( + "Request access and approve this device in the host's console (or web UI) — no PIN needed. " + + "Or pair with the 4-digit PIN the host displays.", + ) + } +} + +@Composable +fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { + GamepadDialog( + title = "Waiting for approval", + onDismiss = onCancel, + actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)), + ) { + val deviceName = Build.MODEL ?: "this device" + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) + Text("Approve this device on $hostLabel.", color = Color.White) + } + DialogText( + "Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " + + "once you approve — no PIN needed.", + ) + } +} + +/** + * Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes + * 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified + * fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits. + */ +@Composable +fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) } + var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button + var pairing by remember(pt) { mutableStateOf(false) } + var err by remember(pt) { mutableStateOf(null) } + val name = remember { Build.MODEL ?: "Android" } + + fun pair() { + val id = identity ?: return + pairing = true + err = null + val pin = digits.joinToString("") + scope.launch { + val fp = withContext(Dispatchers.IO) { + NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name) + } + pairing = false + if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed." + } + } + + BackHandler(onBack = { if (!pairing) onDismiss() }) + GamepadNavEffect2D( + active = !pairing, + onDirection = { dir -> + when (dir) { + NavDir.LEFT -> if (slot > 0) slot-- + NavDir.RIGHT -> if (slot < 4) slot++ + NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10 + NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10 + } + }, + onActivate = { if (slot == 4 && identity != null) pair() }, + ) + + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) { + Column( + Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .verticalScroll(rememberScrollState()) + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text( + "Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.", + style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) } + } + err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) } + DialogButton( + label = if (pairing) "Pairing…" else "Pair", + focused = slot == 4 && !pairing, + primary = true, + enabled = !pairing && identity != null, + onClick = { if (identity != null) pair() }, + ) + } + } +} + +@Composable +private fun PinSlot(value: Int, focused: Boolean) { + val shape = RoundedCornerShape(12.dp) + Box( + Modifier.size(54.dp, 66.dp).clip(shape) + .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) + .border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape), + contentAlignment = Alignment.Center, + ) { + Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt new file mode 100644 index 0000000..15013e6 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -0,0 +1,328 @@ +package io.unom.punktfunk + +import android.content.res.Configuration +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import io.unom.punktfunk.kit.security.KnownHost +import kotlin.math.absoluteValue +import kotlinx.coroutines.launch + +// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct, +// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is +// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add +// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library. + +/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */ +class HomeTile( + val id: String, + val title: String, + val subtitle: String, + val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline) + val online: Boolean = false, // advertising on the LAN right now + val paired: Boolean = false, // pinned identity (shows a lock) + val connecting: Boolean = false, + val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram) + val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y) + val activate: () -> Unit, +) { + // Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair + // first" message if the host hasn't authorized this device for its management API. + val hasLibrary: Boolean get() = knownHost != null +} + +/** + * The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a + * tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick + * / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch. + */ +@Composable +fun GamepadHome( + tiles: List, + libraryEnabled: Boolean, + controllerName: String?, + // False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay + // can be driven instead. + navActive: Boolean, + onActivate: (HomeTile) -> Unit, + onOpenLibrary: (HomeTile) -> Unit, + onOpenSettings: () -> Unit, + // Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost. + onOptions: (HomeTile) -> Unit = {}, +) { + // Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend + // sits the same distance from the left and the bottom). + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val pagerState = rememberPagerState(pageCount = { tiles.size }) + val scope = rememberCoroutineScope() + // navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed + // at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage + // mid-animation (which is what let a flick overshoot by two). + var navTarget by remember { mutableStateOf(0) } + LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage } + val current = tiles.getOrNull(navTarget) + + GamepadNavEffect( + active = navActive && tiles.isNotEmpty(), + onMove = { dir -> + val target = (navTarget + dir).coerceIn(0, tiles.lastIndex) + if (target != navTarget) { + navTarget = target + scope.launch { pagerState.animateScrollToPage(target) } + } + }, + onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect + onSecondary = { // Y (gamepad) → Library + tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary) + }, + onTertiary = onOpenSettings, // X (gamepad) → Settings + // A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library / + // Edit / Forget). A gamepad instead opens Options on its Select/View button. + onUp = onOpenSettings, + onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) }, + onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) }, + ) + + // The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the + // Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up + // (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either + // way. Each hint is also TAPPABLE (touch hatch). + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false + val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect" + val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) } + val optionsAction: () -> Unit = { current?.let(onOptions) } + val arrowTint = Color(0xFF9A93C7) + val hints = buildList { + if (padIsGamepad) { + add(PadGlyph.hint('A', connectLabel, onClick = connectAction)) + if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") { + tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary) + }) + add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings)) + // The pad's Select/View button (drawn as its capsule glyph) opens host options. + if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true)) + } else { + add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true)) + add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() })) + if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction)) + } + } + + val hazeState = remember { HazeState() } + + Box(Modifier.fillMaxSize()) { + // The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur + // whatever scrolls under it. + BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadAuroraBackground(Modifier.fillMaxSize()) + + // Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they + // no longer push the cards below the true centre. + val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp) + val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp) + val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp) + Box(Modifier.fillMaxSize().systemBarsPadding()) { + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(cardWidth), + contentPadding = PaddingValues(horizontal = sidePad), + pageSpacing = 22.dp, + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val tile = tiles[page] + // Real distance-from-centered (page + fractional drag), so the pop tracks the + // live scroll: centered tile at full scale/brightness, neighbours recede + blur. + val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction) + .absoluteValue.coerceIn(0f, 1f) + GamepadHostTile( + tile = tile, + modifier = Modifier + .graphicsLayer { + val s = lerp(1f, 0.86f, offset) + scaleX = s + scaleY = s + alpha = lerp(1f, 0.5f, offset) + } + // Unbounded so the depth blur isn't hard-clipped at the card's rectangle + // (the cut-off edge). No-op below API 31; a soft blur above. + .blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .height(cardHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (page == navTarget) { + onActivate(tile) + } else { + navTarget = page + scope.launch { pagerState.animateScrollToPage(page) } + } + }, + ) + } + } + } + + // Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses + // the shared ConsoleHeader so it lines up with every other screen's heading. + Row( + Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding() + .padding(end = ConsoleEdgeInset), + verticalAlignment = Alignment.CenterVertically, + ) { + ConsoleHeader("Select a Host", modifier = Modifier.weight(1f)) + if (controllerName != null) ControllerStatusChip(controllerName) + } + + // Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE + // it ignores the safe area (the nav-bar inset made the bottom gap look oversized). + Box( + Modifier + .align(Alignment.BottomStart) + .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar(hints, hazeState = hazeState) + } + } +} + +/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */ +@Composable +private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(26.dp) + val wash = if (tile.filled) { + Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A))) + } else { + Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF))) + } + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(wash) + .border(1.dp, Color.White.copy(alpha = 0.16f), shape) + .padding(22.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + MonogramBadge(tile) + Spacer(Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (tile.paired) { + Icon( + Icons.Filled.Lock, + contentDescription = "Paired", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.padding(end = 6.dp).size(15.dp), + ) + } + if (tile.online) { + Box( + Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape) + .background(Color(0xFF3CD070)), + ) + } + } + } + Spacer(Modifier.weight(1f)) + Text( + tile.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + tile.subtitle, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.55f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun MonogramBadge(tile: HomeTile) { + val shape = RoundedCornerShape(15.dp) + val fill = if (tile.filled) { + Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5))) + } else { + Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2))) + } + Box( + modifier = Modifier.size(52.dp).clip(shape).background(fill), + contentAlignment = Alignment.Center, + ) { + when { + tile.connecting -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = Color.White, + ) + tile.isAdd -> Icon( + Icons.Filled.Add, + contentDescription = null, + tint = if (tile.filled) Color.White else Color(0xFF8678F5), + ) + else -> Text( + tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (tile.filled) Color.White else Color(0xFF8678F5), + ) + } + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt new file mode 100644 index 0000000..a9e8994 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -0,0 +1,257 @@ +package io.unom.punktfunk + +import android.os.SystemClock +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import kotlin.math.abs +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +// Controller navigation for the console carousels (host launcher + library coverflow). It taps the +// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so +// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis — +// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a +// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left +// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it +// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces +// the moves against a target index so a fast repeat walks smoothly instead of overshooting. + +private const val STICK_HIGH = 0.6f // cross this to commit a move +private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis) +private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat +private const val REPEAT_MS = 150L // then repeat this often while held + +private class NavInputState { + @Volatile var stickX = 0f + @Volatile var stickY = 0f + @Volatile var hatX = 0f + @Volatile var hatY = 0f + @Volatile var dpadX = 0 + @Volatile var dpadY = 0 + fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 } +} + +/** A committed navigation direction from the stick / D-pad / HAT. */ +enum class NavDir { UP, DOWN, LEFT, RIGHT } + +/** + * Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1 + * (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X, + * [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the + * screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops + * consuming the pad and the overlay can be navigated. + */ +@Composable +fun GamepadNavEffect( + active: Boolean, + onMove: (Int) -> Unit, + onActivate: () -> Unit, + onSecondary: () -> Unit = {}, + onTertiary: () -> Unit = {}, + // D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button. + onUp: () -> Unit = {}, + onDown: () -> Unit = {}, + // Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK + // button (the Android-TV context-menu convention). A short OK press is [onActivate]. + onOptions: () -> Unit = {}, +) { + val activity = LocalContext.current as? MainActivity ?: return + val state = remember { NavInputState() } + // The effects below are keyed on `active` only (they must NOT restart on every recomposition), so + // they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are + // discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the + // long-lived coroutine/probes pointed at the CURRENT callbacks. + val currentOnMove by rememberUpdatedState(onMove) + val currentOnActivate by rememberUpdatedState(onActivate) + val currentOnSecondary by rememberUpdatedState(onSecondary) + val currentOnTertiary by rememberUpdatedState(onTertiary) + val currentOnUp by rememberUpdatedState(onUp) + val currentOnDown by rememberUpdatedState(onDown) + val currentOnOptions by rememberUpdatedState(onOptions) + + DisposableEffect(active) { + // Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still + // own it — a cross-fading-out screen mustn't null the incoming screen's probes. + val motionProbe: (MotionEvent) -> Boolean = probe@{ ev -> + if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) { + state.stickX = ev.getAxisValue(MotionEvent.AXIS_X) + state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X) + return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it + } + false + } + val keyProbe: (KeyEvent) -> Boolean = probe@{ ev -> + val down = ev.action == KeyEvent.ACTION_DOWN + val edge = down && ev.repeatCount == 0 + when (ev.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true } + // TV remote (no face buttons): Up → Settings, Down → a saved host's Options. + KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true } + KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true } + KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } + // The gamepad Select / View / Share button → context options (a remote uses Down). + KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true } + KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } + KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } + else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK) + } + } + if (active) { + activity.padMotionProbe = motionProbe + activity.padKeyProbe = keyProbe + } + onDispose { + if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null + if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null + state.reset() + } + } + + LaunchedEffect(active) { + if (!active) return@LaunchedEffect + var committed = 0 // the direction currently held (hysteresis + repeat authority) + var fireAt = 0L // uptime at/after which the next auto-repeat may fire + while (isActive) { + val now = SystemClock.uptimeMillis() + val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0 + val dir = when { + state.dpadX != 0 -> state.dpadX + hat != 0 -> hat + else -> { + val x = state.stickX + when { + x >= STICK_HIGH -> 1 + x <= -STICK_HIGH -> -1 + abs(x) < STICK_LOW -> 0 + else -> committed // inside the hysteresis band → hold the committed value + } + } + } + when { + dir == 0 -> committed = 0 + dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS } + now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS } + } + delay(16) + } + } +} + +/** + * 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen + * keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant + * stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick + * returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X, + * [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels + * one layer": close the keyboard, then the screen). + */ +@Composable +fun GamepadNavEffect2D( + active: Boolean, + onDirection: (NavDir) -> Unit, + onActivate: () -> Unit, + onTertiary: () -> Unit = {}, + onSecondary: () -> Unit = {}, +) { + val activity = LocalContext.current as? MainActivity ?: return + val state = remember { NavInputState() } + val currentOnDirection by rememberUpdatedState(onDirection) + val currentOnActivate by rememberUpdatedState(onActivate) + val currentOnTertiary by rememberUpdatedState(onTertiary) + val currentOnSecondary by rememberUpdatedState(onSecondary) + + DisposableEffect(active) { + // Stable probe refs so onDispose only releases the slot if WE still own it — during a + // cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's + // teardown must not null out the incoming screen's just-installed probes. + val motionProbe: (MotionEvent) -> Boolean = probe@{ ev -> + if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) { + state.stickX = ev.getAxisValue(MotionEvent.AXIS_X) + state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y) + state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X) + state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y) + return@probe true + } + false + } + val keyProbe: (KeyEvent) -> Boolean = probe@{ ev -> + val down = ev.action == KeyEvent.ACTION_DOWN + val edge = down && ev.repeatCount == 0 + when (ev.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true } + KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true } + KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true } + KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } + KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } + KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } + else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler) + } + } + if (active) { + activity.padMotionProbe = motionProbe + activity.padKeyProbe = keyProbe + } + onDispose { + if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null + if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null + state.reset() + } + } + + LaunchedEffect(active) { + if (!active) return@LaunchedEffect + var committed: NavDir? = null + var fireAt = 0L + while (isActive) { + val now = SystemClock.uptimeMillis() + val raw = resolveDir(state) + val nearCentre = state.dpadX == 0 && state.dpadY == 0 && + abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f && + abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW + when { + raw == null && nearCentre -> committed = null + raw == null -> { /* in the hysteresis band → hold, don't fire */ } + raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS } + now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS } + } + delay(16) + } + } +} + +/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */ +private fun resolveDir(s: NavInputState): NavDir? { + if (s.dpadY < 0) return NavDir.UP + if (s.dpadY > 0) return NavDir.DOWN + if (s.dpadX < 0) return NavDir.LEFT + if (s.dpadX > 0) return NavDir.RIGHT + if (s.hatY <= -0.5f) return NavDir.UP + if (s.hatY >= 0.5f) return NavDir.DOWN + if (s.hatX <= -0.5f) return NavDir.LEFT + if (s.hatX >= 0.5f) return NavDir.RIGHT + return if (abs(s.stickY) >= abs(s.stickX)) { + when { + s.stickY <= -STICK_HIGH -> NavDir.UP + s.stickY >= STICK_HIGH -> NavDir.DOWN + else -> null + } + } else { + when { + s.stickX <= -STICK_HIGH -> NavDir.LEFT + s.stickX >= STICK_HIGH -> NavDir.RIGHT + else -> null + } + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt new file mode 100644 index 0000000..8ef08b1 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -0,0 +1,313 @@ +package io.unom.punktfunk + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +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.PaddingValues +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.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +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.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource + +// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: +// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with +// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, +// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. + +private class GpRow( + val id: String, + val header: String?, + val label: String, + val value: String, + val detail: String, + val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed + val activate: () -> Unit, // A → cycle forward (wrapping) / flip +) + +@Composable +fun GamepadSettingsScreen( + initial: Settings, + onChange: (Settings) -> Unit, + onBack: () -> Unit, + navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad +) { + var s by remember { mutableStateOf(initial) } + fun update(next: Settings) { s = next; onChange(next) } + + val rows = buildSettingsRows(s, ::update) + var focus by remember { mutableIntStateOf(0) } + if (focus > rows.lastIndex) focus = rows.lastIndex + val listState = rememberLazyListState() + + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + BackHandler(onBack = onBack) + GamepadNavEffect2D( + active = navActive, + onDirection = { dir -> + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < rows.lastIndex) focus++ + NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1) + NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1) + } + }, + onActivate = { rows.getOrNull(focus)?.activate() }, + ) + // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the + // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. + // +1 accounts for the heading being item 0. + LaunchedEffect(focus) { + runCatching { + val itemIndex = focus + 1 + val info = listState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex } + val offScreen = item == null || + item.offset < info.viewportStartOffset || + item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend + if (offScreen) listState.animateScrollToItem(itemIndex) + } + } + + val hazeState = remember { HazeState() } + + Box(Modifier.fillMaxSize()) { + // Everything scrolls — including the heading — so nothing is pinned. Vital in landscape, + // where a fixed title + a fixed detail/legend strip ate most of the (short) height. + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadFormBackground(Modifier.fillMaxSize()) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().systemBarsPadding(), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item(key = "__title") { + ConsoleHeader("Settings", horizontalInset = false) + } + itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> + SettingRowView(row, focused = index == focus, onClick = { + if (focus == index) row.activate() else focus = index + }) + } + } + } + + // Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated + // strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset. + Box( + Modifier + .align(Alignment.BottomStart) + .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), + // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. + PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ), + hazeState = hazeState, + ) + } + } +} + +@Composable +private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale") + val shape = RoundedCornerShape(14.dp) + Column { + if (row.header != null) { + Text( + row.header.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.45f), + letterSpacing = 1.4.sp, + modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp), + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape) + .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) + .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + row.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + ) + Spacer(Modifier.weight(1f)) + if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f)) + Text( + row.value, + style = MaterialTheme.typography.bodyMedium, + color = if (focused) Color.White else Color.White.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f)) + } + // The focused row carries its own one-line description — no dedicated (space-eating) + // detail strip. It appears right where you're looking, and the row grows to fit. + if (focused && row.detail.isNotBlank()) { + Text( + row.detail, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f), + maxLines = 2, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } +} + +/** Build the console settings rows from the current [Settings], writing through [update]. */ +private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List { + fun choice( + id: String, header: String?, label: String, detail: String, + options: List>, current: T, write: (T) -> Unit, + ): GpRow { + val idx = options.indexOfFirst { it.first == current } + return GpRow( + id, header, label, + value = options.getOrNull(idx)?.second ?: "—", + detail = detail, + adjust = { delta -> + if (idx < 0) { + options.firstOrNull()?.let { write(it.first) } != null + } else { + val t = idx + delta + if (t in options.indices) { write(options[t].first); true } else false + } + }, + activate = { + val i = if (idx < 0) 0 else (idx + 1) % options.size + options.getOrNull(i)?.let { write(it.first) } + }, + ) + } + fun toggle( + id: String, header: String?, label: String, detail: String, + value: Boolean, write: (Boolean) -> Unit, + ): GpRow = GpRow( + id, header, label, + value = if (value) "On" else "Off", + detail = detail, + adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false }, + activate = { write(!value) }, + ) + + return listOf( + choice( + "resolution", "Stream", "Resolution", + "The host creates a virtual display at exactly this size — no scaling.", + RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height, + ) { (w, h) -> update(s.copy(width = w, height = h)) }, + choice( + "refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", + REFRESH_OPTIONS, s.hz, + ) { update(s.copy(hz = it)) }, + choice( + "bitrate", null, "Bitrate", + "Automatic uses the host's default. Run a speed test from the touch UI for an informed value.", + BITRATE_OPTIONS, s.bitrateKbps, + ) { update(s.copy(bitrateKbps = it)) }, + choice( + "compositor", null, "Compositor", + "Which compositor drives the virtual output — honored only if available on the host.", + COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor, + ) { update(s.copy(compositor = it)) }, + + choice( + "codec", "Video", "Video codec", + "A preference — the host falls back if it can't encode this one.", + CODEC_OPTIONS, s.codec, + ) { update(s.copy(codec = it)) }, + toggle( + "hdr", null, "10-bit HDR", + "HDR10 — engages when the host sends HDR content and this display supports it.", + s.hdrEnabled, + ) { update(s.copy(hdrEnabled = it)) }, + + choice( + "audio", "Audio", "Audio channels", "The speaker layout requested from the host.", + AUDIO_CHANNEL_OPTIONS, s.audioChannels, + ) { update(s.copy(audioChannels = it)) }, + toggle( + "mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.", + s.micEnabled, + ) { update(s.copy(micEnabled = it)) }, + + choice( + "padType", "Controller", "Controller type", + "The virtual pad the host creates — Automatic matches this controller.", + GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad, + ) { update(s.copy(gamepad = it)) }, + + toggle( + "hud", "Interface", "Statistics overlay", + "Show FPS, throughput and latency while streaming.", + s.statsHudEnabled, + ) { update(s.copy(statsHudEnabled = it)) }, + toggle( + "library", null, "Game library", + "Browse a paired host's games with Y (experimental).", + s.libraryEnabled, + ) { update(s.copy(libraryEnabled = it)) }, + toggle( + "gamepadUI", null, "Controller-optimized UI", + "Turn off to use the touch interface even with a controller connected.", + s.gamepadUiEnabled, + ) { update(s.copy(gamepadUiEnabled = it)) }, + ) +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt new file mode 100644 index 0000000..89e73dd --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt @@ -0,0 +1,63 @@ +package io.unom.punktfunk + +import android.app.UiModeManager +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import io.unom.punktfunk.kit.Gamepad + +/** + * Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should + * replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`: + * the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced] + * flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the + * console UI (as long as the setting is on). + */ +fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean = + enabled && (controllerConnected || tv || forced) + +/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */ +fun isTvDevice(context: Context): Boolean { + val pm = context.packageManager + if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) + ) { + return true + } + val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION +} + +/** + * Live "is a game controller attached" state, updated as pads connect/disconnect via + * [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is + * plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client + * gets from observing `GamepadManager.shared`. + */ +@Composable +fun rememberControllerConnected(): State { + val context = LocalContext.current + val connected = remember { mutableStateOf(Gamepad.firstPad() != null) } + DisposableEffect(Unit) { + val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager + val listener = object : InputManager.InputDeviceListener { + private fun refresh() { connected.value = Gamepad.firstPad() != null } + override fun onInputDeviceAdded(deviceId: Int) = refresh() + override fun onInputDeviceRemoved(deviceId: Int) = refresh() + override fun onInputDeviceChanged(deviceId: Int) = refresh() + } + im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper())) + connected.value = Gamepad.firstPad() != null + onDispose { im.unregisterInputDeviceListener(listener) } + } + return connected +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt new file mode 100644 index 0000000..9419057 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -0,0 +1,297 @@ +package io.unom.punktfunk + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import android.content.res.Configuration +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.zIndex +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.launch +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT +import io.unom.punktfunk.kit.library.GameEntry +import io.unom.punktfunk.kit.library.LibraryClient +import io.unom.punktfunk.kit.library.LibraryResult +import io.unom.punktfunk.kit.library.mtlsHttpClient +import io.unom.punktfunk.kit.security.IdentityStore +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.obtainIdentity +import kotlin.math.PI +import kotlin.math.absoluteValue +import kotlin.math.cos +import kotlin.math.sign +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView: +// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D +// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host. + +private sealed class LibState { + object Loading : LibState() + data class Ready(val games: List, val loader: ImageLoader) : LibState() + data class Message(val text: String) : LibState() // unauthorized / empty / error +} + +@Composable +fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) { + BackHandler(onBack = onBack) + val context = LocalContext.current + val hazeState = remember { HazeState() } + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + var state by remember { mutableStateOf(LibState.Loading) } + + LaunchedEffect(host.address, host.port, host.fpHex) { + state = LibState.Loading + state = withContext(Dispatchers.IO) { + val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull() + ?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.") + when (val res = LibraryClient.fetch( + address = host.address, + mgmtPort = DEFAULT_MGMT_PORT, + certPem = id.certPem, + keyPem = id.privateKeyPem, + fpHex = host.fpHex, + )) { + is LibraryResult.Ok -> if (res.games.isEmpty()) { + LibState.Message("No games found on this host.") + } else { + val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex) + LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build()) + } + is LibraryResult.Unauthorized -> LibState.Message(res.message) + is LibraryResult.Error -> LibState.Message(res.message) + } + } + } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadAuroraBackground(Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().systemBarsPadding()) { + ConsoleHeader("${host.name} — Library") + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + when (val s = state) { + is LibState.Loading -> LoadingState() + is LibState.Message -> MessageState(s.text) + is LibState.Ready -> Coverflow(s.games, s.loader, navActive) + } + } + } + } + // Floating legend at the shared spot — same landscape-aware inset as every other console + // screen (ignore the safe area in landscape, where the bottom edge isn't a tap target). + Box( + Modifier.align(Alignment.BottomStart) + .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState) + } + } +} + +@Composable +private fun LoadingState() { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + CircularProgressIndicator(color = Color.White) + Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun MessageState(text: String) { + Text( + text, + color = Color.White.copy(alpha = 0.75f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) +} + +@Composable +private fun Coverflow(games: List, loader: ImageLoader, navActive: Boolean) { + BoxWithConstraints(Modifier.fillMaxSize()) { + // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. + val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) + val coverWidth = coverHeight * 2f / 3f + val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp) + val pagerState = rememberPagerState(pageCount = { games.size }) + val scope = rememberCoroutineScope() + var navTarget by remember { mutableIntStateOf(0) } + LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage } + val current = games.getOrNull(navTarget) + + // Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a + // coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes + // via the screen's BackHandler. + GamepadNavEffect( + active = navActive && games.isNotEmpty(), + onMove = { dir -> + val t = (navTarget + dir).coerceIn(0, games.lastIndex) + if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } } + }, + onActivate = { /* launch a title — browse-only for now */ }, + ) + + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(coverWidth), + contentPadding = PaddingValues(horizontal = sidePad), + pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer + beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible + modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + val d = signed.absoluteValue + Poster( + game = games[page], + loader = loader, + modifier = Modifier + .zIndex(-d) // centred cover on top, neighbours stacked behind + .width(coverWidth) + .height(coverHeight) + .graphicsLayer { + // Centre at full size; EVERY neighbour settles to one size, so an even pitch + // yields even VISUAL gaps. (A progressive shrink made the outer gaps grow — + // the "edges spread apart while the centre gets crowded" look.) + val scale = 1f - 0.28f * d.coerceAtMost(1f) + scaleX = scale + scaleY = scale + alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size + val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward + rotationY = rotDeg + // Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over + // the first step so scrolling stays smooth) so the CENTRE card breathes. + val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f + // Counter-balance: a rotated card projects narrower (≈cos θ), which opens its + // inner gap — pull it back toward centre by the half-width it loses so the + // gaps stay even no matter the tilt. + val halfW = size.width * scale * 0.5f + val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f))) + translationX = base + counter + // Lower cameraDistance = stronger perspective (CSS `perspective`); the flat + // 22 washed the tilt out. 9 makes the same angle read as real depth. + cameraDistance = 9f * density + transformOrigin = TransformOrigin(0.5f, 0.5f) + }, + ) + } + Column( + Modifier.fillMaxWidth().padding(top = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + current?.title ?: " ", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (current != null) { + Text( + if (current.isCustom) "CUSTOM" else "STEAM", + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.5f), + letterSpacing = 2.sp, + ) + } + } + } + } +} + +/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */ +@Composable +private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) { + val candidates = game.art.posterCandidates + var idx by remember(game.id) { mutableStateOf(0) } + val shape = RoundedCornerShape(16.dp) + Box( + modifier = modifier + .clip(shape) + .background(Color(0xFF241F3D)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + contentAlignment = Alignment.Center, + ) { + if (idx < candidates.size) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(), + imageLoader = loader, + contentDescription = game.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder + ) + } else { + Text( + game.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.75f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(12.dp), + ) + } + // Store badge, top-start. + Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) { + Text( + if (game.isCustom) "Custom" else "Steam", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt index c1dcb45..e6f3ee3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt @@ -3,14 +3,21 @@ package io.unom.punktfunk import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.platform.LocalContext import androidx.compose.ui.text.font.FontFamily @@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) { context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() } }.getOrDefault("Third-party notices unavailable.") } + // The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL + // requires the license travel with the font, so surface it here (mirrors the Apple client). + val fontLicense = remember { + runCatching { + context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() } + }.getOrNull() + } val version = remember { runCatching { @Suppress("DEPRECATION") @@ -38,29 +52,52 @@ fun LicensesScreen(onBack: () -> Unit) { }.getOrNull() } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium) - if (version != null) { - Text( - "punktfunk $version", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(Modifier.fillMaxSize()) { + // Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler). + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall) + } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (version != null) { + Text( + "Punktfunk $version", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + + "components below, each under its own license.", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + notices, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + if (fontLicense != null) { + Text("Bundled font", style = MaterialTheme.typography.titleMedium) + Text( + "The Geist typeface is licensed under the SIL Open Font License 1.1.", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + fontLicense, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } } - Text( - "punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + - "components below, each under its own license.", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - notices, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 3cd7c4e..4beb5fe 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -1,5 +1,6 @@ package io.unom.punktfunk +import android.os.Build import android.os.Bundle import android.view.InputDevice import android.view.KeyEvent @@ -10,6 +11,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Keymap @@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() { var padKeyProbe: ((KeyEvent) -> Boolean)? = null var padMotionProbe: ((MotionEvent) -> Boolean)? = null + /** + * Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a + * couch user with no keyboard/Back can always leave a stream. + */ + var requestStreamExit: (() -> Unit)? = null + + /** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */ + private var heldPadButtons = 0 + + /** + * Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad + * remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad + * face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state). + */ + var lastPadIsGamepad by mutableStateOf(false) + private set + + /** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */ + private var highRefreshModeId = 0 + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + resolveHighRefreshMode() + setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own // Dark, transparent system bars regardless of the system theme — our UI is always dark, so // the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge // picks the *system* light/dark, which left a black status bar over our dark background.) @@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() { statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), ) + // Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console + // UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez + // pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV. + val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false setContent { PunktfunkTheme { - Surface(modifier = Modifier.fillMaxSize()) { App() } + Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) } } } } + /** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */ + private fun resolveHighRefreshMode() { + @Suppress("DEPRECATION") + val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay + highRefreshModeId = disp?.supportedModes?.maxWithOrNull( + compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }), + )?.modeId ?: 0 + } + + /** + * Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin + * third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the + * UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so + * its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead. + */ + fun setConsoleHighRefreshRate(high: Boolean) { + if (highRefreshModeId == 0) return + window.attributes = window.attributes.apply { + preferredDisplayModeId = if (high) highRefreshModeId else 0 + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { val handle = streamHandle if (handle != 0L) { @@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() { if (bit != 0) { when (event.action) { // repeatCount guard: don't re-send a held button as auto-repeat. - KeyEvent.ACTION_DOWN -> + KeyEvent.ACTION_DOWN -> { if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true) - KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false) + heldPadButtons = heldPadButtons or bit + // Emergency exit: Select + Start + L1 + R1 held together leaves the stream + // (a couch user has no keyboard/Back). Fired once per full chord. + if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) { + heldPadButtons = 0 + requestStreamExit?.let { exit -> window.decorView.post { exit() } } + } + } + KeyEvent.ACTION_UP -> { + NativeBridge.nativeSendGamepadButton(handle, bit, false) + heldPadButtons = heldPadButtons and bit.inv() + } } return true // consumed } @@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() { } } } else { + // Note which input the console UI is being driven by, so its glyphs match (a TV remote's + // D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are). + if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) { + lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD) + } // The Controllers debug screen sees pad events before the navigation remap below. padKeyProbe?.let { if (it(event)) return true } if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { // Not streaming: a game controller drives the Compose UI (TV + phone). Map the face - // buttons to the navigation keys the focus system understands; D-pad *keys* already - // move focus on their own, so they fall through to super untouched. - val mapped = when (event.keyCode) { - KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element - KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss - else -> 0 + // buttons to the navigation the focus system / back stack understand; D-pad *keys* + // already move focus on their own, so they fall through to super untouched. + when (event.keyCode) { + // B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a + // BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default + // onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire. + KeyEvent.KEYCODE_BUTTON_B -> { + if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed() + return true + } + // A → activate the focused element (the focus system understands DPAD_CENTER). + KeyEvent.KEYCODE_BUTTON_A -> + return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER)) } - if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped)) } } return super.dispatchKeyEvent(event) @@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() { if (dir != lastNavDir) { lastNavDir = dir if (dir != 0) { + lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir)) super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir)) return true @@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() { } return super.dispatchGenericMotionEvent(event) } + + /** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */ + private fun isConsoleNavKey(kc: Int): Boolean = when (kc) { + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, + -> true + else -> KeyEvent.isGamepadButton(kc) + } + + private companion object { + /** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */ + val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB + } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 368bfd1..152c0fc 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -41,6 +41,19 @@ data class Settings( * understand touch. Mirrors the Apple client's TouchInputMode. */ val touchMode: TouchMode = TouchMode.TRACKPAD, + /** + * Swap the whole home screen for the controller-optimized "console" UI (the host carousel + + * gamepad chrome) whenever a controller is connected — mirrors the Apple client's + * `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached. + * A TV (leanback) is always in this mode regardless (its remote/pad is the only input). + */ + val gamepadUiEnabled: Boolean = true, + /** + * Show the experimental game-library browser (the coverflow reached with Y from a saved host). + * Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple + * client's `libraryEnabled`. + */ + val libraryEnabled: Boolean = true, ) /** [Settings.touchMode] values; persisted by name. */ @@ -67,6 +80,8 @@ class SettingsStore(context: Context) { ?.let { name -> TouchMode.entries.firstOrNull { it.name == name } } // Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct). ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, + gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), + libraryEnabled = prefs.getBoolean(K_LIBRARY, true), ) fun save(s: Settings) { @@ -83,6 +98,8 @@ class SettingsStore(context: Context) { .putBoolean(K_MIC, s.micEnabled) .putBoolean(K_HUD, s.statsHudEnabled) .putString(K_TOUCH_MODE, s.touchMode.name) + .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) + .putBoolean(K_LIBRARY, s.libraryEnabled) .apply() } @@ -99,6 +116,8 @@ class SettingsStore(context: Context) { const val K_MIC = "mic_enabled" const val K_HUD = "stats_hud_enabled" const val K_TOUCH_MODE = "touch_mode" + const val K_GAMEPAD_UI = "gamepad_ui_enabled" + const val K_LIBRARY = "library_enabled" /** Legacy Boolean the enum replaced — read once as the migration default, never written. */ const val K_TRACKPAD = "trackpad_mode" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 489819b..351f2e4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -5,44 +5,79 @@ import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat /** - * Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted - * immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native" - * resolve from the device display at connect time. + * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category + * subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen + * it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits + * persist immediately via [onChange]; [onBack] returns to the connect screen. */ @Composable -fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) { +fun SettingsScreen( + initial: Settings, + onChange: (Settings) -> Unit, + onBack: () -> Unit, +) { var s by remember { mutableStateOf(initial) } val context = LocalContext.current var showLicenses by remember { mutableStateOf(false) } @@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - onChange(next) } - BackHandler(onBack = onBack) - // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. val micLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> update(s.copy(micEnabled = granted)) } + val onMicChange: (Boolean) -> Unit = { on -> + when { + !on -> update(s.copy(micEnabled = false)) + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true)) + else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + // Deep sub-screens replace the whole settings surface (they carry their own back). if (showLicenses) { LicensesScreen(onBack = { showLicenses = false }) return @@ -68,160 +110,314 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - return } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - Text("Settings", style = MaterialTheme.typography.headlineMedium) + // Selected category persists across rotation (stored by name — null = the bare list on a phone). + var selectedName by rememberSaveable { mutableStateOf(null) } + val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } } - val (nw, nh, nhz) = nativeDisplayMode(context) + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 640.dp + // A two-column layout must never show an empty detail — land on the first category. + LaunchedEffect(twoPane) { + if (twoPane && selected == null) selectedName = SettingsCategory.Display.name + } - SettingsGroup("Display") { - SettingDropdown( - label = "Resolution", - options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> - (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) - }, - selected = s.width to s.height, - ) { (w, h) -> update(s.copy(width = w, height = h)) } - - SettingDropdown( - label = "Refresh rate", - options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) }, - selected = s.hz, - ) { hz -> update(s.copy(hz = hz)) } - - SettingDropdown( - label = "Bitrate", - options = BITRATE_OPTIONS, - selected = s.bitrateKbps, - ) { kbps -> update(s.copy(bitrateKbps = kbps)) } - - SettingDropdown( - label = "Video codec", - options = CODEC_OPTIONS, - selected = s.codec, - ) { c -> update(s.copy(codec = c)) } - - // HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle - // is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the - // panel would mis-tone-map. The capability is fixed for the device, so read it once. - val hdrCapable = remember { displaySupportsHdr(context) } - ToggleRow( - title = "HDR", - subtitle = if (hdrCapable) { - "Stream 10-bit HDR (BT.2020 PQ) when the host supports it" - } else { - "This display can't present HDR10 — streams stay SDR" - }, - checked = s.hdrEnabled && hdrCapable, - enabled = hdrCapable, - onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) }, + val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back -> + CategoryDetail( + category = cat, + settings = s, + onChange = ::update, + context = context, + onMicChange = onMicChange, + onOpenControllers = { showControllers = true }, + onOpenLicenses = { showLicenses = true }, + onBack = back, ) } - SettingsGroup("Host") { - SettingDropdown( - label = "Compositor", - options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, - selected = s.compositor, - ) { c -> update(s.copy(compositor = c)) } - - SettingDropdown( - label = "Controller type", - options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, - selected = s.gamepad, - ) { g -> update(s.copy(gamepad = g)) } - - ClickableRow( - title = "Connected controllers", - subtitle = "What the app detects, with a live input test", - onClick = { showControllers = true }, - ) - } - - SettingsGroup("Audio") { - SettingDropdown( - label = "Audio channels", - options = AUDIO_CHANNEL_OPTIONS, - selected = s.audioChannels, - ) { ch -> update(s.copy(audioChannels = ch)) } - - ToggleRow( - title = "Microphone", - subtitle = "Send your mic to the host's virtual microphone", - checked = s.micEnabled, - onCheckedChange = { on -> - when { - !on -> update(s.copy(micEnabled = false)) - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true)) - else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO) + if (twoPane) { + BackHandler(onBack = onBack) + Row(Modifier.fillMaxSize()) { + CategoryList( + selected = selected, + twoPane = true, + onSelect = { selectedName = it.name }, + modifier = Modifier.width(300.dp).fillMaxHeight(), + ) + VerticalDivider() + Box(Modifier.weight(1f).fillMaxHeight()) { + // Cross-fade the detail pane as the selected category changes. + AnimatedContent( + targetState = selected ?: SettingsCategory.Display, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "SettingsPane", + ) { cat -> detail(cat, null) } + } + } + } else { + // Compact: the category list pushes to a full-screen detail and back, like the iOS / + // Android system settings — a horizontal slide that tracks the drill-in direction. + BackHandler { if (selected != null) selectedName = null else onBack() } + AnimatedContent( + targetState = selected, + transitionSpec = { + if (targetState != null) { + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + } else { + slideInHorizontally { -it } + fadeIn() togetherWith + slideOutHorizontally { it } + fadeOut() } }, - ) - } - - SettingsGroup("Touch input") { - SettingDropdown( - label = "Touch input", - options = TOUCH_MODE_OPTIONS, - selected = s.touchMode, - onSelect = { mode -> update(s.copy(touchMode = mode)) }, - ) - Text( - "Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " + - "tap right-clicks, two fingers scroll, tap-then-drag holds the button. " + - "Direct pointer: the cursor jumps to your finger. Touch passthrough: real " + - "multi-touch reaches the host, for apps that understand touch.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) - } - - SettingsGroup("Overlay") { - ToggleRow( - title = "Stats overlay", - subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", - checked = s.statsHudEnabled, - onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, - ) - } - - SettingsGroup("About") { - ClickableRow( - title = "Open-source licenses", - subtitle = "Third-party notices and credits", - onClick = { showLicenses = true }, - ) + label = "SettingsPush", + ) { sel -> + if (sel == null) { + CategoryList( + selected = null, + twoPane = false, + onSelect = { selectedName = it.name }, + modifier = Modifier.fillMaxSize(), + ) + } else { + detail(sel) { selectedName = null } + } + } } } } -/** A titled group of settings rendered inside an outlined card. */ +/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */ +enum class SettingsCategory(val title: String, val icon: ImageVector) { + Display("Display", Icons.Filled.Tv), + Audio("Audio", Icons.Filled.VolumeUp), + Controls("Controls", Icons.Filled.SportsEsports), + Interface("Interface", Icons.Filled.Tune), + About("About", Icons.Filled.Info), +} + +/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */ @Composable -private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { +private fun CategoryList( + selected: SettingsCategory?, + twoPane: Boolean, + onSelect: (SettingsCategory) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( - title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp), + "Settings", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), ) - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - content = content, - ) + SettingsCategory.entries.forEach { cat -> + val highlighted = twoPane && selected == cat + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .clickable { onSelect(cat) } + .padding(horizontal = 14.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + cat.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 16.dp), + ) + Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (!twoPane) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } +/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */ +@Composable +private fun CategoryDetail( + category: SettingsCategory, + settings: Settings, + onChange: (Settings) -> Unit, + context: android.content.Context, + onMicChange: (Boolean) -> Unit, + onOpenControllers: () -> Unit, + onOpenLicenses: () -> Unit, + onBack: (() -> Unit)?, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (onBack != null) { + IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + Text(category.title, style = MaterialTheme.typography.headlineMedium) + } + when (category) { + SettingsCategory.Display -> DisplaySettings(settings, onChange, context) + SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange) + SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers) + SettingsCategory.Interface -> InterfaceSettings(settings, onChange) + SettingsCategory.About -> AboutSettings(onOpenLicenses) + } + } +} + +@Composable +private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) { + val (nw, nh, nhz) = nativeDisplayMode(context) + SettingsCard { + SettingDropdown( + label = "Resolution", + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) }, + selected = s.width to s.height, + ) { (w, h) -> update(s.copy(width = w, height = h)) } + + SettingDropdown( + label = "Refresh rate", + options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) }, + selected = s.hz, + ) { hz -> update(s.copy(hz = hz)) } + + SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps -> + update(s.copy(bitrateKbps = kbps)) + } + + SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c -> + update(s.copy(codec = c)) + } + + // HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is + // disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps. + val hdrCapable = remember { displaySupportsHdr(context) } + ToggleRow( + title = "HDR", + subtitle = if (hdrCapable) { + "Stream 10-bit HDR (BT.2020 PQ) when the host supports it" + } else { + "This display can't present HDR10 — streams stay SDR" + }, + checked = s.hdrEnabled && hdrCapable, + enabled = hdrCapable, + onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) }, + ) + + SettingDropdown( + label = "Compositor", + options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, + selected = s.compositor, + ) { c -> update(s.copy(compositor = c)) } + } +} + +@Composable +private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) { + SettingsCard { + SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch -> + update(s.copy(audioChannels = ch)) + } + ToggleRow( + title = "Microphone", + subtitle = "Send your mic to the host's virtual microphone", + checked = s.micEnabled, + onCheckedChange = onMicChange, + ) + } +} + +@Composable +private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) { + SettingsCard { + SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode -> + update(s.copy(touchMode = mode)) + } + Text( + "Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " + + "right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " + + "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " + + "host, for apps that understand touch.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SettingsCard { + SettingDropdown( + label = "Controller type", + options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, + selected = s.gamepad, + ) { g -> update(s.copy(gamepad = g)) } + ClickableRow( + title = "Connected controllers", + subtitle = "What the app detects, with a live input test", + onClick = onOpenControllers, + ) + } +} + +@Composable +private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) { + SettingsCard { + ToggleRow( + title = "Controller-optimized UI", + subtitle = "Switch to the console home (host carousel) when a controller is connected", + checked = s.gamepadUiEnabled, + onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) }, + ) + ToggleRow( + title = "Game library", + subtitle = "Browse a paired host's game library (press Y on a saved host)", + checked = s.libraryEnabled, + onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) }, + ) + ToggleRow( + title = "Stats overlay", + subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", + checked = s.statsHudEnabled, + onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, + ) + } +} + +@Composable +private fun AboutSettings(onOpenLicenses: () -> Unit) { + SettingsCard { + ClickableRow( + title = "Open-source licenses", + subtitle = "Third-party notices and credits", + onClick = onOpenLicenses, + ) + } +} + +/** A group of settings rendered inside an outlined card. */ +@Composable +private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) + } +} + /** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */ @Composable private fun ToggleRow( @@ -265,6 +461,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index ab14f25..9ce71d6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -91,6 +91,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.streamHandle = handle // route hardware keys to this session activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes + activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream + activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. val feedback = GamepadFeedback(handle).also { it.start() } onDispose { @@ -99,6 +101,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.axisMapper?.reset() // release-all so nothing sticks on the host activity?.axisMapper = null activity?.streamHandle = 0L + activity?.requestStreamExit = null + activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh controller?.show(WindowInsetsCompat.Type.systemBars()) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // Release the landscape lock so the rest of the app follows the device/system again. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt index 9e2687e..8858ef3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt @@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) { } else { BrandDark } - MaterialTheme(colorScheme = scheme, content = content) + // Geist Sans across the whole type scale — the brand typeface the website and the Apple client + // already ship (see Type.kt). + MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Type.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Type.kt new file mode 100644 index 0000000..733ce01 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Type.kt @@ -0,0 +1,44 @@ +package io.unom.punktfunk + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight + +// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship). +// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the +// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono +// is intentionally not shipped (the licenses screen's technical block uses the platform monospace). +// +// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt). +val Geist = FontFamily( + Font(R.font.geist_regular, FontWeight.Normal), + Font(R.font.geist_medium, FontWeight.Medium), + Font(R.font.geist_semibold, FontWeight.SemiBold), + Font(R.font.geist_bold, FontWeight.Bold), +) + +/** + * The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no + * `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the + * Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights. + */ +val PunktfunkTypography: Typography = Typography().run { + Typography( + displayLarge = displayLarge.copy(fontFamily = Geist), + displayMedium = displayMedium.copy(fontFamily = Geist), + displaySmall = displaySmall.copy(fontFamily = Geist), + headlineLarge = headlineLarge.copy(fontFamily = Geist), + headlineMedium = headlineMedium.copy(fontFamily = Geist), + headlineSmall = headlineSmall.copy(fontFamily = Geist), + titleLarge = titleLarge.copy(fontFamily = Geist), + titleMedium = titleMedium.copy(fontFamily = Geist), + titleSmall = titleSmall.copy(fontFamily = Geist), + bodyLarge = bodyLarge.copy(fontFamily = Geist), + bodyMedium = bodyMedium.copy(fontFamily = Geist), + bodySmall = bodySmall.copy(fontFamily = Geist), + labelLarge = labelLarge.copy(fontFamily = Geist), + labelMedium = labelMedium.copy(fontFamily = Geist), + labelSmall = labelSmall.copy(fontFamily = Geist), + ) +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt new file mode 100644 index 0000000..070d62a --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeController.kt @@ -0,0 +1,125 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.unom.punktfunk.kit.NativeBridge +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the + * Apple client's `HostWaker`. + * + * A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start + * advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one + * packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible + * "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via + * [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for + * a wake-only); on timeout it parks in a retry/cancel state. One wake at a time. + * + * [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the + * [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]. */ + data class Waking( + val hostName: String, + /** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */ + val connectsAfter: Boolean, + val seconds: Int = 0, + val timedOut: Boolean = false, + ) + + var waking by mutableStateOf(null) + private set + + private var loop: Job? = null + + /** Captured so "Try Again" replays the exact same wait. */ + private var replay: (() -> Unit)? = null + + /** + * Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target + * the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host + * is already up (a race between the caller's check and here). + */ + fun start( + hostName: String, + connectsAfter: Boolean, + macs: List, + lastIp: String, + isOnline: () -> Boolean, + onOnline: () -> Unit, + ) { + if (macs.isEmpty() || isOnline()) { + cancel() + onOnline() + return + } + replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) } + replay?.invoke() + } + + /** Stop waiting and dismiss the overlay (B / Cancel). */ + fun cancel() { + loop?.cancel() + loop = null + replay = null + waking = null + } + + /** Restart the wait after a timeout (A / Try Again). */ + fun retry() { + replay?.invoke() + } + + private fun run( + hostName: String, + connectsAfter: Boolean, + macs: List, + lastIp: String, + isOnline: () -> Boolean, + onOnline: () -> Unit, + ) { + loop?.cancel() + waking = Waking(hostName = hostName, connectsAfter = connectsAfter) + loop = scope.launch { + var elapsed = 0 + while (isActive) { + // Re-send periodically: a single packet can be missed, and some NICs only wake on a + // fresh packet after dropping into a deeper sleep state. + if (elapsed % RESEND_EVERY_S == 0) { + val csv = macs.joinToString(",") + launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) } + } + if (isOnline()) { + waking = null + loop = null + onOnline() + return@launch + } + if (elapsed >= TIMEOUT_S) { + waking = waking?.copy(timedOut = true) + loop = null + return@launch + } + delay(1000) + elapsed++ + waking = waking?.copy(seconds = elapsed) + } + } + } + + companion object { + /** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */ + const val TIMEOUT_S = 90 + + /** Re-send the magic packet this often. */ + const val RESEND_EVERY_S = 6 + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt new file mode 100644 index 0000000..e91292e --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/WakeOverlay.kt @@ -0,0 +1,124 @@ +package io.unom.punktfunk + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The "Waking …" modal shown while [WakeController] brings a sleeping host back — a spinner + a + * live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the + * Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows + * input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once + * timed out) while the touch buttons work for a pointer. + */ +@Composable +fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) { + val w = waker.waking ?: return + + BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait + if (gamepadUi) { + // A retries once timed out; B falls through to the BackHandler above. + GamepadNavEffect2D( + active = true, + onDirection = {}, + onActivate = { if (w.timedOut) waker.retry() }, + ) + } + + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + // Swallow taps so the home behind can't be touched while waking. + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(40.dp) + .widthIn(max = 380.dp) + .clip(RoundedCornerShape(22.dp)) + .background(Color(0xF01A1730)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp)) + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (w.timedOut) { + Icon( + Icons.Filled.Bedtime, + contentDescription = null, + tint = Color.White.copy(alpha = 0.85f), + modifier = Modifier.size(34.dp), + ) + Text( + "${w.hostName} didn't wake", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 19.sp, + textAlign = TextAlign.Center, + ) + Text( + "It may still be booting, or it's powered off / off this network.", + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") } + Button(onClick = { waker.retry() }) { Text("Try Again") } + } + } else { + CircularProgressIndicator(color = Color.White) + Text( + "Waking ${w.hostName}…", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 19.sp, + textAlign = TextAlign.Center, + ) + Text( + "Waiting for it to come online · ${w.seconds}s", + color = Color.White.copy(alpha = 0.6f), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + ) + OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) { + Text(if (w.connectsAfter) "Cancel" else "Stop Waiting") + } + } + } + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index 43b34e9..1e83087 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -59,7 +59,7 @@ fun HostCard( enabled: Boolean, onConnect: () -> Unit, onForget: (() -> Unit)?, - onRename: (() -> Unit)? = null, + onEdit: (() -> Unit)? = null, onWake: (() -> Unit)? = null, ) { // D-pad / controller focus highlight: a clickable card is focusable, but the default state @@ -108,7 +108,7 @@ fun HostCard( StatusPill(status) } - if (onForget != null || onRename != null || onWake != null) { + if (onForget != null || onEdit != null || onWake != null) { var menu by remember { mutableStateOf(false) } Box(modifier = Modifier.align(Alignment.TopEnd)) { IconButton(enabled = enabled, onClick = { menu = true }) { @@ -129,12 +129,12 @@ fun HostCard( }, ) } - if (onRename != null) { + if (onEdit != null) { DropdownMenuItem( - text = { Text("Rename") }, + text = { Text("Edit…") }, onClick = { menu = false - onRename() + onEdit() }, ) } diff --git a/clients/android/app/src/main/res/drawable-hdpi/tv_banner.png b/clients/android/app/src/main/res/drawable-hdpi/tv_banner.png new file mode 100644 index 0000000..85ac4b2 Binary files /dev/null and b/clients/android/app/src/main/res/drawable-hdpi/tv_banner.png differ diff --git a/clients/android/app/src/main/res/drawable-xhdpi/tv_banner.png b/clients/android/app/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 0000000..3ab5b2d Binary files /dev/null and b/clients/android/app/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/clients/android/app/src/main/res/drawable-xxhdpi/tv_banner.png b/clients/android/app/src/main/res/drawable-xxhdpi/tv_banner.png new file mode 100644 index 0000000..77e1b25 Binary files /dev/null and b/clients/android/app/src/main/res/drawable-xxhdpi/tv_banner.png differ diff --git a/clients/android/app/src/main/res/font/geist_bold.otf b/clients/android/app/src/main/res/font/geist_bold.otf new file mode 100644 index 0000000..6ab5615 Binary files /dev/null and b/clients/android/app/src/main/res/font/geist_bold.otf differ diff --git a/clients/android/app/src/main/res/font/geist_medium.otf b/clients/android/app/src/main/res/font/geist_medium.otf new file mode 100644 index 0000000..99fb7c2 Binary files /dev/null and b/clients/android/app/src/main/res/font/geist_medium.otf differ diff --git a/clients/android/app/src/main/res/font/geist_regular.otf b/clients/android/app/src/main/res/font/geist_regular.otf new file mode 100644 index 0000000..8287833 Binary files /dev/null and b/clients/android/app/src/main/res/font/geist_regular.otf differ diff --git a/clients/android/app/src/main/res/font/geist_semibold.otf b/clients/android/app/src/main/res/font/geist_semibold.otf new file mode 100644 index 0000000..277a521 Binary files /dev/null and b/clients/android/app/src/main/res/font/geist_semibold.otf differ diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 7cd3893..e2a5a6e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -83,7 +83,7 @@ internal fun HostsScene() { } item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } items(SAVED) { h -> - HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {}) + HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {}) } item(span = { GridItemSpan(maxLineSpan) }) { Spacer(Modifier.height(12.dp)) diff --git a/clients/android/kit/build.gradle.kts b/clients/android/kit/build.gradle.kts index 6f8e82c..61d0618 100644 --- a/clients/android/kit/build.gradle.kts +++ b/clients/android/kit/build.gradle.kts @@ -15,8 +15,10 @@ android { ndkVersion = ndkVer defaultConfig { - minSdk = 31 - ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated. + // Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV + // boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator. + ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 @@ -28,6 +30,9 @@ android { kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } dependencies { + // mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads). + // OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256. + implementation("com.squareup.okhttp3:okhttp:4.12.0") testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser } @@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) = // find their subtools. val cmd = mutableListOf( "$cargoBin/cargo", "ndk", - "-t", "arm64-v8a", "-t", "x86_64", - // Link against the minSdk-31 sysroot so libaaudio (API 26+) is found. - "--platform", "31", + "-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64", + // Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the + // floor makes the linker reject any accidental >28 hard import (the one API-30 call we + // make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate). + "--platform", "28", "-o", file("src/main/jniLibs").absolutePath, "build", "-p", "punktfunk-client-android", ) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index bc42af8..2c37cf7 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest import android.os.Build import android.os.CombinedVibration import android.os.VibrationEffect +import android.os.Vibrator import android.os.VibratorManager import android.util.Log import android.view.InputDevice @@ -16,7 +17,8 @@ import java.nio.ByteBuffer /** * Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll * threads drain the blocking native pulls and render in Kotlin: rumble → the controller's - * `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive + * `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar / + * player-LED via `LightsManager` (API 33+); adaptive * triggers are parse-validated and logged (Android has no public adaptive-trigger API). * * Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a @@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) { private var hidoutThread: Thread? = null private var vm: VibratorManager? = null + // API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager + // until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor. + private var legacy: Vibrator? = null private var vibratorIds: IntArray = IntArray(0) private var amplitudeControlled = false @@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) { rumbleThread?.interrupt() hidoutThread?.interrupt() runCatching { vm?.cancel() } // drop any held rumble immediately + runCatching { legacy?.cancel() } // Join WITHOUT a timeout. These poll threads dereference the native session handle on every // pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's // onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out @@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) { rgbLight = null playerLight = null vm = null + legacy = null vibratorIds = IntArray(0) } @@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) { Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)") return } - val m = dev.vibratorManager - val ids = m.vibratorIds - if (ids.isEmpty()) { - Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op") - return + if (Build.VERSION.SDK_INT >= 31) { + val m = dev.vibratorManager + val ids = m.vibratorIds + if (ids.isEmpty()) { + Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op") + return + } + vm = m + vibratorIds = ids + amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() } + Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled") + } else { + // API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator. + @Suppress("DEPRECATION") + val v = dev.vibrator + if (!v.hasVibrator()) { + Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op") + return + } + legacy = v + amplitudeControlled = v.hasAmplitudeControl() + Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled") } - vm = m - vibratorIds = ids - amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() } - Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled") } /** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */ private fun renderRumble(low: Int, high: Int) { Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return - val m = vm ?: return val lo = toAmplitude(low) val hi = toAmplitude(high) - if (lo == 0 && hi == 0) { - m.cancel() // (0,0) = stop + val m = vm + if (m != null) { + if (lo == 0 && hi == 0) { + m.cancel() // (0,0) = stop + return + } + val combo = CombinedVibration.startParallel() + if (amplitudeControlled && vibratorIds.size >= 2) { + // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). + if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi)) + if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo)) + } else { + // Single motor or no amplitude control: blend both into one effect. + val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) + for (id in vibratorIds) combo.addVibrator(id, oneShot(a)) + } + runCatching { m.vibrate(combo.combine()) } return } - val combo = CombinedVibration.startParallel() - if (amplitudeControlled && vibratorIds.size >= 2) { - // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). - if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi)) - if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo)) - } else { - // Single motor or no amplitude control: blend both into one effect. - val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) - for (id in vibratorIds) combo.addVibrator(id, oneShot(a)) + // API 28–30 legacy single-motor path: blend both motors into one effect. + val lv = legacy ?: return + if (lo == 0 && hi == 0) { + lv.cancel() // (0,0) = stop + return + } + val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) + runCatching { + lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE)) } - runCatching { m.vibrate(combo.combine()) } } // 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0. diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt new file mode 100644 index 0000000..12b4d1e --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt @@ -0,0 +1,195 @@ +package io.unom.punktfunk.kit.library + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.security.KeyFactory +import java.security.KeyStore +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import java.util.concurrent.TimeUnit +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a +// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the +// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and +// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork +// schema in crates/punktfunk-host/src/library.rs. + +/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */ +const val DEFAULT_MGMT_PORT = 47990 + +/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */ +data class Artwork(val portrait: String?, val header: String?, val hero: String?) { + /** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */ + val posterCandidates: List get() = listOfNotNull(portrait, header, hero) +} + +/** One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). */ +data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) { + val isCustom: Boolean get() = store == "custom" +} + +/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */ +sealed class LibraryResult { + data class Ok(val games: List) : LibraryResult() + data class Unauthorized(val message: String) : LibraryResult() + data class Error(val message: String) : LibraryResult() +} + +object LibraryClient { + /** + * `GET https://
:/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned + * host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank + * value means the host was never connected/paired, so there's nothing authorized to browse. + * BLOCKING — call from a background dispatcher. + */ + fun fetch( + address: String, + mgmtPort: Int = DEFAULT_MGMT_PORT, + certPem: String, + keyPem: String, + fpHex: String, + ): LibraryResult { + if (fpHex.isBlank()) { + return LibraryResult.Unauthorized( + "Connect to this host once first — the library uses the identity created on pairing to authenticate.", + ) + } + val client = try { + mtlsHttpClient(certPem, keyPem, address, fpHex) + } catch (e: Exception) { + return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}") + } + val base = "https://$address:$mgmtPort" + val req = Request.Builder().url("$base/api/v1/library").build() + return try { + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base)) + 401 -> LibraryResult.Unauthorized( + "The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.", + ) + else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.") + } + } + } catch (e: Exception) { + LibraryResult.Error( + "Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.", + ) + } + } + + private fun parse(json: String, base: String): List { + val arr = JSONArray(json) + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val art = o.optJSONObject("art") ?: JSONObject() + out.add( + GameEntry( + id = o.optString("id"), + store = o.optString("store"), + title = o.optString("title"), + art = Artwork( + portrait = resolveArt(str(art, "portrait"), base), + header = resolveArt(str(art, "header"), base), + hero = resolveArt(str(art, "hero"), base), + ), + ), + ) + } + return out + } + + /** A present, non-null, non-blank JSON string field, else null. */ + private fun str(o: JSONObject, key: String): String? = + if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null + + /** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */ + private fun resolveArt(s: String?, base: String): String? = + if (s != null && s.startsWith("/")) base + s else s +} + +/** + * An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by + * SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client + * reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and + * defers to normal public trust for any other origin (an external CDN URL); the hostname verifier + * accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise. + */ +fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient { + val clientCert = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate + val privateKey = parsePrivateKey(keyPem) + + val keyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert)) + } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(keyStore, CharArray(0)) + + // System default trust manager, for non-host (external CDN) origins. + val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + sysTmf.init(null as KeyStore?) + val sysTm = sysTmf.trustManagers.filterIsInstance().first() + + val pinned = fpHex.lowercase() + val trustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) { + if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host + sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust + } + override fun getAcceptedIssuers(): Array = sysTm.acceptedIssuers + } + + val ssl = SSLContext.getInstance("TLS") + ssl.init(kmf.keyManagers, arrayOf(trustManager), null) + + val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier() + val verifier = HostnameVerifier { hostname, session -> + hostname == host || defaultVerifier.verify(hostname, session) + } + + return OkHttpClient.Builder() + .sslSocketFactory(ssl.socketFactory, trustManager) + .hostnameVerifier(verifier) + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() +} + +/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */ +private fun parsePrivateKey(pem: String): PrivateKey { + val body = pem + .replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "") + .replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "") + .replace(Regex("\\s"), "") + val der = Base64.getDecoder().decode(body) + val spec = PKCS8EncodedKeySpec(der) + for (alg in listOf("EC", "RSA", "Ed25519")) { + try { + return KeyFactory.getInstance(alg).generatePrivate(spec) + } catch (_: Exception) { + // try the next algorithm + } + } + throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)") +} + +private fun sha256Hex(der: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt index a651497..5ab3f68 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt @@ -74,6 +74,16 @@ class KnownHostStore(context: Context) { save(h.copy(name = newName)) } + /** + * Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so + * a plain [save] would otherwise leave a stale record under the old key). The caller passes an + * [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form). + */ + fun update(oldAddress: String, oldPort: Int, updated: KnownHost) { + if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort) + save(updated) + } + /** All trusted hosts, name-sorted — backs the saved-hosts list. */ fun all(): List = prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } @@ -89,4 +99,22 @@ class KnownHostStore(context: Context) { mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() }, ) }.getOrNull() + + companion object { + /** + * Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma / + * space / newline separated). Anything that isn't six colon-separated hex octets is dropped; + * an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`. + */ + fun parseMacs(s: String): List = s + .split(',', ';', ' ', '\n', '\t') + .map { it.trim().lowercase() } + .filter { m -> + // Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts + // a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.) + m.split(":").let { o -> + o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } } + } + } + } } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/security/KnownHostStoreTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/security/KnownHostStoreTest.kt new file mode 100644 index 0000000..1b19e58 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/security/KnownHostStoreTest.kt @@ -0,0 +1,33 @@ +package io.unom.punktfunk.kit.security + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for the pure MAC-parsing helper backing the host edit form. */ +class KnownHostStoreTest { + @Test + fun parsesAndNormalizesSingleMac() { + assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF")) + } + + @Test + fun parsesMultipleSeparators() { + val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66") + assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66")) + assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66")) + assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66")) + } + + @Test + fun dropsMalformedEntries() { + // Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC. + assertEquals(emptyList(), KnownHostStore.parseMacs("")) + assertEquals(emptyList(), KnownHostStore.parseMacs("not-a-mac")) + assertEquals(emptyList(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets + assertEquals(emptyList(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex + assertEquals(emptyList(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width + assertEquals(emptyList(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet + assertEquals(emptyList(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets + assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff")) + } +} diff --git a/clients/android/native/Cargo.toml b/clients/android/native/Cargo.toml index 6d76e39..a4f9272 100644 --- a/clients/android/native/Cargo.toml +++ b/clients/android/native/Cargo.toml @@ -34,7 +34,11 @@ android_logger = "0.14" # NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback). # Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode + # audio run entirely in Rust on native threads (the "no async on the hot path" invariant). -ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] } +# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and +# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make +# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not +# linked, so the .so still loads on API 28/29. +ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] } # setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority). libc = "0.2" # Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the diff --git a/clients/android/native/src/decode.rs b/clients/android/native/src/decode.rs index 3d934c7..77b9f62 100644 --- a/clients/android/native/src/decode.rs +++ b/clients/android/native/src/decode.rs @@ -12,11 +12,12 @@ use ndk::media::media_codec::{ OutputBuffer, }; use ndk::media::media_format::MediaFormat; -use ndk::native_window::{FrameRateCompatibility, NativeWindow}; +use ndk::native_window::NativeWindow; use punktfunk_core::client::NativeClient; use punktfunk_core::error::PunktfunkError; use punktfunk_core::session::Frame; use std::collections::VecDeque; +use std::ffi::c_void; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -113,11 +114,13 @@ pub fn run( mode.height ); // Tell the display the stream's refresh so Android can pick a matching display mode and align - // vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying - // ANativeWindow_setFrameRate is always present; non-fatal if the platform declines. - if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) { - log::warn!( - "decode: set_frame_rate({} Hz) failed (non-fatal): {e}", + // vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30, + // above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link + // it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent + // there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it). + if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) { + log::debug!( + "decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)", mode.refresh_hz ); } @@ -340,6 +343,32 @@ fn boost_thread_priority() { } } +/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib +/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load` +/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in +/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol +/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy. +fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool { + // int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility) + type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32; + // SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed — + // process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30), + // checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the + // live `ANativeWindow` this `NativeWindow` owns for the call's duration. + unsafe { + let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW); + if lib.is_null() { + return false; + } + let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr()); + if sym.is_null() { + return false; // device API < 30 — no per-surface frame-rate hint + } + let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym); + set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0 + } +} + /// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns /// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and /// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and