bd4e15b68d
- native: the 756-line session.rs becomes session/{mod,connect,input,planes}.rs
around a SessionHandle (connect lifecycle + trust, input plane shims, plane
start/stop + stats drain).
- Decode-stats sampling is HUD-gated (nativeSetVideoStatsEnabled): with the
overlay hidden the decode thread skips the per-AU clock read + lock; enabling
resets the measurement window.
- audio: the AAudio open path is a per-sharing-mode try_open closure — the
realtime callback state (ring, prime, free-list) is rebuilt per attempt, so a
failed exclusive-mode try can't leak state into the shared-mode retry.
- Kotlin: ConnectScreen/StreamScreen slimmed by extracting ConnectDialogs,
StatsOverlay and TouchInput.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
237 lines
8.7 KiB
Rust
237 lines
8.7 KiB
Rust
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
|
|
//! ~1 Hz decode-stats drain for the HUD.
|
|
|
|
use jni::objects::JObject;
|
|
use jni::sys::{jboolean, jdoubleArray, jlong, jsize};
|
|
use jni::JNIEnv;
|
|
|
|
use super::{jni_guard, SessionHandle};
|
|
|
|
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
|
|
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
|
|
#[cfg(target_os = "android")]
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
|
env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
surface: JObject,
|
|
) {
|
|
use super::VideoThread;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::Arc;
|
|
|
|
if handle == 0 {
|
|
return;
|
|
}
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
let mut guard = h.video.lock().unwrap();
|
|
if guard.is_some() {
|
|
return; // already streaming
|
|
}
|
|
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
|
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
|
|
let window = match unsafe {
|
|
ndk::native_window::NativeWindow::from_surface(
|
|
env.get_native_interface() as *mut _,
|
|
surface.as_raw() as *mut _,
|
|
)
|
|
} {
|
|
Some(w) => w,
|
|
None => {
|
|
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
|
return;
|
|
}
|
|
};
|
|
let shutdown = Arc::new(AtomicBool::new(false));
|
|
let client = h.client.clone();
|
|
let sd = shutdown.clone();
|
|
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
|
let join = std::thread::Builder::new()
|
|
.name("pf-decode".into())
|
|
.spawn(move || crate::decode::run(client, window, sd, st))
|
|
.ok();
|
|
*guard = Some(VideoThread { shutdown, join });
|
|
}
|
|
|
|
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
|
/// session). No-op on `0`.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) {
|
|
jni_guard((), || {
|
|
if handle != 0 {
|
|
// SAFETY: live handle per the contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
h.stop_video();
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
|
/// 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,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) -> jdoubleArray {
|
|
jni_guard(std::ptr::null_mut(), || {
|
|
if handle == 0 {
|
|
return std::ptr::null_mut();
|
|
}
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
if h.video.lock().unwrap().is_none() {
|
|
return std::ptr::null_mut(); // not streaming → no stats
|
|
}
|
|
let snap = h.stats.drain();
|
|
let mode = h.client.mode();
|
|
let color = h.client.color;
|
|
let buf: [f64; 14] = [
|
|
snap.fps,
|
|
snap.mbps,
|
|
snap.lat_p50_ms,
|
|
snap.lat_p95_ms,
|
|
if snap.lat_valid { 1.0 } else { 0.0 },
|
|
if snap.skew_corrected { 1.0 } else { 0.0 },
|
|
mode.width as f64,
|
|
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,
|
|
Err(_) => return std::ptr::null_mut(),
|
|
};
|
|
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
|
return std::ptr::null_mut();
|
|
}
|
|
arr.into_raw()
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
|
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
|
|
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
|
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
|
/// pure `jni` + an atomic store, so it links on the host build too.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
enabled: jboolean,
|
|
) {
|
|
jni_guard((), || {
|
|
if handle != 0 {
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
h.stats.set_enabled(enabled != 0);
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
|
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
|
#[cfg(target_os = "android")]
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) {
|
|
if handle == 0 {
|
|
return;
|
|
}
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
let mut guard = h.audio.lock().unwrap();
|
|
if guard.is_some() {
|
|
return; // already playing
|
|
}
|
|
match crate::audio::AudioPlayback::start(h.client.clone()) {
|
|
Some(p) => *guard = Some(p),
|
|
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
|
}
|
|
}
|
|
|
|
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
|
/// closing the session). No-op on `0`.
|
|
#[cfg(target_os = "android")]
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) {
|
|
jni_guard((), || {
|
|
if handle != 0 {
|
|
// SAFETY: live handle per the contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
h.stop_audio();
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
|
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
|
|
/// permission) leaves the rest of the session streaming.
|
|
#[cfg(target_os = "android")]
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) {
|
|
if handle == 0 {
|
|
return;
|
|
}
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
let mut guard = h.mic.lock().unwrap();
|
|
if guard.is_some() {
|
|
return; // already capturing
|
|
}
|
|
match crate::mic::MicCapture::start(h.client.clone()) {
|
|
Some(m) => *guard = Some(m),
|
|
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
|
|
}
|
|
}
|
|
|
|
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
|
/// stream (without closing the session). No-op on `0`.
|
|
#[cfg(target_os = "android")]
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) {
|
|
jni_guard((), || {
|
|
if handle != 0 {
|
|
// SAFETY: live handle per the contract.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
h.stop_mic();
|
|
}
|
|
})
|
|
}
|