feat(android): the panel is pinned to the stream's refresh, and touch skips the vsync batch

Three quick latency wins for phones, ahead of the presenter rebuild:

- setStreamDisplayMode: the window-level preferredDisplayModeId is pinned to
  the stream's refresh (exact rate, else the smallest integer multiple, else
  the highest available) for the session. The surface-level frame-rate hint
  alone is advisory and some OEM refresh governors (Nothing OS's LTPO logic
  among them) ignore it for third-party apps — leaving a 120 Hz session
  presenting on a 60/90 Hz panel. nativeVideoSize gained a trailing
  refreshHz element for this (old readers index only 0/1). TV keeps the
  native HDMI mode switch instead.
- The surface hint itself now passes compatibility = FIXED_SOURCE on every
  form factor: the stream is fixed-rate video the client cannot re-pace;
  DEFAULT invited governors to not switch.
- requestUnbufferedDispatch(SOURCE_CLASS_POINTER) on the hosting view while
  streaming: touch/pointer events were vsync-batched — up to a frame of
  input latency the stream shouldn't pay.
- The HUD polls the panel's live refresh each second and flags '⚠ panel N Hz'
  when it sits below the stream rate, so an unpinned panel is visible instead
  of reading as inexplicable judder. Stale nativeStartVideo kdoc (low-latency
  'off, the default') corrected — it defaults ON under low_latency_mode_v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:44:09 +02:00
co-authored by Claude Fable 5
parent 2748b84933
commit 87b6fa8813
6 changed files with 115 additions and 22 deletions
@@ -444,8 +444,8 @@ class MainActivity : ComponentActivity() {
/**
* 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.
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
@@ -454,6 +454,48 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
* pulldown), else the highest available. Same-resolution modes only.
*
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
* the pin (the pre-pin behaviour).
*/
fun setStreamDisplayMode(hz: Int) {
if (hz <= 0) {
setConsoleHighRefreshRate(false)
return
}
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val current = disp?.mode ?: return
val sameRes = disp.supportedModes.filter {
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
}
fun multiple(rate: Float): Int {
val k = (rate / hz).toInt()
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
}
val target = sameRes.minWithOrNull(
compareBy(
{
when {
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
else -> 2 // no relation — prefer highest so at least nothing is halved
}
},
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
),
) ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -46,12 +46,20 @@ internal fun StatsOverlay(
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
/**
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
* otherwise reads as inexplicable judder and an extra refresh of latency.
*/
panelHz: Float = 0f,
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
@@ -65,12 +73,12 @@ internal fun StatsOverlay(
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid) + profileTag, Color.White)
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
return@Column
}
statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
Color.White,
)
if (detailed && decoderLabel.isNotEmpty()) {
@@ -78,6 +78,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current
val activity = context as? MainActivity
// The View hosting this composition — the one that receives the stream's touch/pointer events
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
// requested.
val composeView = androidx.compose.ui.platform.LocalView.current
val window = activity?.window
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
@@ -100,6 +104,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
var panelHz by remember { mutableStateOf(0f) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -121,6 +128,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
}
@@ -305,7 +313,22 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
} else {
null
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
val streamHz = NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0
if (isTv) {
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
} else {
activity?.setStreamDisplayMode(streamHz)
}
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
// Undone by passing 0 on the way out (API 30+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
}
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
@@ -435,6 +458,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
}
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
controller?.show(WindowInsetsCompat.Type.systemBars())
@@ -555,6 +581,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
stats?.let {
StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
panelHz,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
@@ -184,10 +184,12 @@ object NativeBridge {
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
* Cheap; UI-safe.
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
* Resolved at the handshake, so it is known before the first frame — the stream view sizes
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
* (an older native lib returns only `[width, height]` — index defensively). Fixed for the
* session; read once. Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
@@ -204,8 +206,8 @@ object NativeBridge {
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). No-op if already started.
+14 -6
View File
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
/// phone. Falls through to the 2-arg hint on API 30.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
/// phones/tablets and the universal fallback.
///
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
if lib.is_null() {
return false;
}
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
const FIXED_SOURCE: i8 = 1;
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
);
if !sym.is_null() {
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
}
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
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
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
}
}
+11 -5
View File
@@ -264,10 +264,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
/// on the host build too. Cheap; safe on the UI thread.
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
@@ -281,7 +283,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mode = h.client.mode();
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
let buf: [i32; 3] = [
mode.width as i32,
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),