From d5cb3f10cb483fedb0dce63e04a2a74f49d87cc3 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:05:22 +0000 Subject: [PATCH 1/8] feat: S switches only the playback mode (stream / key frames), in multi-view and in the single view; it never changes the view (#153) Co-Authored-By: Claude --- .../java/com/openipc/decoder/Decoder.java | 47 +++++++++++++------ .../java/com/openipc/decoder/MenuManager.java | 10 ++-- 2 files changed, 37 insertions(+), 20 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 bab1efb8..c54f21cc 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -359,21 +359,12 @@ public void onToggleQuad() { } /** - * S (#108): multi-view with every cell on key frames only, the lowest decoder load. From - * the single or U view it starts multi-view in this mode; in multi-view it toggles. + * S (#108, #153): the playback mode only — every frame, or key frames only (the lowest + * decoder load) — in whatever view is shown: multi-view, U or a single camera. It never + * switches the view; Q does that. */ @Override public void onToggleSlides() { - if (leaveUdpView()) { - setSlides(true); - startQuad(); - return; - } - if (!quadEnabled) { - setSlides(true); - startQuad(); - return; - } setSlides(!slides); } @@ -390,8 +381,9 @@ private void setSlides(boolean on) { // Back to adaptive: plan from scratch, cells are promoted again where they fit // The decoders keep running: no warm-up (Qodo on #149) if (!on && quadEnabled) decodePlanner = newPlanner(false); - Toast.makeText(this, on ? "Slides: key frames only" : "Multi-view", - Toast.LENGTH_SHORT).show(); + // The single view: back to every frame from the next key frame on (#153) + if (!on) singleWaitKey = true; + Toast.makeText(this, on ? "Key frames only" : "Full stream", Toast.LENGTH_SHORT).show(); } @Override @@ -1751,7 +1743,14 @@ private void startListener() { try { Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS); if (buffer != null && rtspClient.isJpeg()) { - drawJpeg(buffer, gen); + // S: MJPEG has no key frames, one frame per second instead (#153) + long now = System.nanoTime() / 1_000_000; + if (!slides || now - lastSlideJpegMs >= QuadCell.JPEG_REDUCED_MS) { + lastSlideJpegMs = now; + drawJpeg(buffer, gen); + } + framePool.recycle(buffer); + } else if (buffer != null && skipForKeyFrames(buffer)) { framePool.recycle(buffer); } else if (buffer != null && surfacePending) { // Waiting for a fresh surface after MJPEG; the next key frame follows @@ -1782,6 +1781,24 @@ private void startListener() { }); } + /** S in the single view: the last MJPEG frame drawn (#153). */ + private long lastSlideJpegMs; + /** Back from key frames only: P frames wait for the next key frame (#153). */ + private volatile boolean singleWaitKey; + + /** + * Key frames only in the single view (S, #153): parameter sets and key frames go to the + * decoder, the rest is dropped; after S is turned off, until the next key frame. + */ + private boolean skipForKeyFrames(Frame buffer) { + if (!slides && !singleWaitKey) return false; + if (buffer.length() < 5) return true; + int type = NalAssembler.fragment(buffer.data()[4], codecH265); + if (!QuadCell.isKeyNal(type, codecH265)) return true; + if (!slides && QuadCell.isKeyFrameNal(type, codecH265)) singleWaitKey = false; + return false; + } + // ====================== Lifecycle ====================== @Override diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 44f27426..3ef206e2 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -260,7 +260,8 @@ public boolean dispatchKeyEvent(KeyEvent event) { camRow.addView(quadBtn, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - if (quadEnabled && !slidesShown) highlightItem(quadBtn); + // Q is the view, S the playback mode: both may be on (#153) + if (quadEnabled) highlightItem(quadBtn); mCachedQuadBtn = quadBtn; quadBtn.setOnClickListener(v -> { PopupWindow p = mMenuPopup; @@ -274,7 +275,7 @@ public boolean dispatchKeyEvent(KeyEvent event) { camRow.addView(slidesBtn, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - if (quadEnabled && slidesShown) highlightItem(slidesBtn); + if (slidesShown) highlightItem(slidesBtn); mCachedSlidesBtn = slidesBtn; slidesBtn.setOnClickListener(v -> { PopupWindow p = mMenuPopup; @@ -326,11 +327,11 @@ private void updateCachedMenu(boolean quadEnabled, int mActive, String[] mHosts) mWebUiBtn.setVisibility(single ? View.VISIBLE : View.GONE); } if (mCachedQuadBtn != null) { - if (quadEnabled && !slidesShown) highlightItem(mCachedQuadBtn); + if (quadEnabled) highlightItem(mCachedQuadBtn); else resetItem(mCachedQuadBtn); } if (mCachedSlidesBtn != null) { - if (quadEnabled && slidesShown) highlightItem(mCachedSlidesBtn); + if (slidesShown) highlightItem(mCachedSlidesBtn); else resetItem(mCachedSlidesBtn); } if (mCachedUdpBtn != null) { @@ -368,7 +369,6 @@ private static void focusForKeys(View layout, View item) { /** The highlighted item of the slot row: the active slot, Q in multi-view, U. */ private TextView focusItem(boolean quadEnabled, int mActive) { if (udpShown && mCachedUdpBtn != null) return mCachedUdpBtn; - if (quadEnabled && slidesShown && mCachedSlidesBtn != null) return mCachedSlidesBtn; if (quadEnabled && mCachedQuadBtn != null) return mCachedQuadBtn; if (camButtons != null && mActive >= 0 && mActive < camButtons.length) { return camButtons[mActive]; From f54387b702d97aaaf0231ac9d004974f89d6f02d Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:11:44 +0000 Subject: [PATCH 2/8] feat: frames_out and decode (full / key frames) for the single view in /stats.json (#153) Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 3 +++ 1 file changed, 3 insertions(+) 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 c54f21cc..34dc0921 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1460,6 +1460,9 @@ private Map debugState() { single.put("codec", rtspClient != null && rtspClient.isJpeg() ? "MJPEG" : codecH265 ? "H.265" : "H.264"); single.put("audio", audioCodec); + // Frames shown so far and the playback mode of S (#153) + single.put("frames_out", videoDecoder.framesOut() + jpegFramesOut); + single.put("decode", slides ? "key frames" : "full"); m.put("single", single); } QuadCell[] cells = quadCells; From 3074d4ffcc44d314a1021a995218744b4525e90e Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:18:12 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20Q=20no=20longer=20turns=20S=20off=20?= =?UTF-8?q?=E2=80=94=20Q=20is=20the=20view,=20S=20the=20playback=20mode=20?= =?UTF-8?q?(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../app/src/main/java/com/openipc/decoder/Decoder.java | 9 +-------- 1 file changed, 1 insertion(+), 8 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 34dc0921..19bb3406 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -332,20 +332,13 @@ public void onSwitchCamera(int slot) { @Override public void onToggleQuad() { + // Q switches the view only; the playback mode is S's and stays as it is (#153) if (leaveUdpView()) { - setSlides(false); startQuad(); return; } - if (quadEnabled && slides) { - // From slides back to the adaptive grid; Q again then leaves multi-view - setSlides(false); - return; - } boolean newState = !quadEnabled; if (newState) { - // Q always opens the adaptive grid; slides only come with S (Qodo on #124) - if (slides) setSlides(false); startQuad(); } else { stopQuad(); From 5bfe4836f881e8ae3b904cf6152953d54ef6778d Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:24:56 +0000 Subject: [PATCH 4/8] chore: version 2.01 (#153) Co-Authored-By: Claude --- android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7f4c1d22..58a5f14d 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 25 - versionName "2.00" + versionCode 26 + versionName "2.01" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } From c1f20b872599e606980d0fc382b3410724f3d4e2 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:33:22 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20Qodo=20on=20#155=20=E2=80=94=20slide?= =?UTF-8?q?s=20is=20volatile=20(the=20single=20view's=20decode=20thread=20?= =?UTF-8?q?reads=20it);=20frames=5Fout=20is=20documented=20as=20a=20runnin?= =?UTF-8?q?g=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 19bb3406..f45b257a 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -104,7 +104,8 @@ public class Decoder extends Activity /** Debug text in every cell (menu D), or in none (#92). */ private boolean showStats; /** Slide mode (#108): multi-view, every cell on key frames only; saved as "slides". */ - private boolean slides; + /** Read by the single view's decode thread too (Qodo on #155). */ + private volatile boolean slides; /** With the debug text on: where the debug page is (#96). */ private TextView debugUrls; private volatile int quadAudioGen; @@ -1453,7 +1454,8 @@ private Map debugState() { single.put("codec", rtspClient != null && rtspClient.isJpeg() ? "MJPEG" : codecH265 ? "H.265" : "H.264"); single.put("audio", audioCodec); - // Frames shown so far and the playback mode of S (#153) + // Frames shown since the app started (all single-view streams; the rate is the + // difference of two samples) and the playback mode of S (#153) single.put("frames_out", videoDecoder.framesOut() + jpegFramesOut); single.put("decode", slides ? "key frames" : "full"); m.put("single", single); From 8f605dce97a4067e1135372c575b5b6a401b4476 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:33:34 +0000 Subject: [PATCH 6/8] docs: the slides field comment matches #153 Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f45b257a..cbe883c3 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -103,7 +103,7 @@ public class Decoder extends Activity private final TextView[] quadStats = new TextView[CELL_COUNT]; /** Debug text in every cell (menu D), or in none (#92). */ private boolean showStats; - /** Slide mode (#108): multi-view, every cell on key frames only; saved as "slides". */ + /** S (#108, #153): key frames only, in every view; saved as "slides". */ /** Read by the single view's decode thread too (Qodo on #155). */ private volatile boolean slides; /** With the debug text on: where the debug page is (#96). */ From 7be441ea13881be1fd3a735775b2a0d0914f6dc9 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 18:54:40 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20Qodo=20on=20#155=20=E2=80=94=20out?= =?UTF-8?q?=5Ffps=20of=20the=20last=20second=20instead=20of=20a=20running?= =?UTF-8?q?=20total,=20the=20key-frame=20wait=20ends=20only=20with=20a=20f?= =?UTF-8?q?rame=20that=20reaches=20the=20decoder,=20the=20MJPEG=20second?= =?UTF-8?q?=20counts=20from=20a=20frame=20shown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../java/com/openipc/decoder/Decoder.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 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 cbe883c3..1d8dd7fe 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1454,9 +1454,8 @@ private Map debugState() { single.put("codec", rtspClient != null && rtspClient.isJpeg() ? "MJPEG" : codecH265 ? "H.265" : "H.264"); single.put("audio", audioCodec); - // Frames shown since the app started (all single-view streams; the rate is the - // difference of two samples) and the playback mode of S (#153) - single.put("frames_out", videoDecoder.framesOut() + jpegFramesOut); + // Frames shown in the last second and the playback mode of S (#153) + single.put("out_fps", singleOutFps); single.put("decode", slides ? "key frames" : "full"); m.put("single", single); } @@ -1691,10 +1690,11 @@ private int dp(float dp) { * One JPEG frame of an MJPEG stream into the single view, on the decode thread, drawn over * the whole surface; the view's fit transform keeps the aspect ratio, as for video. */ - private void drawJpeg(Frame frame, int gen) { + private boolean drawJpeg(Frame frame, int gen) { Surface s = mVideoSurface; int[] size = jpegRenderer.render(frame, s, () -> gen == listenerGen && s == mVideoSurface); if (size != null) jpegShown(size[0], size[1]); + return size != null; } /** A JPEG frame is on screen (hardware or software path). */ @@ -1741,18 +1741,21 @@ private void startListener() { try { Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS); if (buffer != null && rtspClient.isJpeg()) { - // S: MJPEG has no key frames, one frame per second instead (#153) + // S: MJPEG has no key frames, one frame per second instead (#153); the + // second counts from a frame really shown (Qodo on #155) long now = System.nanoTime() / 1_000_000; - if (!slides || now - lastSlideJpegMs >= QuadCell.JPEG_REDUCED_MS) { + if ((!slides || now - lastSlideJpegMs >= QuadCell.JPEG_REDUCED_MS) + && drawJpeg(buffer, gen)) { lastSlideJpegMs = now; - drawJpeg(buffer, gen); } framePool.recycle(buffer); - } else if (buffer != null && skipForKeyFrames(buffer)) { - framePool.recycle(buffer); } else if (buffer != null && surfacePending) { // Waiting for a fresh surface after MJPEG; the next key frame follows framePool.recycle(buffer); + } else if (buffer != null && skipForKeyFrames(buffer)) { + // After surfacePending: a key frame dropped there must not end the wait + // for one (Qodo on #155) + framePool.recycle(buffer); } else if (buffer != null) { if (videoDecoder.decodeFrame(buffer, codecH265, 0)) { createDecoder(); @@ -1818,6 +1821,7 @@ protected void onPause() { private static final int UDP_STALL_SECONDS = 8; private int singleStallSeconds; private int lastSingleFrames; + private volatile int singleOutFps; private long lastSingleBytes; private final Runnable singleUdpCheck = new Runnable() { @Override @@ -1831,6 +1835,8 @@ public void run() { && (client.playsOverUdp() || client.listens()) && "connected".equals(singleState) && bytes - lastSingleBytes > 10_000 && frames == lastSingleFrames; + // Frames shown in the last second, for /stats.json (#153) + singleOutFps = Math.max(0, frames - lastSingleFrames); lastSingleFrames = frames; lastSingleBytes = bytes; singleStallSeconds = stalled ? singleStallSeconds + 1 : 0; From 52ac0ebcc202e41028383b4aeb67b2b1e90d660c Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 19:13:29 +0000 Subject: [PATCH 8/8] fix: the S throttle for MJPEG starts again with each single-view stream (Qodo on #155) Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 2 ++ 1 file changed, 2 insertions(+) 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 1d8dd7fe..f502bd9e 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1857,6 +1857,8 @@ public void run() { private void stopStreams() { lastJpegFrameMs = 0; + // The next stream's first MJPEG frame shows at once with S on (Qodo on #155) + lastSlideJpegMs = 0; resetDecoderStuck(); mSurface.removeCallbacks(singleUdpCheck); // The single view's stream ends here: it is no longer "connected" for the debug page