Merge remote-tracking branch 'origin/main'
apple / swift (push) Successful in 53s
android / android (push) Successful in 2m10s
ci / rust (push) Failing after 54s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 27s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 1m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
flatpak / build-publish (push) Failing after 2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m25s
deb / build-publish (push) Successful in 6m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m35s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 53s
android / android (push) Successful in 2m10s
ci / rust (push) Failing after 54s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 27s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 1m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
flatpak / build-publish (push) Failing after 2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m25s
deb / build-publish (push) Successful in 6m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m35s
docker / deploy-docs (push) Successful in 17s
This commit is contained in:
Generated
+53
@@ -2049,6 +2049,30 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"ndk-sys",
|
||||||
|
"num_enum",
|
||||||
|
"raw-window-handle",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-sys"
|
||||||
|
version = "0.6.0+11769913"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2161,6 +2185,28 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||||
|
dependencies = [
|
||||||
|
"num_enum_derive",
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oid-registry"
|
name = "oid-registry"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -2445,6 +2491,7 @@ dependencies = [
|
|||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
|
"ndk",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2725,6 +2772,12 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-window-handle"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
|
|||||||
@@ -1,59 +1,166 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Cross the JNI bridge into libpunktfunk_android.so → punktfunk-core. A live ABI version is
|
|
||||||
// the scaffold's proof the whole native stack is wired (cargo-ndk → jniLibs → APK →
|
|
||||||
// System.loadLibrary → JNI → core). Logged so it's verifiable headlessly via logcat.
|
|
||||||
val abi = runCatching { NativeBridge.abiVersion() }.getOrDefault(-1)
|
|
||||||
val core = runCatching { NativeBridge.coreVersion() }.getOrDefault("?")
|
|
||||||
Log.i("punktfunk", "native bridge: core ABI v$abi, core $core")
|
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
||||||
ScaffoldScreen(abi, core)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scaffold mode requested from the host (WxH@Hz). TODO: derive from the display. */
|
||||||
|
private val REQUEST_MODE = Triple(1280, 720, 60)
|
||||||
|
|
||||||
|
private sealed interface Screen {
|
||||||
|
data object Connect : Screen
|
||||||
|
data class Stream(val handle: Long) : Screen
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScaffoldScreen(abi: Int, core: String) {
|
private fun App() {
|
||||||
|
var screen by remember { mutableStateOf<Screen>(Screen.Connect) }
|
||||||
|
when (val s = screen) {
|
||||||
|
Screen.Connect -> ConnectScreen(onConnected = { handle -> screen = Screen.Stream(handle) })
|
||||||
|
is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectScreen(onConnected: (Long) -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var host by remember { mutableStateOf("") }
|
||||||
|
var port by remember { mutableStateOf("9777") }
|
||||||
|
var connecting by remember { mutableStateOf(false) }
|
||||||
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
|
val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
|
||||||
|
val (w, h, hz) = REQUEST_MODE
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
|
Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text("Android client — scaffold", style = MaterialTheme.typography.bodyMedium)
|
Text("Android client", style = MaterialTheme.typography.bodyMedium)
|
||||||
Text(
|
Spacer(Modifier.height(24.dp))
|
||||||
if (abi > 0) "✓ native bridge linked" else "✗ native bridge FAILED",
|
OutlinedTextField(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
value = host,
|
||||||
|
onValueChange = { host = it },
|
||||||
|
label = { Text("Host") },
|
||||||
|
singleLine = true,
|
||||||
)
|
)
|
||||||
Text("core ABI v$abi · core $core", style = MaterialTheme.typography.bodySmall)
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
connecting = true
|
||||||
|
status = "Connecting to $host:$port…"
|
||||||
|
scope.launch {
|
||||||
|
val handle = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativeConnect(host.trim(), port.toInt(), w, h, hz)
|
||||||
|
}
|
||||||
|
connecting = false
|
||||||
|
if (handle != 0L) {
|
||||||
|
onConnected(handle)
|
||||||
|
} else {
|
||||||
|
status = "Connection failed — check host/port and logcat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
|
||||||
|
status?.let {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text("core ABI v$abi", style = MaterialTheme.typography.labelSmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val window = (context as? ComponentActivity)?.window
|
||||||
|
|
||||||
|
DisposableEffect(handle) {
|
||||||
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
onDispose {
|
||||||
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
// Leaving the stream: stop the decode thread and tear down the session.
|
||||||
|
NativeBridge.nativeStopVideo(handle)
|
||||||
|
NativeBridge.nativeClose(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler { onDisconnect() }
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { ctx ->
|
||||||
|
SurfaceView(ctx).apply {
|
||||||
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
|
NativeBridge.nativeStartVideo(handle, holder.surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
NativeBridge.nativeStopVideo(handle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,4 +28,13 @@ object NativeBridge {
|
|||||||
|
|
||||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||||
external fun nativeClose(handle: Long)
|
external fun nativeClose(handle: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||||
|
*/
|
||||||
|
external fun nativeStartVideo(handle: Long, surface: android.view.Surface)
|
||||||
|
|
||||||
|
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||||
|
external fun nativeStopVideo(handle: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ log = "0.4"
|
|||||||
# `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets.
|
# `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets.
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.14"
|
android_logger = "0.14"
|
||||||
|
# NDK bindings for the per-frame video path: AMediaCodec (HEVC hardware decode) + ANativeWindow
|
||||||
|
# (the SurfaceView surface). Links libmediandk/libnativewindow. Decode runs entirely in Rust — no
|
||||||
|
# per-frame JNI crossing (the "no async / native threads on the hot path" invariant).
|
||||||
|
ndk = { version = "0.9", features = ["media"] }
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
||||||
|
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||||
|
//!
|
||||||
|
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
||||||
|
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
||||||
|
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
||||||
|
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||||
|
|
||||||
|
use ndk::media::media_codec::{
|
||||||
|
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||||
|
};
|
||||||
|
use ndk::media::media_format::MediaFormat;
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
||||||
|
pub fn run(client: Arc<NativeClient>, window: NativeWindow, shutdown: Arc<AtomicBool>) {
|
||||||
|
let mode = client.mode();
|
||||||
|
let codec = match MediaCodec::from_decoder_type("video/hevc") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
log::error!("decode: no HEVC decoder on this device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut format = MediaFormat::new();
|
||||||
|
format.set_str("mime", "video/hevc");
|
||||||
|
format.set_i32("width", mode.width as i32);
|
||||||
|
format.set_i32("height", mode.height as i32);
|
||||||
|
// Generous input buffer so a large keyframe AU is never truncated.
|
||||||
|
format.set_i32(
|
||||||
|
"max-input-size",
|
||||||
|
(mode.width * mode.height).max(2_000_000) as i32,
|
||||||
|
);
|
||||||
|
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
|
||||||
|
format.set_i32("low-latency", 1);
|
||||||
|
|
||||||
|
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||||
|
log::error!("decode: configure failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.start() {
|
||||||
|
log::error!("decode: start failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"decode: HEVC decoder started at {}x{}",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut fed: u64 = 0;
|
||||||
|
let mut rendered: u64 = 0;
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
if fed == 0 {
|
||||||
|
let p = &frame.data;
|
||||||
|
log::info!(
|
||||||
|
"decode: first AU {} bytes, head {:02x?}",
|
||||||
|
p.len(),
|
||||||
|
&p[..p.len().min(6)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fed += 1;
|
||||||
|
feed(&codec, &frame.data, frame.pts_ns / 1000);
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||||
|
Err(_) => break, // session closed
|
||||||
|
}
|
||||||
|
rendered += drain(&codec);
|
||||||
|
if fed > 0 && fed % 300 == 0 {
|
||||||
|
log::info!("decode: fed={fed} rendered={rendered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = codec.stop();
|
||||||
|
log::info!("decode: stopped (fed={fed} rendered={rendered})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one access unit into a codec input buffer and queue it.
|
||||||
|
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
|
||||||
|
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
|
||||||
|
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||||
|
let n = {
|
||||||
|
let dst = buf.buffer_mut();
|
||||||
|
let n = au.len().min(dst.len());
|
||||||
|
if n < au.len() {
|
||||||
|
log::warn!(
|
||||||
|
"decode: AU {} > input buffer {}, truncated",
|
||||||
|
au.len(),
|
||||||
|
dst.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (slot, &b) in dst.iter_mut().zip(&au[..n]) {
|
||||||
|
slot.write(b);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||||
|
log::warn!("decode: queue_input_buffer: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DequeuedInputBufferResult::TryAgainLater) => {
|
||||||
|
// No input buffer free right now; the AU is dropped (FEC/keyframes recover).
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release any ready output buffers to the surface (render = true), latency-first. Returns the
|
||||||
|
/// number of frames presented.
|
||||||
|
fn drain(codec: &MediaCodec) -> u64 {
|
||||||
|
let mut n = 0;
|
||||||
|
loop {
|
||||||
|
match codec.dequeue_output_buffer(Duration::from_millis(0)) {
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
|
if let Err(e) = codec.release_output_buffer(buf, true) {
|
||||||
|
log::warn!("decode: release_output_buffer: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
// TryAgainLater / OutputFormatChanged / OutputBuffersChanged — nothing to render now.
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("decode: dequeue_output_buffer: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ use jni::objects::JObject;
|
|||||||
use jni::sys::jint;
|
use jni::sys::jint;
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod decode;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
|
|||||||
@@ -1,33 +1,60 @@
|
|||||||
//! Session handle lifecycle over JNI.
|
//! Session lifecycle + plane wiring over JNI.
|
||||||
//!
|
//!
|
||||||
//! A connected [`NativeClient`] is boxed and handed to Kotlin as an opaque `jlong`; [`nativeClose`]
|
//! A connected session is a [`SessionHandle`] — an `Arc<NativeClient>` plus the decode thread it
|
||||||
//! drops it, and the connector's `Drop` tears down the worker thread + QUIC connection (RAII). The
|
//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
|
||||||
//! client is `Sync`, so the Kotlin side is free to pull each plane from its own thread later.
|
//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle.
|
||||||
//!
|
//!
|
||||||
//! TODO(M4 Android stage 1): build out the plane pumps + IO on top of this handle. Port the
|
//! Wired so far: connect/close + the video plane (HEVC `next_frame` → NDK AMediaCodec → the
|
||||||
//! orchestration from `crates/punktfunk-client-linux`:
|
//! SurfaceView's `ANativeWindow`, see [`crate::decode`]).
|
||||||
//!
|
//!
|
||||||
//! - video: `next_frame` → AnnexB access unit → `AMediaCodec` (NDK, async) → `SurfaceView`
|
//! TODO(M4 Android stage 1): audio (`next_audio` → Opus → Oboe), input (`send_input` /
|
||||||
//! - audio: `next_audio` → Opus decode → jitter ring → Oboe (port `client-linux/src/audio.rs`)
|
//! `send_rich_input`), rumble/HID feedback, pairing/identity (Keystore). Port the orchestration
|
||||||
//! - input: Kotlin capture → `send_input` / `send_rich_input` (VK keymap from `keymap.rs`)
|
//! from `crates/punktfunk-client-linux`.
|
||||||
//! - rumble/HID feedback: `next_rumble` / `next_hidout` → VibratorManager / LightsManager
|
|
||||||
//! - trust: `generate_identity` + `pair` + pin (Keystore-wrapped), then pass `pin`/`identity` here
|
|
||||||
//!
|
|
||||||
//! The signatures below are deliberately minimal (TOFU, anonymous) so the scaffold can already
|
|
||||||
//! stand up a session against a host that does not require pairing.
|
|
||||||
|
|
||||||
use jni::objects::{JObject, JString};
|
use jni::objects::{JObject, JString};
|
||||||
use jni::sys::{jint, jlong};
|
use jni::sys::{jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long`.
|
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||||
///
|
pub(crate) struct SessionHandle {
|
||||||
/// Trust-on-first-use (no pin) and anonymous (no client identity) — enough to bring up a stream
|
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||||
/// against a host that does not require pairing. Returns an opaque session handle, or `0` on
|
// build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused.
|
||||||
/// failure (the cause is logged to logcat).
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub client: Arc<NativeClient>,
|
||||||
|
video: Mutex<Option<VideoThread>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoThread {
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
join: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionHandle {
|
||||||
|
/// Signal the decode thread to stop and join it. Idempotent.
|
||||||
|
fn stop_video(&self) {
|
||||||
|
if let Some(mut vt) = self.video.lock().unwrap().take() {
|
||||||
|
vt.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(j) = vt.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop_video();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long` — trust-on-first-use,
|
||||||
|
/// anonymous. Returns an opaque session handle, or `0` on failure (logged to logcat).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||||
mut env: JNIEnv<'local>,
|
mut env: JNIEnv<'local>,
|
||||||
@@ -53,13 +80,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
mode,
|
mode,
|
||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0, // bitrate_kbps: let the host choose its default
|
0, // bitrate_kbps: host default
|
||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
None, // pin: trust on first use
|
None, // pin: trust on first use
|
||||||
None, // identity: anonymous (TODO: Keystore-backed identity + pairing)
|
None, // identity: anonymous (TODO: Keystore-backed identity + pairing)
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
) {
|
) {
|
||||||
Ok(client) => Box::into_raw(Box::new(client)) as jlong,
|
Ok(client) => {
|
||||||
|
let handle = SessionHandle {
|
||||||
|
client: Arc::new(client),
|
||||||
|
video: Mutex::new(None),
|
||||||
|
};
|
||||||
|
Box::into_raw(Box::new(handle)) as jlong
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||||
0
|
0
|
||||||
@@ -67,12 +100,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeClose(handle)` — drop the boxed [`NativeClient`] (RAII shutdown of the
|
/// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears
|
||||||
/// worker thread + QUIC connection). No-op on a `0` handle.
|
/// down the connector). No-op on `0`.
|
||||||
///
|
///
|
||||||
/// # Safety contract
|
/// # Safety contract
|
||||||
/// `handle` must be either `0` or a value previously returned by [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`]
|
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||||
/// and not already closed. Kotlin owns this invariant (one `nativeClose` per non-zero `nativeConnect`).
|
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -80,7 +113,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
) {
|
) {
|
||||||
if handle != 0 {
|
if handle != 0 {
|
||||||
// SAFETY: per the contract above, `handle` is a live `Box<NativeClient>` pointer.
|
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||||
unsafe { drop(Box::from_raw(handle as *mut NativeClient)) };
|
unsafe { drop(Box::from_raw(handle as *mut 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,
|
||||||
|
) {
|
||||||
|
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 join = std::thread::Builder::new()
|
||||||
|
.name("pf-decode".into())
|
||||||
|
.spawn(move || crate::decode::run(client, window, sd))
|
||||||
|
.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,
|
||||||
|
) {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stop_video();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user