Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()}\""
}
Expand Down
75 changes: 49 additions & 26 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ 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". */
private boolean 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). */
private TextView debugUrls;
private volatile int quadAudioGen;
Expand Down Expand Up @@ -332,20 +333,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();
Expand All @@ -359,21 +353,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);
}

Expand All @@ -390,8 +375,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
Expand Down Expand Up @@ -1468,6 +1454,9 @@ private Map<String, Object> debugState() {
single.put("codec", rtspClient != null && rtspClient.isJpeg() ? "MJPEG"
: codecH265 ? "H.265" : "H.264");
single.put("audio", audioCodec);
// 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);
}
QuadCell[] cells = quadCells;
Expand Down Expand Up @@ -1701,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). */
Expand Down Expand Up @@ -1751,11 +1741,21 @@ 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); 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)
&& drawJpeg(buffer, gen)) {
lastSlideJpegMs = now;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
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();
Expand All @@ -1782,6 +1782,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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Single-view mode changes may be ignored 🐞 Bug ☼ Reliability

skipForKeyFrames() reads slides on the video worker, but setSlides() writes that non-volatile
field on the UI thread without synchronization. When S is toggled in single view, the worker is not
guaranteed to observe the new value, so it can keep passing every frame or keep dropping inter
frames after the menu shows the opposite mode.
Agent Prompt
## Issue description
The single-view video worker reads the UI-owned `slides` field without a visibility guarantee, so toggling S may not change frame selection.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[103-103]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1742-1745]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1789-1795]
## Recommended Fix
Make the playback-mode state safely visible to the video worker, for example by declaring `slides` volatile, and use that state consistently in both the MJPEG throttle and NAL filter.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
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;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment on lines +1797 to +1799

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Healthy streams can fall back to tcp 🐞 Bug ≡ Correctness

skipForKeyFrames() drops non-key frames in single-view S mode, but singleUdpCheck still treats
eight seconds without a rendered frame as a broken UDP stream. On a healthy UDP feed with key frames
more than eight seconds apart and continuing packet traffic, the check reports a false stall,
enables packet-loss salvage, and can force Auto transport onto TCP.
Agent Prompt
## Issue description
Single-view S mode intentionally drops non-key frames, but the UDP watchdog interprets the resulting gap in rendered frames as a transport failure.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1794-1801]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1830-1854]

## Recommended Fix
Make the single-view UDP stall check distinguish intentional key-frame-only output gaps from evidence of packet loss before enabling salvage or reporting UDP broken. Preserve detection of genuinely stalled streams.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return false;
}

// ====================== Lifecycle ======================

@Override
Expand All @@ -1803,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
Expand All @@ -1816,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;
Expand All @@ -1836,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
Expand Down
10 changes: 5 additions & 5 deletions android/app/src/main/java/com/openipc/decoder/MenuManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
Expand Down
Loading