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 ffe679d0..7022b5b8 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 @@ -96,7 +96,11 @@ internal fun ControllersScreen( InputDevice.getDeviceIds() .toList() .mapNotNull { InputDevice.getDevice(it) } - .filter { !it.isVirtual && !Gamepad.isPad(it) } + // Everything real that is NOT counted as a controller — including a device that claims + // a pad source with no pad hardware behind it, which the Gamepads list above now + // rejects. One list or the other, never neither: this screen is where someone looks + // when the client's idea of "a pad is attached" disagrees with the room. + .filter { !it.isVirtual && !Gamepad.looksLikeController(it) } } DisposableEffect(Unit) { val im = context.getSystemService(InputManager::class.java) 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 8f94ec47..3e35bf07 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 @@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + // The view's CURRENT pixel size, for the ASurfaceControl layer's + // destination rect. It is reported here and not only at + // surfaceCreated because the view grows a frame or two after the + // stream screen appears — hiding the system bars and switching on + // cutout drawing both resize it, and neither recreates the surface. + // A layer left on the start-up rect paints the picture small, in the + // top-left corner. The view's own size, not the buffer geometry in + // `width`/`height`: the layer composites in the view's space. + NativeBridge.nativeVideoSurfaceSize( + handle, this@apply.width, this@apply.height, + ) // Re-assert the frame-rate vote: a buffer-geometry change can reset // the surface's frame-rate setting on some OEM builds, silently // dropping the 120 Hz pin mid-stream. Mirrors the native hint's diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index b19171b9..7b3cf6e5 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -193,9 +193,53 @@ object Gamepad { s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK } - /** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */ - fun pads(): List = - InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) } + /** + * True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a + * REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons. + * + * [isPad] alone answers "did this event come from a pad source", which is the right question + * for ROUTING an event and the wrong one for "is a controller attached". Devices publish + * inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM + * game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is + * enough to pin the console UI on forever: a pad that was never there cannot disconnect, so + * "With a controller" has no way back to the touch UI. + * + * The capability probe is what separates them: a source class is a claim, a stick or a face + * button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD` + * and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays + * the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic + * device), which is worth excluding but catches none of the above. + */ + fun looksLikeController(dev: InputDevice?): Boolean { + val d = dev ?: return false + return looksLikeController( + padSource = isPad(d), + virtual = d.isVirtual, + hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null || + d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null, + // `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick, + // a d-pad-only pad) still counts. + hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B) + .any { it }, + ) + } + + /** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at + * (an [InputDevice] cannot be built off a device). */ + fun looksLikeController( + padSource: Boolean, + virtual: Boolean, + hasStick: Boolean, + hasFaceButtons: Boolean, + ): Boolean = padSource && !virtual && (hasStick || hasFaceButtons) + + /** + * All connected controllers, in system enumeration order — the devices that answer "is a pad + * attached", so the filter is [looksLikeController] rather than the looser [isPad]. + */ + fun pads(): List = InputDevice.getDeviceIds().toList() + .mapNotNull { InputDevice.getDevice(it) } + .filter { looksLikeController(it) } /** First connected gamepad/joystick [InputDevice], or null when none is attached. */ fun firstPad(): InputDevice? = pads().firstOrNull() 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 9032e17f..f5f9ed9b 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 @@ -298,6 +298,18 @@ object NativeBridge { surfaceH: Int, ) + /** + * Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`. + * + * The ASurfaceControl present backend composites the picture into exactly this rectangle, and + * the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and + * switches the window to draw into the display cutout a frame or two later, and neither + * recreates the surface. Without this the layer keeps painting at its start-up size in the + * corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle; + * cheap (one atomic store), UI-safe. + */ + external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int) + /** Stop + join the decode thread without closing the session. No-op on `0`. */ external fun nativeStopVideo(handle: Long) diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt new file mode 100644 index 00000000..60091ee9 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt @@ -0,0 +1,67 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The truth table behind "is a controller attached" — the question the console UI's + * "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the + * console UI on with no pad in the room, and no setting short of turning the whole thing off can + * dismiss it, because the phantom pad never disconnects. + */ +class PadPresenceTest { + + /** A real pad: the source class plus hardware behind it, in either of the two shapes. */ + @Test + fun realPadsCount() { + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = true, hasFaceButtons = true, + ), + ) + // An arcade stick / d-pad-only pad — buttons, no analog stick. + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = false, hasFaceButtons = true, + ), + ) + // A wheel or flight stick — axes, no A/B. + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = true, hasFaceButtons = false, + ), + ) + } + + /** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the + * gamepad source class. This is the field report — the console UI that could not be dismissed. */ + @Test + fun virtualDevicesAreNotControllers() { + assertFalse( + Gamepad.looksLikeController( + padSource = true, virtual = true, hasStick = true, hasFaceButtons = true, + ), + ) + } + + /** A device that claims a pad source with nothing behind it is not a pad either. */ + @Test + fun aSourceClaimWithoutHardwareIsNotAController() { + assertFalse( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = false, hasFaceButtons = false, + ), + ) + } + + /** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */ + @Test + fun nonPadSourcesNeverCount() { + assertFalse( + Gamepad.looksLikeController( + padSource = false, virtual = false, hasStick = true, hasFaceButtons = true, + ), + ) + } +} diff --git a/clients/android/native/src/decode/asc_presenter.rs b/clients/android/native/src/decode/asc_presenter.rs index 0bf7cad7..6acd532b 100644 --- a/clients/android/native/src/decode/asc_presenter.rs +++ b/clients/android/native/src/decode/asc_presenter.rs @@ -142,21 +142,21 @@ pub(super) struct AscBackend { impl AscBackend { /// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then /// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the - /// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner); + /// negotiated decode size; `surface_size` the LIVE view size the layer composites into; + /// `panel_hz` the mode-table panel rate (seeds the learner); /// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate. #[allow(clippy::too_many_arguments)] pub(super) fn create( window: &NativeWindow, src_w: i32, src_h: i32, - surface_w: i32, - surface_h: i32, + surface_size: std::sync::Arc, panel_hz: i32, dataspace: i32, source_hz: u32, priority: PresentPriority, ) -> Option { - let layer = Layer::create(window, surface_w, surface_h)?; + let layer = Layer::create(window, surface_size)?; let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE | ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY; let reader = match ImageReader::new_with_usage( diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index d489cb73..c5b579cb 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -96,8 +96,7 @@ pub(super) fn run_async( present_priority, smooth_buffer, panel_hz, - surface_w, - surface_h, + surface_size, } = opts; boost_thread_priority(); let mode = client.mode(); @@ -199,8 +198,7 @@ pub(super) fn run_async( &window, mode.width as i32, mode.height as i32, - surface_w, - surface_h, + surface_size, panel_hz, initial_ds, mode.refresh_hz, diff --git a/clients/android/native/src/decode/mod.rs b/clients/android/native/src/decode/mod.rs index a96e4375..524149bf 100644 --- a/clients/android/native/src/decode/mod.rs +++ b/clients/android/native/src/decode/mod.rs @@ -133,12 +133,12 @@ pub(crate) struct DecodeOptions { /// named here is not necessarily the one the panel ends up in. The measured timeline spacing /// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]). pub panel_hz: i32, - /// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from - /// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this - /// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin - /// couldn't read it yet, and the backend falls back to the window buffer size. - pub surface_w: i32, - pub surface_h: i32, + /// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint), + /// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every + /// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space + /// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it + /// yet, and the backend falls back to the window buffer size. + pub surface_size: std::sync::Arc, } /// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop. diff --git a/clients/android/native/src/decode/surface_control.rs b/clients/android/native/src/decode/surface_control.rs index 2f72a85f..5d42cbb6 100644 --- a/clients/android/native/src/decode/surface_control.rs +++ b/clients/android/native/src/decode/surface_control.rs @@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer; use ndk::native_window::NativeWindow; use std::ffi::c_void; use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc}; use super::async_loop::DecodeEvent; @@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans pub(super) struct Layer { api: Api, sc: Arc, - /// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it. - dest_w: i32, - dest_h: i32, + /// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every + /// present — the destination rectangle the buffer is scaled to fill. Live rather than captured + /// because the view resizes under a surface that is never recreated (see `dest`). + surface_size: Arc, + /// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the + /// view when video started): the window's own buffer geometry, the best remaining guess. + fallback_w: i32, + fallback_h: i32, /// `true` once the first transaction has made the layer visible + set its z-order + frame rate. configured: bool, } @@ -287,13 +293,16 @@ impl Layer { /// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on /// API < 29 / a null layer — the caller then uses the SurfaceView presenter. /// - /// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child - /// layer is composited into, which is the display footprint of the (aspect-fitted) video view, - /// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a - /// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank - /// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view - /// yet) falls back to that buffer size as the best remaining guess. - pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option { + /// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the + /// child layer is composited into, which is the display footprint of the (aspect-fitted) video + /// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer + /// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — + /// using it shrank the picture to the top-left corner. It is read fresh on every present + /// because that view RESIZES mid-stream under a surface that is never recreated: the stream + /// screen hides the system bars and switches on cutout drawing a frame or two after + /// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the + /// view yet) falls back to the buffer size as the best remaining guess. + pub(super) fn create(window: &NativeWindow, surface_size: Arc) -> Option { let api = Api::resolve()?; // SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a // static NUL-terminated string; the call returns null on failure (checked). @@ -303,20 +312,11 @@ impl Layer { log::warn!("asc: createFromWindow returned null — falling back to SurfaceView"); return None; } - let dest_w = if dest_w > 0 { - dest_w - } else { - window.width().max(1) - }; - let dest_h = if dest_h > 0 { - dest_h - } else { - window.height().max(1) - }; + let fallback_w = window.width().max(1); + let fallback_h = window.height().max(1); log::info!( - "asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})", - window.width(), - window.height(), + "asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})", + crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)), ); Some(Layer { sc: Arc::new(ScHandle { @@ -324,12 +324,20 @@ impl Layer { release: api.ac_release, }), api, - dest_w, - dest_h, + surface_size, + fallback_w, + fallback_h, configured: false, }) } + /// The destination rectangle for this present: the live view size, or the window's buffer + /// geometry while Kotlin has reported nothing. + fn dest(&self) -> (i32, i32) { + crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed)) + .unwrap_or((self.fallback_w, self.fallback_h)) + } + /// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes /// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot /// completion that reports the real latch + the previous buffer's release fence on `ev_tx`, @@ -370,11 +378,12 @@ impl Layer { right: src_w.max(1), bottom: src_h.max(1), }; + let (dest_w, dest_h) = self.dest(); let dst = ARect { left: 0, top: 0, - right: self.dest_w, - bottom: self.dest_h, + right: dest_w, + bottom: dest_h, }; (self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY); if dataspace != 0 { diff --git a/clients/android/native/src/decode/sync_loop.rs b/clients/android/native/src/decode/sync_loop.rs index f578e42a..6a10ecdb 100644 --- a/clients/android/native/src/decode/sync_loop.rs +++ b/clients/android/native/src/decode/sync_loop.rs @@ -50,8 +50,7 @@ pub(super) fn run_sync( panel_hz: _, // The ASurfaceControl backend is async-loop only; the sync loop renders straight to the // SurfaceView, so it never needs the view's on-screen size. - surface_w: _, - surface_h: _, + surface_size: _, } = opts; boost_thread_priority(); let mode = client.mode(); diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 68f6ecca..2cabd57b 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // A fresh session is never muted (mute is per-session UI state, not a setting). mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)), access_seq: std::sync::atomic::AtomicU32::new(0), + // Reported by Kotlin at `surfaceCreated` and on every resize after it. + surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)), }; Box::into_raw(Box::new(handle)) as jlong } diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index b1d21e5a..c3c12fa4 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -26,7 +26,7 @@ mod probe; use punktfunk_core::client::NativeClient; use std::panic::AssertUnwindSafe; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -87,6 +87,37 @@ pub(crate) struct SessionHandle { /// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update /// (the host's expiry warnings) arrived without holding a blocking event thread. pub(crate) access_seq: AtomicU32, + /// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by + /// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback + /// sends, read by the ASurfaceControl presenter before each present. + /// + /// Shared and live rather than a start-time parameter because the view RESIZES under a surface + /// that is never recreated: hiding the system bars and switching the window to + /// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`, + /// and each one grows the video view. A destination rect captured once at creation then keeps + /// compositing the picture at its old, smaller size anchored at the layer's origin — the + /// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer + /// falls back to the window's buffer geometry. + pub surface_size: Arc, +} + +/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a +/// single atomic load and can never see a torn pair (a new width against an old height). +/// Non-positive values pack as `0`, the "not reported yet" sentinel. +pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 { + if w <= 0 || h <= 0 { + return 0; + } + ((w as u64) << 32) | (h as u64 & 0xffff_ffff) +} + +/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> { + if packed == 0 { + return None; + } + Some((((packed >> 32) as u32) as i32, (packed as u32) as i32)) } struct VideoThread { @@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> { } Some(out) } + +#[cfg(test)] +mod tests { + use super::{pack_surface_size, unpack_surface_size}; + + /// The pair the presenter reads as one atomic load must survive the round trip — including a + /// size wider than a signed 16-bit value, which every panel this runs on now is. + #[test] + fn surface_size_round_trips() { + assert_eq!( + unpack_surface_size(pack_surface_size(2800, 1260)), + Some((2800, 1260)) + ); + assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1))); + } + + /// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back + /// to the window's buffer geometry rather than composing into an empty rectangle. + #[test] + fn non_positive_sizes_are_the_sentinel() { + assert_eq!(pack_surface_size(0, 0), 0); + assert_eq!(pack_surface_size(1920, 0), 0); + assert_eq!(pack_surface_size(-1, 1080), 0); + assert_eq!(unpack_surface_size(0), None); + } +} diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 510f7073..1bedda74 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( let client = h.client.clone(); let sd = shutdown.clone(); let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate) + + // Seed the live view size with what the view measures right now; `surfaceChanged` keeps it + // current from here on (the bars hide and the cutout mode changes AFTER this call). + h.surface_size.store( + super::pack_surface_size(surface_w, surface_h), + std::sync::atomic::Ordering::Relaxed, + ); let opts = crate::decode::DecodeOptions { decoder_name: decoder, ll_feature, @@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( present_priority, smooth_buffer, panel_hz: panel_fps, - surface_w, - surface_h, + surface_size: h.surface_size.clone(), }; let join = std::thread::Builder::new() .name("pf-decode".into()) @@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( .resolve::() } +/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s +/// on-screen pixel size, re-reported on every `surfaceChanged`. +/// +/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the +/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars +/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which +/// grow it. Without this the layer would keep painting the picture at its start-up size, in the +/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture). +/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo` +/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni` +/// + an atomic store, so it links on the host build too. +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize( + _env: EnvUnowned, + _this: JObject, + handle: jlong, + width: jni::sys::jint, + height: jni::sys::jint, +) { + jni_guard((), || { + let packed = super::pack_surface_size(width, height); + if handle == 0 || packed == 0 { + return; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.surface_size + .store(packed, std::sync::atomic::Ordering::Relaxed); + }) +} + /// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host /// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList` /// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].