From 6f8820c063d4487d8e4fd849491af4ab9681313a Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 15:57:01 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20say=20when=20the=20device's=20video?= =?UTF-8?q?=20decoder=20stands=20still=20=E2=80=94=20frames=20arrive=20in?= =?UTF-8?q?=20several=20cells,=20none=20decoded=20for=2020=20s=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A banner over the grid asks to restart the device; /stats.json has decoder_stuck and the debug page shows it in red. Cells losing packets on the network do not count. Co-Authored-By: Claude --- .../java/com/openipc/decoder/DebugServer.java | 7 ++- .../java/com/openipc/decoder/Decoder.java | 62 +++++++++++++++++++ .../java/com/openipc/decoder/QuadCell.java | 15 +++++ .../com/openipc/decoder/QuadGridTest.java | 10 +++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 96919e9d..924261d2 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -512,8 +512,11 @@ private static void closeQuietly(Socket s) { + "up(s.uptime_s):'';\n" // Orange: the UI thread is stuck, or there is no activity behind the server at all + "var ui=s.ui_thread&&s.ui_thread!=='ok',none=!s.ui_thread;\n" - + "$('err').textContent=ui?'UI '+s.ui_thread:none?(s.mode||'no data'):'';\n" - + "$('dot').className='dot '+(ui||none?'warn':'ok');\n" + // Red: frames arrive, the device's decoder gives none; only a restart helps (#145) + + "var stuck=s.decoder_stuck===true;\n" + + "$('err').textContent=stuck?'video decoder not responding \u2014 restart the device'" + + ":ui?'UI '+s.ui_thread:none?(s.mode||'no data'):'';\n" + + "$('dot').className='dot '+(stuck?'bad':ui||none?'warn':'ok');\n" + "var list,side=1;\n" + "if(s.mode==='paused'||!(s.cells||s.single))" + "list=[{text:s.mode==='paused'?'App in background':(s.mode||'')}];\n" 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 ac407c9a..40cc1736 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.os.SystemClock; import android.provider.MediaStore; import android.util.Log; +import android.util.TypedValue; import android.view.GestureDetector; import android.view.Gravity; import android.view.KeyEvent; @@ -482,6 +483,7 @@ private boolean leaveUdpView() { menuManager.setUdpShown(false); stopQuadCells(); quadContainer.setVisibility(View.GONE); + showDecoderStuck(false); mSurface.setVisibility(View.VISIBLE); return true; } @@ -735,6 +737,7 @@ private void stopQuad() { } quadContainer.setVisibility(View.GONE); + showDecoderStuck(false); mSurface.setVisibility(View.VISIBLE); } @@ -1021,6 +1024,7 @@ public void run() { v.setVisibility(showStats ? View.VISIBLE : View.GONE); if (log) Log.i(TAG, "Quad-" + i + ": " + line); } + checkDecoderStuck(cells); planDecoding(cells, now); updateDebugUrls(); refreshUdpHint(); @@ -1028,6 +1032,61 @@ public void run() { } }; + // ====================== The device's decoder stands still (#145) ====================== + + /** Seconds with frames arriving in several cells and none decoded: the device is stuck. */ + static final int STUCK_SECONDS = 20; + private int stuckSeconds; + private TextView stuckBanner; + + /** At least two cells receive and none of them decodes a frame (#145). */ + static boolean looksStuck(int receiving, int decoding) { + return receiving >= 2 && decoding == 0; + } + + /** + * Seen on the AG-216: every cell receives but no decoder gives a frame, and only a reboot + * of the device helps (#145). Said on screen instead of black cells and "0.0 fps". + */ + private void checkDecoderStuck(QuadCell[] cells) { + int receiving = 0; + int decoding = 0; + for (QuadCell c : cells) { + if (c == null || !c.isReceiving()) continue; + receiving++; + if (c.outFps() > 0) decoding++; + } + stuckSeconds = looksStuck(receiving, decoding) ? stuckSeconds + 1 : 0; + showDecoderStuck(stuckSeconds >= STUCK_SECONDS); + } + + private void showDecoderStuck(boolean on) { + if (!on) { + stuckSeconds = 0; + if (stuckBanner != null) stuckBanner.setVisibility(View.GONE); + return; + } + if (stuckBanner == null) { + stuckBanner = new TextView(this); + stuckBanner.setText("The video decoder of this device does not respond.\n" + + "Frames arrive, none is decoded. Restart the device."); + stuckBanner.setTextColor(Color.WHITE); + stuckBanner.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20); + stuckBanner.setGravity(Gravity.CENTER); + stuckBanner.setBackgroundColor(0xCC000000); + int pad = (int) (16 * getResources().getDisplayMetrics().density); + stuckBanner.setPadding(pad, pad, pad, pad); + ((FrameLayout) menuAnchor).addView(stuckBanner, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER)); + } + if (stuckBanner.getVisibility() != View.VISIBLE) { + Log.w(TAG, "No cell decodes although frames arrive: the device's decoder is stuck"); + } + stuckBanner.setVisibility(View.VISIBLE); + stuckBanner.bringToFront(); + } + private void startStatsTick() { quadContainer.removeCallbacks(quadStatsTick); decodePlanner = newPlanner(); @@ -1357,6 +1416,8 @@ private Map debugState() { dec.put("learnt", planner.isLearnt()); dec.put("failed_load", planner.failedLoad()); m.put("decoder", dec); + // Frames arrive in several cells, none decodes: restart the device (#145) + m.put("decoder_stuck", stuckSeconds >= STUCK_SECONDS); } if (udpViewShown) { Map udp = new LinkedHashMap<>(); @@ -1885,6 +1946,7 @@ private void startStreams() { } else { // The views may still be in Quad layout from before the pause quadContainer.setVisibility(View.GONE); + showDecoderStuck(false); mSurface.setVisibility(View.VISIBLE); boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL); if (configured) { 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 287ff8d0..3e41b28e 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -283,6 +283,9 @@ String sample(long nowMs) { lastIn = in; lastOut = out; lastLost = lost; + int netLoss = nalAssembler.lossCount(); + networkLoss = netLoss > lastNetLoss; + lastNetLoss = netLoss; lastSampleMs = nowMs; checkUdpStall(); @@ -411,6 +414,18 @@ boolean showsVideo(long ms) { return pausedReason == null && "connected".equals(state) && decoder.renderedWithin(window); } + /** NAL units were lost on the network in the last second (#145). */ + private volatile boolean networkLoss; + private int lastNetLoss; + + /** + * Connected, not paused, and whole frames arrive (#145): a cell losing packets on the + * network may decode nothing for that reason alone (#113, #133). + */ + boolean isReceiving() { + return pausedReason == null && "connected".equals(state) && inFps >= 1 && !networkLoss; + } + /** Connected and decoding: its load is real, so demoting or pausing it helps (#85). */ boolean isPlaying() { return pausedReason == null && "connected".equals(state) && decoder.hasDecoder(); diff --git a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java index 041e7664..ffc62264 100644 --- a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java +++ b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java @@ -76,4 +76,14 @@ public void udpHintListsEveryAddress() { java.util.Arrays.asList("172.17.32.144", "192.168.1.20"), 5600); assertTrue(two, two.endsWith("output to\n\n172.17.32.144:5600\n192.168.1.20:5600")); } + + @Test + public void decoderStuckNeedsSeveralReceivingCellsAndNoOutput() { + assertTrue(Decoder.looksStuck(2, 0)); + assertTrue(Decoder.looksStuck(9, 0)); + // One camera alone may just wait for a key frame; any output means it works + assertFalse(Decoder.looksStuck(1, 0)); + assertFalse(Decoder.looksStuck(9, 1)); + assertFalse(Decoder.looksStuck(0, 0)); + } } From 2b9185d6ea1c612dde08ad91e580ac1702d6780e Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 16:04:30 +0000 Subject: [PATCH 2/6] fix: the stuck banner goes into the root layout by id (#145) 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 40cc1736..54cea4b8 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1076,7 +1076,7 @@ private void showDecoderStuck(boolean on) { stuckBanner.setBackgroundColor(0xCC000000); int pad = (int) (16 * getResources().getDisplayMetrics().density); stuckBanner.setPadding(pad, pad, pad, pad); - ((FrameLayout) menuAnchor).addView(stuckBanner, new FrameLayout.LayoutParams( + ((FrameLayout) findViewById(R.id.decoder)).addView(stuckBanner, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); } From 2e1df42914f97a8ccf534a1be249323b39766c61 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 16:19:12 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20Qodo=20on=20#147=20=E2=80=94=20the?= =?UTF-8?q?=20stuck=20count=20survives=20below=20the=20threshold,=20output?= =?UTF-8?q?=20of=20any=20cell=20counts,=20any=20RTP=20gap=20marks=20a=20ce?= =?UTF-8?q?ll=20lossy,=20leaving=20the=20grid=20resets=20the=20detector?= 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 | 34 ++++++++++++------- .../com/openipc/decoder/NalAssembler.java | 4 +++ .../java/com/openipc/decoder/QuadCell.java | 3 +- .../com/openipc/decoder/QuadGridTest.java | 21 ++++++++---- 4 files changed, 42 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 54cea4b8..78955ae1 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -483,7 +483,7 @@ private boolean leaveUdpView() { menuManager.setUdpShown(false); stopQuadCells(); quadContainer.setVisibility(View.GONE); - showDecoderStuck(false); + resetDecoderStuck(); mSurface.setVisibility(View.VISIBLE); return true; } @@ -737,7 +737,7 @@ private void stopQuad() { } quadContainer.setVisibility(View.GONE); - showDecoderStuck(false); + resetDecoderStuck(); mSurface.setVisibility(View.VISIBLE); } @@ -1039,9 +1039,12 @@ public void run() { private int stuckSeconds; private TextView stuckBanner; - /** At least two cells receive and none of them decodes a frame (#145). */ - static boolean looksStuck(int receiving, int decoding) { - return receiving >= 2 && decoding == 0; + /** + * One second of the detector: the seconds in a row in which at least two cells received + * whole frames and no cell at all decoded one (#145). + */ + static int stuckStep(int seconds, int receiving, boolean anyOutput) { + return receiving >= 2 && !anyOutput ? seconds + 1 : 0; } /** @@ -1050,19 +1053,25 @@ static boolean looksStuck(int receiving, int decoding) { */ private void checkDecoderStuck(QuadCell[] cells) { int receiving = 0; - int decoding = 0; + boolean anyOutput = false; for (QuadCell c : cells) { - if (c == null || !c.isReceiving()) continue; - receiving++; - if (c.outFps() > 0) decoding++; + if (c == null) continue; + // Output of any cell, a lossy one too, shows the decoder works (Qodo on #147) + if (c.outFps() > 0) anyOutput = true; + if (c.isReceiving()) receiving++; } - stuckSeconds = looksStuck(receiving, decoding) ? stuckSeconds + 1 : 0; + stuckSeconds = stuckStep(stuckSeconds, receiving, anyOutput); showDecoderStuck(stuckSeconds >= STUCK_SECONDS); } + /** Leaving or rebuilding the grid: a fresh count, no banner (Qodo on #147). */ + private void resetDecoderStuck() { + stuckSeconds = 0; + showDecoderStuck(false); + } + private void showDecoderStuck(boolean on) { if (!on) { - stuckSeconds = 0; if (stuckBanner != null) stuckBanner.setVisibility(View.GONE); return; } @@ -1852,6 +1861,7 @@ public void run() { private void stopStreams() { lastJpegFrameMs = 0; + resetDecoderStuck(); mSurface.removeCallbacks(singleUdpCheck); // The single view's stream ends here: it is no longer "connected" for the debug page singleState = "disconnected"; @@ -1946,7 +1956,7 @@ private void startStreams() { } else { // The views may still be in Quad layout from before the pause quadContainer.setVisibility(View.GONE); - showDecoderStuck(false); + resetDecoderStuck(); mSurface.setVisibility(View.VISIBLE); boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL); if (configured) { diff --git a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java index 7c960561..23436623 100644 --- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java @@ -44,6 +44,7 @@ interface Sink { private int dropped; /** Of those, dropped because a packet of them was lost (not for size) (#113). */ private int lostDropped; + private int gaps; NalAssembler(int bufferSize, Runnable onCodecSwitch, FramePool framePool) { this.nalBuffer = new byte[bufferSize]; @@ -62,6 +63,8 @@ void reset() { int droppedCount() { return dropped; } /** NAL units dropped because one of their packets was lost (#113). */ int lossCount() { return lostDropped; } + /** Gaps in the RTP sequence, any packet lost (#145). */ + int gapCount() { return gaps; } /** * Process one RTP packet ({@code length} bytes at {@code offset}, header included). @@ -86,6 +89,7 @@ boolean assemble(byte[] buf, int offset, int length, boolean codecH265, Sink sin int seq = ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF); boolean gap = lastSeq >= 0 && seq != ((lastSeq + 1) & 0xFFFF); + if (gap) gaps++; lastSeq = seq; if (gap && inFragment) { lostDropped++; 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 3e41b28e..f4f9783e 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -283,7 +283,8 @@ String sample(long nowMs) { lastIn = in; lastOut = out; lastLost = lost; - int netLoss = nalAssembler.lossCount(); + // Any lost packet, not only one inside a fragmented NAL unit (Qodo on #147) + int netLoss = nalAssembler.gapCount(); networkLoss = netLoss > lastNetLoss; lastNetLoss = netLoss; lastSampleMs = nowMs; diff --git a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java index ffc62264..7f54a7d3 100644 --- a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java +++ b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java @@ -78,12 +78,19 @@ public void udpHintListsEveryAddress() { } @Test - public void decoderStuckNeedsSeveralReceivingCellsAndNoOutput() { - assertTrue(Decoder.looksStuck(2, 0)); - assertTrue(Decoder.looksStuck(9, 0)); - // One camera alone may just wait for a key frame; any output means it works - assertFalse(Decoder.looksStuck(1, 0)); - assertFalse(Decoder.looksStuck(9, 1)); - assertFalse(Decoder.looksStuck(0, 0)); + public void decoderStuckIsReportedAfterTwentyQuietSeconds() { + int s = 0; + for (int i = 1; i < Decoder.STUCK_SECONDS; i++) { + s = Decoder.stuckStep(s, 9, false); + assertEquals(i, s); + } + assertTrue(s < Decoder.STUCK_SECONDS); + s = Decoder.stuckStep(s, 9, false); + assertTrue(s >= Decoder.STUCK_SECONDS); + // Any output, from any cell, starts the count again (Qodo on #147) + assertEquals(0, Decoder.stuckStep(s, 9, true)); + // One camera alone may just wait for a key frame + assertEquals(0, Decoder.stuckStep(5, 1, false)); + assertEquals(0, Decoder.stuckStep(5, 0, false)); } } From b009f858ff20fb9c4bc7e4e1242410f4a207e706 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 16:58:44 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20Qodo=20on=20#147=20=E2=80=94=20a=20c?= =?UTF-8?q?ell=20receives=20when=20whole=20units=20come=20out=20of=20its?= =?UTF-8?q?=20assembler,=20the=20threshold=20is=20time=20not=20ticks,=20th?= =?UTF-8?q?e=20gap=20count=20is=20published=20to=20the=20UI=20thread?= 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 | 33 ++++++++++++------- .../com/openipc/decoder/NalAssembler.java | 3 +- .../java/com/openipc/decoder/QuadCell.java | 16 ++++++++- .../com/openipc/decoder/QuadGridTest.java | 26 ++++++++------- 4 files changed, 53 insertions(+), 25 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 dfe5742f..7bcdec50 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1029,17 +1029,27 @@ public void run() { // ====================== The device's decoder stands still (#145) ====================== - /** Seconds with frames arriving in several cells and none decoded: the device is stuck. */ - static final int STUCK_SECONDS = 20; - private int stuckSeconds; + /** Time with frames arriving in several cells and none decoded: the device is stuck. */ + static final long STUCK_MS = 20_000; + /** Since when the condition holds, or -1 (monotonic ms; Qodo on #147: time, not ticks). */ + private long stuckSinceMs = -1; private TextView stuckBanner; /** - * One second of the detector: the seconds in a row in which at least two cells received - * whole frames and no cell at all decoded one (#145). + * One observation of the detector: since when at least two cells have received whole + * frames and no cell at all decoded one (#145), or -1 when that does not hold now. */ - static int stuckStep(int seconds, int receiving, boolean anyOutput) { - return receiving >= 2 && !anyOutput ? seconds + 1 : 0; + static long stuckSince(long sinceMs, long nowMs, int receiving, boolean anyOutput) { + if (receiving < 2 || anyOutput) return -1; + return sinceMs >= 0 ? sinceMs : nowMs; + } + + static boolean isStuck(long sinceMs, long nowMs) { + return sinceMs >= 0 && nowMs - sinceMs >= STUCK_MS; + } + + private boolean decoderStuck() { + return isStuck(stuckSinceMs, System.nanoTime() / 1_000_000); } /** @@ -1055,13 +1065,14 @@ private void checkDecoderStuck(QuadCell[] cells) { if (c.outFps() > 0) anyOutput = true; if (c.isReceiving()) receiving++; } - stuckSeconds = stuckStep(stuckSeconds, receiving, anyOutput); - showDecoderStuck(stuckSeconds >= STUCK_SECONDS); + long now = System.nanoTime() / 1_000_000; + stuckSinceMs = stuckSince(stuckSinceMs, now, receiving, anyOutput); + showDecoderStuck(isStuck(stuckSinceMs, now)); } /** Leaving or rebuilding the grid: a fresh count, no banner (Qodo on #147). */ private void resetDecoderStuck() { - stuckSeconds = 0; + stuckSinceMs = -1; showDecoderStuck(false); } @@ -1421,7 +1432,7 @@ private Map debugState() { dec.put("failed_load", planner.failedLoad()); m.put("decoder", dec); // Frames arrive in several cells, none decodes: restart the device (#145) - m.put("decoder_stuck", stuckSeconds >= STUCK_SECONDS); + m.put("decoder_stuck", decoderStuck()); } if (udpViewShown) { Map udp = new LinkedHashMap<>(); diff --git a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java index a04b5b6d..8f2755ee 100644 --- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java @@ -44,7 +44,8 @@ interface Sink { private int dropped; /** Of those, dropped because a packet of them was lost (not for size) (#113). */ private int lostDropped; - private int gaps; + /** Written by the receive thread, read by the UI thread (Qodo on #147). */ + private volatile int gaps; /** * Pass a NAL unit with a lost piece on cut short at the gap instead of dropping it (#133): * for a stream that loses a piece of every key frame, where dropping them leaves nothing to 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 c885bb66..728ed6c2 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -142,6 +142,8 @@ void setAudioSink(AudioManager sink, int route) { } }); this.nalSink = nal -> { + // Whole units out of the assembler, before the queue: what really arrives (#145) + assembled++; if (!nalQueue.offer(nal)) { // Rate-limited: one aggregation packet can carry many NAL units if (queueFullDrops++ % 100 == 0) { @@ -331,6 +333,9 @@ String sample(long nowMs) { // Any lost packet, not only one inside a fragmented NAL unit (Qodo on #147) int netLoss = jpeg ? jpegAssembler.droppedCount() : nalAssembler.gapCount(); networkLoss = netLoss > lastNetLoss; + int units = assembled; + unitsArrived = units != lastAssembled; + lastAssembled = units; lastNetLoss = netLoss; lastSampleMs = nowMs; checkUdpStall(); @@ -485,6 +490,14 @@ boolean showsVideo(long ms) { /** NAL units were lost on the network in the last second (#145). */ private volatile boolean networkLoss; + /** + * NAL units or JPEG frames out of the assembler: unlike marker bits (inFps), only data the + * decoder could take; queue drops included (Qodo on #147). + */ + private volatile int assembled; + private int lastAssembled; + /** Some arrived since the last sample, whatever the sampling interval. */ + private volatile boolean unitsArrived; private int lastNetLoss; /** @@ -492,7 +505,8 @@ boolean showsVideo(long ms) { * network may decode nothing for that reason alone (#113, #133). */ boolean isReceiving() { - return pausedReason == null && "connected".equals(state) && inFps >= 1 && !networkLoss; + return pausedReason == null && "connected".equals(state) && unitsArrived + && !networkLoss; } private boolean renderedWithin(long ms) { diff --git a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java index 7f54a7d3..1a5cbad6 100644 --- a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java +++ b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java @@ -78,19 +78,21 @@ public void udpHintListsEveryAddress() { } @Test - public void decoderStuckIsReportedAfterTwentyQuietSeconds() { - int s = 0; - for (int i = 1; i < Decoder.STUCK_SECONDS; i++) { - s = Decoder.stuckStep(s, 9, false); - assertEquals(i, s); + public void decoderStuckIsReportedAfterTwentySecondsOfTime() { + long since = -1; + // Ticks a little late: the time counts, not the ticks (Qodo on #147) + long t = 1000; + for (; t < 1000 + Decoder.STUCK_MS; t += 1300) { + since = Decoder.stuckSince(since, t, 9, false); + assertEquals(1000, since); + assertFalse(Decoder.isStuck(since, t)); } - assertTrue(s < Decoder.STUCK_SECONDS); - s = Decoder.stuckStep(s, 9, false); - assertTrue(s >= Decoder.STUCK_SECONDS); - // Any output, from any cell, starts the count again (Qodo on #147) - assertEquals(0, Decoder.stuckStep(s, 9, true)); + since = Decoder.stuckSince(since, t, 9, false); + assertTrue(Decoder.isStuck(since, t)); + // Any output, from any cell, ends it + assertEquals(-1, Decoder.stuckSince(since, t, 9, true)); // One camera alone may just wait for a key frame - assertEquals(0, Decoder.stuckStep(5, 1, false)); - assertEquals(0, Decoder.stuckStep(5, 0, false)); + assertEquals(-1, Decoder.stuckSince(-1, t, 1, false)); + assertFalse(Decoder.isStuck(-1, t)); } } From 6e4110499faad35744713ce9939a1054b0f52ac5 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:17:29 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20Qodo=20on=20#147=20=E2=80=94=20only?= =?UTF-8?q?=20H.264/H.265=20cells=20and=20only=20picture=20slices=20count?= =?UTF-8?q?=20for=20the=20stuck-decoder=20check;=20JPEG=20drop=20count=20p?= =?UTF-8?q?ublished=20to=20the=20UI=20thread?= 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 | 2 ++ .../main/java/com/openipc/decoder/JpegAssembler.java | 3 ++- .../src/main/java/com/openipc/decoder/QuadCell.java | 10 ++++++++-- 3 files changed, 12 insertions(+), 3 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 7bcdec50..3aac5281 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1062,6 +1062,8 @@ private void checkDecoderStuck(QuadCell[] cells) { for (QuadCell c : cells) { if (c == null) continue; // Output of any cell, a lossy one too, shows the decoder works (Qodo on #147) + // Only H.264/H.265 cells: MJPEG may be drawn without the video decoder at all + if (!c.usesVideoDecoder()) continue; if (c.outFps() > 0) anyOutput = true; if (c.isReceiving()) receiving++; } diff --git a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java index 89134142..06ebfbcf 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java @@ -83,7 +83,8 @@ interface Sink { /** Offset the next fragment must have; -1 while no frame is being collected. */ private int expectedOffset = -1; private byte[] header; - private int dropped; + /** Written by the receive thread, read by the UI thread (Qodo on #147). */ + private volatile int dropped; /** In-band tables by Q (128..254): a sender may send them once and then only the Q. */ private final java.util.Map tablesByQ = new java.util.HashMap<>(); private final java.util.Map precisionByQ = new java.util.HashMap<>(); 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 728ed6c2..74f2efbb 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -142,8 +142,11 @@ void setAudioSink(AudioManager sink, int route) { } }); this.nalSink = nal -> { - // Whole units out of the assembler, before the queue: what really arrives (#145) - assembled++; + // Picture slices out of the assembler, before the queue: what the video decoder + // would get (#145); parameter sets alone are no picture (Qodo on #147) + if (!jpeg && nal.length() > 4 && NalAssembler.isSlice(nal.data()[4], codecH265)) { + assembled++; + } if (!nalQueue.offer(nal)) { // Rate-limited: one aggregation packet can carry many NAL units if (queueFullDrops++ % 100 == 0) { @@ -504,6 +507,9 @@ boolean showsVideo(long ms) { * Connected, not paused, and whole frames arrive (#145): a cell losing packets on the * network may decode nothing for that reason alone (#113, #133). */ + /** H.264/H.265, decoded by the device's video decoder (not MJPEG, #145). */ + boolean usesVideoDecoder() { return !jpeg; } + boolean isReceiving() { return pausedReason == null && "connected".equals(state) && unitsArrived && !networkLoss; From 72f5be644bdb260b94775cbb0a30af21369dd258 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:25:03 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20Qodo=20on=20#147=20=E2=80=94=20on=20?= =?UTF-8?q?key=20frames=20only=20the=20key=20slices=20count,=20and=20a=20c?= =?UTF-8?q?ell=20with=20its=20own=20decoder=20problem=20is=20left=20out=20?= =?UTF-8?q?of=20the=20stuck-decoder=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../src/main/java/com/openipc/decoder/QuadCell.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 74f2efbb..520cb35d 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -144,7 +144,10 @@ void setAudioSink(AudioManager sink, int route) { this.nalSink = nal -> { // Picture slices out of the assembler, before the queue: what the video decoder // would get (#145); parameter sets alone are no picture (Qodo on #147) - if (!jpeg && nal.length() > 4 && NalAssembler.isSlice(nal.data()[4], codecH265)) { + // On key frames only, just the slices decode() really passes on (Qodo on #147) + if (!jpeg && nal.length() > 4 && NalAssembler.isSlice(nal.data()[4], codecH265) + && (!(keyFramesOnly || waitingKeyFrame) || isKeyNal( + NalAssembler.fragment(nal.data()[4], codecH265), codecH265))) { assembled++; } if (!nalQueue.offer(nal)) { @@ -511,8 +514,9 @@ boolean showsVideo(long ms) { boolean usesVideoDecoder() { return !jpeg; } boolean isReceiving() { - return pausedReason == null && "connected".equals(state) && unitsArrived - && !networkLoss; + // A cell with its own decoder problem (error, limit) says so itself (Qodo on #147) + return pausedReason == null && problem == null && "connected".equals(state) + && unitsArrived && !networkLoss; } private boolean renderedWithin(long ms) {