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 7a69e77..a70b1d9 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 @@ -137,11 +137,9 @@ class MainActivity : ComponentActivity() { } } -/** Scaffold mode requested from the host (WxH@Hz). TODO: derive from the display. */ -private val REQUEST_MODE = Triple(1280, 720, 60) - private sealed interface Screen { data object Connect : Screen + data object Settings : Screen data class Stream(val handle: Long) : Screen } @@ -163,15 +161,27 @@ private data class PendingTrust( @Composable private fun App() { + val context = LocalContext.current + val settingsStore = remember { SettingsStore(context) } + var settings by remember { mutableStateOf(settingsStore.load()) } var screen by remember { mutableStateOf(Screen.Connect) } when (val s = screen) { - Screen.Connect -> ConnectScreen(onConnected = { handle -> screen = Screen.Stream(handle) }) + Screen.Connect -> ConnectScreen( + settings = settings, + onConnected = { handle -> screen = Screen.Stream(handle) }, + onOpenSettings = { screen = Screen.Settings }, + ) + Screen.Settings -> SettingsScreen( + initial = settings, + onChange = { settings = it; settingsStore.save(it) }, + onBack = { screen = Screen.Connect }, + ) is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect }) } } @Composable -private fun ConnectScreen(onConnected: (Long) -> Unit) { +private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpenSettings: () -> Unit) { val scope = rememberCoroutineScope() val context = LocalContext.current var host by remember { mutableStateOf("") } @@ -179,7 +189,8 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { var connecting by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) } - val (w, h, hz) = REQUEST_MODE + // The host streams at exactly this mode; "Native" settings resolve from the device display. + val (w, h, hz) = settings.effectiveMode(context) // mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the // onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.) @@ -225,6 +236,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { NativeBridge.nativeConnect( targetHost, targetPort, w, h, hz, id.certPem, id.privateKeyPem, pinHex ?: "", + settings.bitrateKbps, settings.compositor, settings.gamepad, ) } connecting = false @@ -311,6 +323,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { enabled = !connecting && host.isNotBlank() && port.isNotBlank(), onClick = { connect(host.trim(), port.toInt()) }, ) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") } + TextButton(enabled = !connecting, onClick = onOpenSettings) { Text("Settings") } status?.let { Spacer(Modifier.height(12.dp)) Text(it, style = MaterialTheme.typography.bodySmall) 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 new file mode 100644 index 0000000..3282268 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -0,0 +1,125 @@ +package io.unom.punktfunk + +import android.content.Context + +/** + * User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means + * "native display mode" (resolved at connect time from [nativeDisplayMode]); `0` bitrate means the + * host's default. [compositor]/[gamepad] are the `CompositorPref`/`GamepadPref` wire bytes the host + * understands (0 = Auto). Mirrors the Linux/Apple clients' settings. + */ +data class Settings( + val width: Int = 0, + val height: Int = 0, + val hz: Int = 0, + val bitrateKbps: Int = 0, + val compositor: Int = 0, + val gamepad: Int = 0, +) + +/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ +class SettingsStore(context: Context) { + private val prefs = + context.applicationContext.getSharedPreferences("punktfunk_settings", Context.MODE_PRIVATE) + + fun load(): Settings = Settings( + width = prefs.getInt(K_W, 0), + height = prefs.getInt(K_H, 0), + hz = prefs.getInt(K_HZ, 0), + bitrateKbps = prefs.getInt(K_BITRATE, 0), + compositor = prefs.getInt(K_COMPOSITOR, 0), + gamepad = prefs.getInt(K_GAMEPAD, 0), + ) + + fun save(s: Settings) { + prefs.edit() + .putInt(K_W, s.width) + .putInt(K_H, s.height) + .putInt(K_HZ, s.hz) + .putInt(K_BITRATE, s.bitrateKbps) + .putInt(K_COMPOSITOR, s.compositor) + .putInt(K_GAMEPAD, s.gamepad) + .apply() + } + + private companion object { + const val K_W = "width" + const val K_H = "height" + const val K_HZ = "hz" + const val K_BITRATE = "bitrate_kbps" + const val K_COMPOSITOR = "compositor" + const val K_GAMEPAD = "gamepad" + } +} + +/** + * The device's native display mode as a landscape `(width, height, hz)` — the long edge is the + * width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read. + * [context] must be a visual (Activity) context. + */ +fun nativeDisplayMode(context: Context): Triple { + // getDisplay() throws on a non-visual context rather than returning null — guard it. + val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60) + val mode = display.mode + val w = mode.physicalWidth + val h = mode.physicalHeight + val hz = mode.refreshRate.toInt().coerceAtLeast(1) + return Triple(maxOf(w, h), minOf(w, h), hz) +} + +/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */ +fun Settings.effectiveMode(context: Context): Triple { + val native = nativeDisplayMode(context) + val w = if (width > 0) width else native.first + val h = if (height > 0) height else native.second + val hz = if (hz > 0) hz else native.third + return Triple(w, h, hz) +} + +// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- + +/** (width, height, label). `(0,0)` = native display. */ +val RESOLUTION_OPTIONS = listOf( + Triple(0, 0, "Native display"), + Triple(1280, 720, "1280 × 720"), + Triple(1920, 1080, "1920 × 1080"), + Triple(2560, 1440, "2560 × 1440"), + Triple(3840, 2160, "3840 × 2160"), +) + +/** (hz, label). `0` = native refresh. */ +val REFRESH_OPTIONS = listOf( + 0 to "Native", + 30 to "30 Hz", + 60 to "60 Hz", + 90 to "90 Hz", + 120 to "120 Hz", + 144 to "144 Hz", + 165 to "165 Hz", + 240 to "240 Hz", +) + +/** (kbps, label). `0` = host default. */ +val BITRATE_OPTIONS = listOf( + 0 to "Automatic", + 10_000 to "10 Mbps", + 20_000 to "20 Mbps", + 50_000 to "50 Mbps", + 100_000 to "100 Mbps", +) + +/** index = CompositorPref wire byte. */ +val COMPOSITOR_OPTIONS = listOf( + "Automatic", + "KWin (KDE Plasma)", + "wlroots (Sway / Hyprland)", + "Mutter (GNOME)", + "gamescope", +) + +/** index = GamepadPref wire byte. */ +val GAMEPAD_OPTIONS = listOf( + "Automatic", + "Xbox 360", + "DualSense", +) 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 new file mode 100644 index 0000000..b21b039 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -0,0 +1,125 @@ +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.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.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +/** + * Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect + * screen. Resolution/refresh "Native" resolve from the device display at connect time. + */ +@Composable +fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) { + var s by remember { mutableStateOf(initial) } + val context = LocalContext.current + fun update(next: Settings) { + s = next + onChange(next) + } + + BackHandler(onBack = onBack) + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium) + + val (nw, nh, nhz) = nativeDisplayMode(context) + 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 = "Compositor (virtual-display host backend)", + 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)) } + + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onBack) { Text("Done") } + } +} + +/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingDropdown( + label: String, + options: List>, + selected: T, + onSelect: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = options.firstOrNull { it.first == selected }?.second + ?: options.firstOrNull()?.second.orEmpty() + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (value, lbl) -> + DropdownMenuItem( + text = { Text(lbl) }, + onClick = { + onSelect(value) + expanded = false + }, + ) + } + } + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index dfe1b97..28772f5 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -27,7 +27,10 @@ object NativeBridge { /** * Connect, presenting [certPem]/[keyPem] (both empty = anonymous) and pinning [pinHex] (empty = * trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch → - * `0`). Returns an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose]. + * `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at + * exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the + * `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0` + * on failure. Pair with exactly one [nativeClose]. */ external fun nativeConnect( host: String, @@ -38,6 +41,9 @@ object NativeBridge { certPem: String, keyPem: String, pinHex: String, + bitrateKbps: Int, + compositorPref: Int, + gamepadPref: Int, ): Long /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ diff --git a/crates/punktfunk-android/src/session.rs b/crates/punktfunk-android/src/session.rs index 87282aa..00b5781 100644 --- a/crates/punktfunk-android/src/session.rs +++ b/crates/punktfunk-android/src/session.rs @@ -109,9 +109,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde } } -/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex): Long`. `certPem`/ -/// `keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU -/// (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). +/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, +/// compositorPref, gamepadPref): Long`. `certPem`/`keyPem` empty = anonymous, else presented as the +/// persistent identity. `pinHex` empty = TOFU (read `nativeHostFingerprint` after), else 64-hex +/// SHA-256 to pin the host (mismatch → 0). `bitrateKbps` 0 = host default. `compositorPref`/ +/// `gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes (0 = Auto; unknown → Auto). /// Returns an opaque handle, or 0 on failure (logged). #[no_mangle] #[allow(clippy::too_many_arguments)] @@ -126,6 +128,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo cert_pem: JString<'local>, key_pem: JString<'local>, pin_hex: JString<'local>, + bitrate_kbps: jint, + compositor_pref: jint, + gamepad_pref: jint, ) -> jlong { let host: String = match env.get_string(&host) { Ok(s) => s.into(), @@ -163,12 +168,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo &host, port as u16, mode, - CompositorPref::Auto, - GamepadPref::Auto, - 0, // bitrate_kbps: host default - None, // launch: default app - pin, // Some → Crypto on host-fp mismatch - identity, // owned (cert, key) PEM, or None (anonymous) + CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8), + GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8), + bitrate_kbps.max(0) as u32, // 0 = host default + None, // launch: default app + pin, // Some → Crypto on host-fp mismatch + identity, // owned (cert, key) PEM, or None (anonymous) Duration::from_secs(10), ) { Ok(client) => {