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 d49f989..a380c10 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 @@ -175,9 +175,9 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { status = "Connecting to $targetHost:$targetPort…" discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { - // Advertise HDR only when this device's display can present it (else the host sends a - // proper SDR stream rather than PQ the panel would mis-tone-map). - val hdrEnabled = displaySupportsHdr(context) + // Advertise HDR only when the user enabled it AND this device's display can present it + // (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map). + val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID // (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An // explicit choice is passed through unchanged. @@ -224,7 +224,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { status = null discovery.stop() // free the Wi-Fi radio before the (parked) stream session scope.launch { - val hdrEnabled = displaySupportsHdr(context) + val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) val gamepadPref = Gamepad.resolvePref(settings.gamepad) // Pin the advertised fingerprint for a discovered host (defence against an impostor while // we wait); a manually-typed host has none, so trust-on-first-use. 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 ab970e1..01479be 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 @@ -14,6 +14,13 @@ data class Settings( val height: Int = 0, val hz: Int = 0, val bitrateKbps: Int = 0, + /** + * Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that + * can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never + * advertised regardless, so the host sends a proper 8-bit BT.709 stream rather than PQ the panel + * would mis-tone-map. Turning this off forces SDR even on a capable panel. + */ + val hdrEnabled: Boolean = true, val compositor: Int = 0, val gamepad: Int = 0, /** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it @@ -40,6 +47,7 @@ class SettingsStore(context: Context) { height = prefs.getInt(K_H, 0), hz = prefs.getInt(K_HZ, 0), bitrateKbps = prefs.getInt(K_BITRATE, 0), + hdrEnabled = prefs.getBoolean(K_HDR, true), compositor = prefs.getInt(K_COMPOSITOR, 0), gamepad = prefs.getInt(K_GAMEPAD, 0), audioChannels = prefs.getInt(K_AUDIO_CH, 2), @@ -54,6 +62,7 @@ class SettingsStore(context: Context) { .putInt(K_H, s.height) .putInt(K_HZ, s.hz) .putInt(K_BITRATE, s.bitrateKbps) + .putBoolean(K_HDR, s.hdrEnabled) .putInt(K_COMPOSITOR, s.compositor) .putInt(K_GAMEPAD, s.gamepad) .putInt(K_AUDIO_CH, s.audioChannels) @@ -68,6 +77,7 @@ class SettingsStore(context: Context) { const val K_H = "height" const val K_HZ = "hz" const val K_BITRATE = "bitrate_kbps" + const val K_HDR = "hdr_enabled" const val K_COMPOSITOR = "compositor" const val K_GAMEPAD = "gamepad" const val K_AUDIO_CH = "audio_channels" 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 383a7c5..5447fd5 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 @@ -94,6 +94,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - options = BITRATE_OPTIONS, selected = s.bitrateKbps, ) { kbps -> update(s.copy(bitrateKbps = kbps)) } + + // 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)) }, + ) } SettingsGroup("Host") { @@ -181,24 +197,31 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> } } -/** A title + subtitle on the left, a Switch on the right. */ +/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */ @Composable private fun ToggleRow( title: String, subtitle: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, ) { + // Dim the labels when disabled so the row reads as inactive (the Switch dims itself). + val labelAlpha = if (enabled) 1f else 0.38f Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha), + ) Text( subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha), ) } - Switch(checked = checked, onCheckedChange = onCheckedChange) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } } 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 a9a6fd3..a9b0748 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 @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.Manifest +import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.view.SurfaceHolder import android.view.SurfaceView @@ -102,6 +103,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.hide(WindowInsetsCompat.Type.systemBars()) } + // Lock to landscape while streaming — the host streams a landscape desktop, so pin the device + // there (either landscape direction is fine) and stop it rotating to portrait mid-session. The + // activity declares configChanges=orientation, so this re-lays out the surface in place without + // recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op. + // The prior request is captured and restored on the way out. + val priorOrientation = activity?.requestedOrientation + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.streamHandle = handle // route hardware keys to this session activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. @@ -114,6 +122,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.streamHandle = 0L 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. + activity?.requestedOrientation = + priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED // Leaving the stream: stop the mic + audio + decode threads and tear down the session. NativeBridge.nativeStopMic(handle) NativeBridge.nativeStopAudio(handle) @@ -314,9 +325,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { } /** - * The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from + * The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from * [NativeBridge.nativeVideoStats]: - * `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`. + * `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries, + * colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the + * negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it. */ @Composable internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { @@ -338,6 +351,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { fontFamily = FontFamily.Monospace, fontSize = 12.sp, ) + videoFeedLine(s)?.let { feed -> + Text( + feed, + color = Color.White, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + } if (latValid) { val tag = if (skew) "" else " (same-host)" Text( @@ -357,3 +378,31 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { } } } + +/** + * Format the negotiated video-feed descriptor from the trailing four stats doubles + * `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g. + * `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles) + * so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else + * SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The + * Android decoder is always HEVC (`video/hevc`). + */ +private fun videoFeedLine(s: DoubleArray): String? { + if (s.size < 14) return null + val bitDepth = s[10].toInt() + val primaries = s[11].toInt() + val transfer = s[12].toInt() + val chromaIdc = s[13].toInt() + val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit" + val (dynamicRange, colorSpace) = when (transfer) { + 16 -> "HDR" to "BT.2020 PQ" + 18 -> "HDR" to "BT.2020 HLG" + else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709" + } + val chromaLabel = when (chromaIdc) { + 3 -> "4:4:4" + 2 -> "4:2:2" + else -> "4:2:0" + } + return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel" +} 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 df0b7ae..2dfd79a 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 @@ -186,9 +186,11 @@ internal fun StreamScene() { Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))), ), ) { - // [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped] + // [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped, + // bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit + // BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line. StatsOverlay( - doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0), + doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0), Modifier.align(Alignment.TopStart).padding(12.dp), ) } 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 1c18e19..2cf3625 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 @@ -103,9 +103,12 @@ object NativeBridge { /** * Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs. - * Returns 10 doubles: - * `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]` - * (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window. + * Returns 14 doubles: + * `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped, + * bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]` + * (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth + * 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz; + * each call resets the measurement window. */ external fun nativeVideoStats(handle: Long): DoubleArray? diff --git a/clients/android/native/src/session.rs b/clients/android/native/src/session.rs index a274daa..c12f72e 100644 --- a/clients/android/native/src/session.rs +++ b/clients/android/native/src/session.rs @@ -409,11 +409,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( } /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD. -/// Returns 10 doubles -/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]` -/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI; -/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it -/// links on the host build too (Kotlin only ever calls it on device). +/// Returns 14 doubles +/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped, +/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]` +/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or +/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement +/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too +/// (Kotlin only ever calls it on device). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( env: JNIEnv, @@ -431,7 +433,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( None => return std::ptr::null_mut(), // not streaming → no stats }; let mode = h.client.mode(); - let buf: [f64; 10] = [ + let color = h.client.color; + let buf: [f64; 14] = [ snap.fps, snap.mbps, snap.lat_p50_ms, @@ -442,6 +445,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( mode.height as f64, mode.refresh_hz as f64, h.client.frames_dropped() as f64, + // Video-feed properties the host resolved at the handshake (Welcome): encode bit depth + // (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a + // colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC + // chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated. + h.client.bit_depth as f64, + color.primaries as f64, + color.transfer as f64, + h.client.chroma_format as f64, ]; let arr = match env.new_double_array(buf.len() as jsize) { Ok(a) => a,