A pad that was never there kept the console UI on, and the picture sat in the corner #359
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> =
|
||||
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> = 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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -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<ScHandle>,
|
||||
/// 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<AtomicU64>,
|
||||
/// 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<Layer> {
|
||||
/// `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<AtomicU64>) -> Option<Layer> {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AtomicU64>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `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`].
|
||||
|
||||
Reference in New Issue
Block a user