inital
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user