This commit is contained in:
ImBenji
2026-05-04 08:21:31 +01:00
parent 0ed740ad19
commit bfdaf4a801
9 changed files with 1081 additions and 149 deletions
+9
View File
@@ -1,4 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<application
android:label="pulsar"
android:name="${applicationName}"
@@ -1,6 +1,255 @@
package net.imbenji.pulsar;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Handler;
import android.os.HandlerThread;
import android.view.Surface;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.view.TextureRegistry;
public class MainActivity extends FlutterActivity {
private static final String CHANNEL = "com.pulsar/video";
// one decoder entry per textureId
private static class DecoderEntry {
TextureRegistry.SurfaceTextureEntry textureEntry;
Surface surface;
MediaCodec codec;
HandlerThread outputThread;
Handler outputHandler;
HandlerThread inputThread;
Handler inputHandler;
AtomicBoolean running = new AtomicBoolean(true);
}
private final Map<Long, DecoderEntry> decoders = new HashMap<>();
@Override
public void configureFlutterEngine(FlutterEngine flutterEngine) {
super.configureFlutterEngine(flutterEngine);
TextureRegistry registry = flutterEngine.getRenderer();
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL)
.setMethodCallHandler((call, result) -> {
switch (call.method) {
case "createDecoder": {
byte[] spsPps = call.argument("spsPps");
if (spsPps == null) {
result.error("BAD_ARGS", "spsPps required", null);
break;
}
TextureRegistry.SurfaceTextureEntry entry = registry.createSurfaceTexture();
long texId = entry.id();
// we don't know the video size yet — configure with a resonable
// placeholder; MediaCodec will adapt once it sees the stream
int width = 1280;
int height = 720;
MediaFormat fmt = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
// csd-0 must include Annex B start codes — pass the whole SPS+PPS blob
// (MediaCodec H264 accepts combined SPS+PPS with start codes in csd-0)
fmt.setByteBuffer("csd-0", ByteBuffer.wrap(spsPps));
entry.surfaceTexture().setDefaultBufferSize(width, height);
fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
Surface surface = new Surface(entry.surfaceTexture());
MediaCodec codec;
try {
codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
codec.configure(fmt, surface, null, 0);
codec.start();
} catch (IOException e) {
surface.release();
entry.release();
result.error("CODEC_FAIL", e.getMessage(), null);
break;
}
DecoderEntry dec = new DecoderEntry();
dec.textureEntry = entry;
dec.surface = surface;
dec.codec = codec;
HandlerThread ot = new HandlerThread("decoder-out-" + texId);
ot.start();
dec.outputThread = ot;
dec.outputHandler = new Handler(ot.getLooper());
HandlerThread it = new HandlerThread("decoder-in-" + texId);
it.start();
dec.inputThread = it;
dec.inputHandler = new Handler(it.getLooper());
drainOutputLoop(dec);
decoders.put(texId, dec);
Map<String, Long> res = new HashMap<>();
res.put("textureId", texId);
result.success(res);
break;
}
case "feedNal": {
long texId = ((Number) call.argument("textureId")).longValue();
byte[] nal = call.argument("nal");
DecoderEntry dec = decoders.get(texId);
if (dec == null || nal == null) {
result.error("NO_DECODER", "decoder not found", null);
break;
}
dec.inputHandler.post(() -> {
try {
int idx = dec.codec.dequeueInputBuffer(0);
if (idx >= 0) {
ByteBuffer buf = dec.codec.getInputBuffer(idx);
if (buf != null) {
buf.clear();
buf.put(nal);
dec.codec.queueInputBuffer(idx, 0, nal.length,
System.nanoTime() / 1000, 0);
}
}
// if idx < 0 the decoder is backed up — drop this NAL silently
} catch (Exception ignored) {}
});
result.success(null);
break;
}
case "stopDecoder": {
long texId = ((Number) call.argument("textureId")).longValue();
DecoderEntry dec = decoders.remove(texId);
if (dec != null) releaseDecoder(dec);
result.success(null);
break;
}
case "start":
case "stop":
case "forceKeyframe":
result.success(null);
break;
default:
result.notImplemented();
}
});
}
private void drainOutputLoop(DecoderEntry dec) {
dec.outputHandler.post(() -> {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (dec.running.get()) {
try {
// block up to 10ms waiting for a decoded frame
int idx = dec.codec.dequeueOutputBuffer(info, 10_000);
if (idx >= 0) {
dec.codec.releaseOutputBuffer(idx, true);
}
// idx == INFO_TRY_AGAIN_LATER / FORMAT_CHANGED / BUFFERS_CHANGED → loop
} catch (Exception e) {
break;
}
}
});
}
private final Handler mainHandler = new Handler(android.os.Looper.getMainLooper());
private void releaseDecoder(DecoderEntry dec) {
dec.running.set(false);
// codec stop/release must happen on the decoder thread (after the drain loop exits)
// but entry.release() calls FlutterJNI.unregisterTexture which requires the main thread
dec.outputHandler.post(() -> {
try {
dec.codec.stop();
dec.codec.release();
} catch (Exception ignored) {}
dec.surface.release();
mainHandler.post(() -> dec.textureEntry.release());
});
dec.outputThread.quitSafely();
dec.inputThread.quitSafely();
}
// splits an Annex B blob into individual NAL unit ByteBuffers
// returns null if fewer than 2 NALUs found
private ByteBuffer[] splitAnnexB(byte[] data) {
java.util.List<ByteBuffer> units = new java.util.ArrayList<>();
int start = -1;
for (int i = 0; i < data.length - 3; i++) {
if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1) {
if (start >= 0) {
ByteBuffer bb = ByteBuffer.wrap(data, start, i - start);
units.add(bb);
}
start = i + 4;
i += 3;
} else if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
if (start >= 0) {
ByteBuffer bb = ByteBuffer.wrap(data, start, i - start);
units.add(bb);
}
start = i + 3;
i += 2;
}
}
if (start >= 0 && start < data.length) {
units.add(ByteBuffer.wrap(data, start, data.length - start));
}
return units.size() >= 2 ? units.toArray(new ByteBuffer[0]) : null;
}
// crude SPS parser — reads width/height from the first SPS NALU
// returns null if it cant parse
private int[] dimsFromSps(ByteBuffer spsBuf) {
try {
byte[] sps = new byte[spsBuf.remaining()];
spsBuf.duplicate().get(sps);
// skip NAL header byte (forbidden_zero_bit + nal_ref_idc + nal_unit_type)
// a minimal parse isn't reliable without a full RBSP/Exp-Golomb parser,
// so just return null and let MediaCodec figure it out
return null;
} catch (Exception e) {
return null;
}
}
@Override
protected void onDestroy() {
for (DecoderEntry dec : decoders.values()) {
releaseDecoder(dec);
}
decoders.clear();
super.onDestroy();
}
}