From 6bccdea8b1d4ad78f27d73284b5aec0172051c94 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 17:06:04 +0000 Subject: [PATCH 1/2] fix: Quad cells render to SurfaceView instead of TextureView (#61) With a TextureView every decoded frame raises a SurfaceTexture frame-available callback, which the framework delivers on a native thread attached to the VM for each call ("JNISurfaceTexture"). With four streams this allocated about 1.2 MB/s of garbage and caused a blocking young GC every ~7 s. A SurfaceView hands frames straight to the compositor: no Java callbacks and one composition pass less. The single view keeps its TextureView, which pinch-zoom needs. SurfaceHolder callbacks are added, not replaced, so stop() removes the cell's callback. --- .../java/com/openipc/decoder/Decoder.java | 9 +-- .../java/com/openipc/decoder/QuadCell.java | 71 +++++++------------ 2 files changed, 32 insertions(+), 48 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 8b040780..3ccf88ee 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -30,6 +30,7 @@ import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.Surface; +import android.view.SurfaceView; import android.view.TextureView; import android.view.View; import android.view.WindowInsets; @@ -82,7 +83,7 @@ public class Decoder extends Activity private boolean quadEnabled; private QuadCell[] quadCells; private LinearLayout quadContainer; - private final TextureView[] quadViews = new TextureView[4]; + private final SurfaceView[] quadViews = new SurfaceView[4]; // Quad audio (#12): the cell whose sound plays, -1 for none private int quadAudio = -1; private final TextView[] quadAudioBadges = new TextView[4]; @@ -245,7 +246,7 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { LinearLayout.LayoutParams.MATCH_PARENT, 0, 1)); for (int col = 0; col < 2; col++) { int idx = row * 2 + col; - quadViews[idx] = new TextureView(this); + quadViews[idx] = new SurfaceView(this); quadViews[idx].setOnClickListener(cv -> menuManager.showMenu(menuAnchor, quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts)); @@ -675,7 +676,7 @@ private void removeClearOverlay() { private void clearQuadViews() { for (int i = 0; i < 4; i++) { - TextureView t = quadViews[i]; + SurfaceView t = quadViews[i]; if (t == null) continue; FrameLayout cell = (FrameLayout) t.getParent(); View overlay = cell.findViewWithTag("quad_overlay_" + i); @@ -695,7 +696,7 @@ private void clearQuadViews() { private void removeQuadOverlay(int index) { if (index < 0 || index >= quadViews.length || quadViews[index] == null) return; - TextureView t = quadViews[index]; + SurfaceView t = quadViews[index]; View overlay = ((View) t.getParent()).findViewWithTag("quad_overlay_" + index); if (overlay != null) { overlay.setVisibility(View.GONE); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 5dfe90c3..2ea8b20f 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -9,7 +9,8 @@ import android.util.Log; import android.view.Surface; -import android.view.TextureView; +import android.view.SurfaceHolder; +import android.view.SurfaceView; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; @@ -22,7 +23,7 @@ class QuadCell implements RtspSession.Callback { private final int index; private final String host; - private final TextureView view; + private final SurfaceView view; private final String tag; private final FramePool cellFramePool; private final String userAgent; @@ -65,7 +66,7 @@ void setAudioSink(AudioManager sink, int route) { private final Listener listener; - QuadCell(int index, String host, TextureView view, FramePool pool, String userAgent, Listener listener) { + QuadCell(int index, String host, SurfaceView view, FramePool pool, String userAgent, Listener listener) { this.index = index; this.host = host; this.view = view; @@ -102,51 +103,32 @@ void start() { started = true; running = true; decoder.setEnabled(true); - view.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture st, int w, int h) { - if (threadsStarted) return; - Surface old = surface; - surface = new Surface(st); - if (old != null) { - try { old.release(); } catch (Exception e) { - Log.e(TAG, tag + " Error releasing old surface", e); - } - } - startThreads(); - } - @Override - public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture st, int w, int h) { - Surface old = surface; - surface = new Surface(st); - if (old != null) { - try { old.release(); } catch (Exception e) { - Log.e(TAG, tag + " Error releasing surface on size change", e); - } - } - } - @Override - public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture st) { - Surface s = surface; - surface = null; - if (s != null) { - try { s.release(); } catch (Exception e) { - Log.e(TAG, tag + " Error releasing surface on destroy", e); - } - } - return true; - } - @Override - public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture st) {} - }); - if (view.isAvailable()) { - if (surface == null) { - surface = new Surface(view.getSurfaceTexture()); - } + // SurfaceView, not TextureView (#61): with a TextureView every decoded frame raises a + // frame-available callback that the framework delivers by attaching a native thread to + // the VM, which allocated about 1 MB/s of garbage with four streams + view.getHolder().addCallback(holderCallback); + Surface s = view.getHolder().getSurface(); + if (s != null && s.isValid()) { + surface = s; if (!threadsStarted) startThreads(); } } + private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(SurfaceHolder holder) { + // The surface belongs to the SurfaceView: it is used, never released, here + surface = holder.getSurface(); + if (!threadsStarted) startThreads(); + } + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {} + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + surface = null; + } + }; + private void startThreads() { if (threadsStarted) return; threadsStarted = true; @@ -193,6 +175,7 @@ void stop() { executor = null; } nalQueue.clear(); + if (view != null) view.getHolder().removeCallback(holderCallback); surface = null; } From f8f4e98749f00011ed98ef0cadb74d16bd071a60 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 17:22:06 +0000 Subject: [PATCH 2/2] fix: close a Quad cell's decoder when its surface is destroyed A recreated SurfaceView surface needs a decoder configured for it, and nothing may render into the old one after surfaceDestroyed() returns. The decode thread creates the new decoder once the next surface is there (Qodo). --- android/app/src/main/java/com/openipc/decoder/QuadCell.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 2ea8b20f..e2d4d28d 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -125,7 +125,11 @@ public void surfaceCreated(SurfaceHolder holder) { public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {} @Override public void surfaceDestroyed(SurfaceHolder holder) { + // Nothing may render into the surface once this returns, and a recreated surface + // needs a decoder configured for it: close the decoder, the decode thread creates + // a new one when the next surface is there surface = null; + decoder.closeDecoder(); } };