diff --git a/app/src/main/java/com/m5dev/librecast/H264Decoder.java b/app/src/main/java/com/m5dev/librecast/H264Decoder.java new file mode 100644 index 0000000..afb275f --- /dev/null +++ b/app/src/main/java/com/m5dev/librecast/H264Decoder.java @@ -0,0 +1,286 @@ +package com.m5dev.librecast; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.util.Log; +import android.view.Surface; + +import java.nio.ByteBuffer; +import java.util.LinkedList; +import java.util.Queue; + +public class H264Decoder { + + private static final String TAG = "H264Decoder"; + private static final int MAX_QUEUE_SIZE = 5; + + public interface DecoderListener { + void onFirstFrameRendered(); + void onOutputFormatChanged(MediaFormat format); + void onError(Exception e); + } + + private static class FrameData { + final byte[] data; + final long presentationTimeUs; + final int flags; + + FrameData(byte[] data, long presentationTimeUs, int flags) { + this.data = data; + this.presentationTimeUs = presentationTimeUs; + this.flags = flags; + } + } + + private final Surface surface; + private final DecoderListener listener; + private final Queue frameQueue = new LinkedList<>(); + + private MediaCodec codec; + private Thread workerThread; + private volatile boolean isRunning = false; + private volatile boolean firstFrameRendered = false; + + public H264Decoder(Surface surface) { + this(surface, null); + } + + public H264Decoder(Surface surface, DecoderListener listener) { + this.surface = surface; + this.listener = listener; + } + + public synchronized void start() { + if (isRunning) { + return; + } + if (surface == null || !surface.isValid()) { + Log.e(TAG, "Cannot start decoder: Surface is invalid or null"); + return; + } + try { + codec = MediaCodec.createDecoderByType("video/avc"); + MediaFormat format = MediaFormat.createVideoFormat("video/avc", 1920, 1080); + codec.configure(format, surface, null, 0); + codec.start(); + isRunning = true; + firstFrameRendered = false; + + workerThread = new Thread(new Runnable() { + @Override + public void run() { + workerLoop(); + } + }, "H264DecoderWorker"); + workerThread.start(); + Log.i(TAG, "H264Decoder started successfully"); + } catch (Exception e) { + Log.e(TAG, "Failed to start MediaCodec H264 decoder", e); + stopInternal(); + if (listener != null) { + listener.onError(e); + } + } + } + + public synchronized void stop() { + isRunning = false; + if (workerThread != null) { + workerThread.interrupt(); + try { + workerThread.join(500); + } catch (InterruptedException ignored) { + } + workerThread = null; + } + stopInternal(); + synchronized (frameQueue) { + frameQueue.clear(); + } + Log.i(TAG, "H264Decoder stopped"); + } + + private void stopInternal() { + if (codec != null) { + try { + codec.stop(); + } catch (Exception ignored) { + } + try { + codec.release(); + } catch (Exception ignored) { + } + codec = null; + } + } + + public boolean isRunning() { + return isRunning; + } + + public void feedFrame(ByteBuffer buffer) { + if (!isRunning || buffer == null || !buffer.hasRemaining()) { + return; + } + byte[] bytes = new byte[buffer.remaining()]; + int origPos = buffer.position(); + buffer.get(bytes); + buffer.position(origPos); + feedFrame(bytes); + } + + public void feedFrame(byte[] bytes) { + if (!isRunning || bytes == null || bytes.length == 0) { + return; + } + int flags = detectFlags(bytes); + FrameData frame = new FrameData(bytes, System.nanoTime() / 1000, flags); + + synchronized (frameQueue) { + while (frameQueue.size() >= MAX_QUEUE_SIZE) { + FrameData removed = null; + for (FrameData f : frameQueue) { + if ((f.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) { + removed = f; + break; + } + } + if (removed != null) { + frameQueue.remove(removed); + } else { + frameQueue.poll(); + } + } + frameQueue.add(frame); + } + } + + private int detectFlags(byte[] data) { + int flags = 0; + if (data == null || data.length < 4) { + return flags; + } + int nalType = -1; + if (data[0] == 0 && data[1] == 0 && data[2] == 0 && data[3] == 1) { + if (data.length > 4) { + nalType = data[4] & 0x1F; + } + } else if (data[0] == 0 && data[1] == 0 && data[2] == 1) { + nalType = data[3] & 0x1F; + } else { + nalType = data[0] & 0x1F; + } + + if (nalType == 7 || nalType == 8) { // SPS or PPS NAL unit + flags |= MediaCodec.BUFFER_FLAG_CODEC_CONFIG; + } + return flags; + } + + private void workerLoop() { + ByteBuffer[] inputBuffers = null; + ByteBuffer[] outputBuffers = null; + + try { + inputBuffers = codec.getInputBuffers(); + outputBuffers = codec.getOutputBuffers(); + } catch (Exception e) { + Log.e(TAG, "Error getting codec buffers", e); + if (listener != null) { + listener.onError(e); + } + return; + } + + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (isRunning && !Thread.currentThread().isInterrupted()) { + boolean workDone = false; + + // Drain output buffers + try { + while (isRunning) { + int outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, 1000); // 1ms timeout + if (outputBufferIndex >= 0) { + codec.releaseOutputBuffer(outputBufferIndex, true); + workDone = true; + if (!firstFrameRendered) { + firstFrameRendered = true; + if (listener != null) { + listener.onFirstFrameRendered(); + } + } + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + outputBuffers = codec.getOutputBuffers(); + workDone = true; + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat newFormat = codec.getOutputFormat(); + Log.d(TAG, "Output format changed: " + newFormat); + if (listener != null) { + listener.onOutputFormatChanged(newFormat); + } + workDone = true; + } else if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + break; + } + } + } catch (Exception e) { + if (isRunning) { + Log.e(TAG, "Error dequeuing output buffer", e); + if (listener != null) { + listener.onError(e); + } + } + } + + // Feed input buffers + try { + while (isRunning) { + FrameData frame; + synchronized (frameQueue) { + frame = frameQueue.peek(); + } + if (frame == null) { + break; + } + + int inputBufferIndex = codec.dequeueInputBuffer(0); + if (inputBufferIndex >= 0) { + synchronized (frameQueue) { + frameQueue.poll(); + } + ByteBuffer inputBuffer = inputBuffers[inputBufferIndex]; + inputBuffer.clear(); + inputBuffer.put(frame.data); + codec.queueInputBuffer( + inputBufferIndex, + 0, + frame.data.length, + frame.presentationTimeUs, + frame.flags + ); + workDone = true; + } else { + break; + } + } + } catch (Exception e) { + if (isRunning) { + Log.e(TAG, "Error queuing input buffer", e); + if (listener != null) { + listener.onError(e); + } + } + } + + if (!workDone) { + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + } +} diff --git a/app/src/main/java/com/m5dev/librecast/MainActivity.java b/app/src/main/java/com/m5dev/librecast/MainActivity.java index c7f9549..ed95d3d 100644 --- a/app/src/main/java/com/m5dev/librecast/MainActivity.java +++ b/app/src/main/java/com/m5dev/librecast/MainActivity.java @@ -1,20 +1,59 @@ package com.m5dev.librecast; import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.media.MediaFormat; import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import android.view.SurfaceHolder; +import android.view.SurfaceView; import android.view.View; import android.view.WindowManager; +import android.widget.LinearLayout; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; -public class MainActivity extends AppCompatActivity { +import java.nio.ByteBuffer; +public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback, ReceiverService.VideoChunkListener, H264Decoder.DecoderListener { + + private static final String TAG = "MainActivity"; + + private SurfaceView surfaceView; + private LinearLayout layoutStatus; private TextView textServerStatus; private TextView textConnectionStatus; + private H264Decoder decoder; + private ReceiverService receiverService; + private boolean isBound = false; + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + ReceiverService.LocalBinder binder = (ReceiverService.LocalBinder) service; + receiverService = binder.getService(); + isBound = true; + if (receiverService != null) { + receiverService.setVideoChunkListener(MainActivity.this); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (receiverService != null) { + receiverService.setVideoChunkListener(null); + } + receiverService = null; + isBound = false; + } + }; + private final BroadcastReceiver statusReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { @@ -34,9 +73,13 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_main); + surfaceView = findViewById(R.id.surface_view); + layoutStatus = findViewById(R.id.layout_status); textServerStatus = findViewById(R.id.text_server_status); textConnectionStatus = findViewById(R.id.text_connection_status); + surfaceView.getHolder().addCallback(this); + startReceiverService(); } @@ -46,6 +89,9 @@ protected void onStart() { IntentFilter filter = new IntentFilter(ReceiverService.ACTION_STATUS_UPDATE); registerReceiver(statusReceiver, filter); + Intent serviceIntent = new Intent(this, ReceiverService.class); + bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE); + // Request current status Intent queryIntent = new Intent(this, ReceiverService.class); queryIntent.setAction(ReceiverService.ACTION_QUERY_STATUS); @@ -55,6 +101,13 @@ protected void onStart() { @Override protected void onStop() { super.onStop(); + if (isBound) { + if (receiverService != null) { + receiverService.setVideoChunkListener(null); + } + unbindService(serviceConnection); + isBound = false; + } try { unregisterReceiver(statusReceiver); } catch (Exception ignored) { @@ -93,6 +146,8 @@ private void updateUI(Intent intent) { textConnectionStatus.setText(getString(R.string.client_connected_format, clientIp != null ? clientIp : "")); } else { textConnectionStatus.setText(getString(R.string.waiting_for_connection)); + // Show status UI again when client disconnects + showStatusUI(); } } @@ -106,4 +161,79 @@ private void hideSystemUI() { | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } + + private void showStatusUI() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (layoutStatus != null) { + layoutStatus.setVisibility(View.VISIBLE); + } + } + }); + } + + private void hideStatusUI() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (layoutStatus != null) { + layoutStatus.setVisibility(View.GONE); + } + } + }); + } + + // SurfaceHolder.Callback + + @Override + public void surfaceCreated(SurfaceHolder holder) { + Log.i(TAG, "Surface created, initializing H264Decoder"); + if (decoder != null) { + decoder.stop(); + } + decoder = new H264Decoder(holder.getSurface(), this); + decoder.start(); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + Log.i(TAG, "Surface changed: width=" + width + ", height=" + height); + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + Log.i(TAG, "Surface destroyed, stopping H264Decoder"); + if (decoder != null) { + decoder.stop(); + decoder = null; + } + } + + // ReceiverService.VideoChunkListener + + @Override + public void onVideoChunkReceived(ByteBuffer chunk) { + if (decoder != null) { + decoder.feedFrame(chunk); + } + } + + // H264Decoder.DecoderListener + + @Override + public void onFirstFrameRendered() { + Log.i(TAG, "First video frame rendered"); + hideStatusUI(); + } + + @Override + public void onOutputFormatChanged(MediaFormat format) { + Log.i(TAG, "Decoder output format changed: " + format); + } + + @Override + public void onError(Exception e) { + Log.e(TAG, "Decoder error", e); + } } diff --git a/app/src/main/java/com/m5dev/librecast/ReceiverService.java b/app/src/main/java/com/m5dev/librecast/ReceiverService.java index 1ddef25..908c283 100644 --- a/app/src/main/java/com/m5dev/librecast/ReceiverService.java +++ b/app/src/main/java/com/m5dev/librecast/ReceiverService.java @@ -15,6 +15,10 @@ public class ReceiverService extends Service implements LibreCastServer.ServerEv private static final String TAG = "ReceiverService"; + public interface VideoChunkListener { + void onVideoChunkReceived(ByteBuffer chunk); + } + public static final String ACTION_STATUS_UPDATE = "com.m5dev.librecast.ACTION_STATUS_UPDATE"; public static final String ACTION_START_SERVER = "com.m5dev.librecast.ACTION_START_SERVER"; public static final String ACTION_STOP_SERVER = "com.m5dev.librecast.ACTION_STOP_SERVER"; @@ -39,12 +43,18 @@ public class ReceiverService extends Service implements LibreCastServer.ServerEv private boolean isClientConnected = false; private String clientIp = ""; + private volatile VideoChunkListener videoChunkListener; + public class LocalBinder extends Binder { public ReceiverService getService() { return ReceiverService.this; } } + public void setVideoChunkListener(VideoChunkListener listener) { + this.videoChunkListener = listener; + } + @Override public void onCreate() { super.onCreate(); @@ -202,7 +212,10 @@ public void onClientDisconnected(String ip) { @Override public void onVideoChunkReceived(ByteBuffer chunk) { - // Raw video chunk received for processing / streaming playback pipeline + VideoChunkListener listener = this.videoChunkListener; + if (listener != null) { + listener.onVideoChunkReceived(chunk); + } } @Override diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2774066..878ea9a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,15 +2,21 @@ + android:background="@color/background_dark"> + + + android:orientation="vertical" + android:padding="32dp">