Repository navigation
feat: multi-view cell stats and status, decoder limits (#80, #82, #83) #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -92,6 +92,13 @@ public class Decoder extends Activity | |
| // Quad audio (#12): the cell whose sound plays, -1 for none | ||
| private int quadAudio = -1; | ||
| private final TextView[] quadAudioBadges = new TextView[CAM_COUNT]; | ||
| /** Small text in each cell's corner: stats while playing, otherwise the status (#80, #82). */ | ||
| private final TextView[] quadStats = new TextView[CAM_COUNT]; | ||
| /** Samples (seconds) a cell may fall behind before the grid is relieved (#83). */ | ||
| private static final int OVERLOAD_SAMPLES = 5; | ||
| /** After pausing a cell, time for the load to settle before the next decision. */ | ||
| private static final long RELIEF_COOLDOWN_MS = 10_000; | ||
| private long lastReliefMs; | ||
| private volatile int quadAudioGen; | ||
| private ExecutorService quadAudioExecutor; | ||
|
|
||
|
|
@@ -571,6 +578,7 @@ private void startQuad() { | |
| } | ||
|
|
||
| private void stopQuad() { | ||
| quadContainer.removeCallbacks(quadStatsTick); | ||
| stopQuadAudio(); | ||
| quadEnabled = false; | ||
| getSharedPreferences("settings", MODE_PRIVATE).edit() | ||
|
|
@@ -621,6 +629,7 @@ private void buildQuadGrid(int size) { | |
| quadContainer.removeAllViews(); | ||
| Arrays.fill(quadViews, null); | ||
| Arrays.fill(quadAudioBadges, null); | ||
| Arrays.fill(quadStats, null); | ||
| for (int row = 0; row < size; row++) { | ||
| LinearLayout lr = new LinearLayout(this); | ||
| lr.setOrientation(LinearLayout.HORIZONTAL); | ||
|
|
@@ -662,6 +671,18 @@ private void buildQuadGrid(int size) { | |
| bp.setMargins(dp(4), dp(4), dp(4), dp(4)); | ||
| cell.addView(badge, bp); | ||
| quadAudioBadges[idx] = badge; | ||
| TextView stats = new TextView(this); | ||
| stats.setTextColor(Color.WHITE); | ||
| stats.setTextSize(10); | ||
| stats.setBackgroundColor(0x80000000); | ||
| stats.setPadding(dp(4), dp(1), dp(4), dp(1)); | ||
| stats.setVisibility(View.GONE); | ||
| FrameLayout.LayoutParams sp = new FrameLayout.LayoutParams( | ||
| FrameLayout.LayoutParams.WRAP_CONTENT, | ||
| FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM | Gravity.START); | ||
| sp.setMargins(dp(2), dp(2), dp(2), dp(2)); | ||
| cell.addView(stats, sp); | ||
| quadStats[idx] = stats; | ||
| lr.addView(cell, new LinearLayout.LayoutParams( | ||
| 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); | ||
| } | ||
|
|
@@ -723,6 +744,7 @@ private void createQuadCells() { | |
| } | ||
| quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1); | ||
| startQuadAudio(); | ||
| startStatsTick(); | ||
| } | ||
|
|
||
| // ====================== Quad audio (#12) ====================== | ||
|
|
@@ -807,22 +829,92 @@ private void removeClearOverlay() { | |
| } | ||
|
|
||
| private void clearQuadViews() { | ||
| for (int i = 0; i < CAM_COUNT; i++) { | ||
| SurfaceView t = quadViews[i]; | ||
| if (t == null) continue; | ||
| FrameLayout cell = (FrameLayout) t.getParent(); | ||
| View overlay = cell.findViewWithTag("quad_overlay_" + i); | ||
| if (overlay == null) { | ||
| overlay = new View(this); | ||
| overlay.setTag("quad_overlay_" + i); | ||
| overlay.setLayoutParams(new FrameLayout.LayoutParams( | ||
| FrameLayout.LayoutParams.MATCH_PARENT, | ||
| FrameLayout.LayoutParams.MATCH_PARENT)); | ||
| overlay.setBackgroundColor(Color.BLACK); | ||
| cell.addView(overlay); | ||
| for (int i = 0; i < CAM_COUNT; i++) showQuadOverlay(i); | ||
| } | ||
|
|
||
| /** Black overlay over a cell; its corner text stays visible above it. */ | ||
| private void showQuadOverlay(int i) { | ||
| SurfaceView t = quadViews[i]; | ||
| if (t == null) return; | ||
| FrameLayout cell = (FrameLayout) t.getParent(); | ||
| View overlay = cell.findViewWithTag("quad_overlay_" + i); | ||
| if (overlay == null) { | ||
| overlay = new View(this); | ||
| overlay.setTag("quad_overlay_" + i); | ||
| overlay.setLayoutParams(new FrameLayout.LayoutParams( | ||
| FrameLayout.LayoutParams.MATCH_PARENT, | ||
| FrameLayout.LayoutParams.MATCH_PARENT)); | ||
| overlay.setBackgroundColor(Color.BLACK); | ||
| cell.addView(overlay); | ||
| } | ||
| overlay.setVisibility(View.VISIBLE); | ||
| overlay.bringToFront(); | ||
| if (quadStats[i] != null) quadStats[i].bringToFront(); | ||
| } | ||
|
|
||
| // ====================== Cell stats and decoder load (#80, #82, #83) ====================== | ||
|
|
||
| private final Runnable quadStatsTick = new Runnable() { | ||
| private int ticks; | ||
|
|
||
| @Override | ||
| public void run() { | ||
| QuadCell[] cells = quadCells; | ||
| if (!quadEnabled || cells == null) return; | ||
| long now = System.nanoTime() / 1_000_000; | ||
| boolean log = ++ticks % 5 == 0; | ||
| for (int i = 0; i < cells.length; i++) { | ||
| QuadCell c = cells[i]; | ||
| TextView v = quadStats[i]; | ||
| if (c == null || v == null) continue; | ||
| String line = c.sample(now); | ||
| v.setText(line); | ||
| v.setVisibility(View.VISIBLE); | ||
| if (log) Log.i(TAG, "Quad-" + i + ": " + line); | ||
| } | ||
| overlay.setVisibility(View.VISIBLE); | ||
| overlay.bringToFront(); | ||
| relieveDecoder(cells, now); | ||
| quadContainer.postDelayed(this, 1000); | ||
| } | ||
| }; | ||
|
|
||
| private void startStatsTick() { | ||
| quadContainer.removeCallbacks(quadStatsTick); | ||
| quadContainer.postDelayed(quadStatsTick, 1000); | ||
| } | ||
|
|
||
| /** | ||
| * Load is judged by measurement, not by the declared budget: a cell falls behind when the | ||
| * decoder makes it lose frames (its queue overflows, or the decoder has no free input | ||
| * buffer). Frames lost on the network do not count: they lower decoded/received in the | ||
| * corner text but say nothing about the decoder. When a cell keeps falling behind, the | ||
| * highest-numbered playing cell is paused, then the load is given time to settle (#83). | ||
| */ | ||
| private void relieveDecoder(QuadCell[] cells, long now) { | ||
| boolean overloaded = false; | ||
| int active = 0; | ||
| int last = -1; | ||
| for (int i = 0; i < cells.length; i++) { | ||
| QuadCell c = cells[i]; | ||
| if (c == null || c.isPaused()) continue; | ||
| active++; | ||
| last = i; | ||
| if (c.isSettled(now) && c.inFps() >= 5) { | ||
| boolean behind = c.lostFps() >= Math.max(2f, 0.1f * c.inFps()); | ||
| c.behindSamples = behind ? c.behindSamples + 1 : 0; | ||
| if (c.behindSamples >= OVERLOAD_SAMPLES) overloaded = true; | ||
|
Comment on lines
+901
to
+904
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Nal drops can trigger false overload sample() sums queueFullDrops and inputDropped as lostFps, although both count dropped NAL units, while relieveDecoder() compares that rate with RTP-marker-counted inFps without using the decoded frame rate. Multi-slice frames, aggregated NAL packets, or parameter sets sent with keyframes can inflate the measured loss past the max(2, 10% of inFps) threshold and pause a cell even when decoded-to-received frame throughput remains healthy. Agent Prompt
|
||
| } else { | ||
| c.behindSamples = 0; | ||
| } | ||
| } | ||
| if (!overloaded || active < 2 || now - lastReliefMs < RELIEF_COOLDOWN_MS) return; | ||
| lastReliefMs = now; | ||
| String reason = "Paused: decoder overloaded"; | ||
| Log.w(TAG, "Decoder overloaded with " + active + " streams: pausing Quad-" + last); | ||
| cells[last].pause(reason); | ||
| showQuadOverlay(last); | ||
| if (quadStats[last] != null) quadStats[last].setText(reason); | ||
|
Comment on lines
+913
to
+915
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 7. Freed decoder slot restores the overload cells[last].pause() stops the cell, and setEnabled(false) calls releaseSlot(), which returns its InstanceBudget slot. A different cell that is waiting on Decoder limit keeps retrying tryAcquire (back-off of at most 10 s), takes the slot and starts decoding. The decoder load is back where it was, and after the cooldown another cell is paused, so cells drop one by one while the total load stays the same. Agent Prompt
|
||
| for (QuadCell c : cells) { | ||
| if (c != null) c.behindSamples = 0; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1058,6 +1150,7 @@ protected void onPause() { | |
| menuManager.onPauseDismissBrowser(); | ||
| // The UDP view is not a saved mode: after a resume the slot or grid comes back | ||
| leaveUdpView(); | ||
| quadContainer.removeCallbacks(quadStatsTick); | ||
| stopStreams(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,11 +8,15 @@ | |
| package com.openipc.decoder; | ||
|
|
||
| import android.media.MediaCodec; | ||
| import android.media.MediaCodecInfo; | ||
| import android.media.MediaFormat; | ||
| import android.os.Build; | ||
| import android.util.Log; | ||
| import android.view.Surface; | ||
|
|
||
| import java.nio.ByteBuffer; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
|
|
||
| class MediaCodecManager { | ||
| private static final String TAG = "OpenIPCDecoder"; | ||
|
|
@@ -44,10 +48,92 @@ class MediaCodecManager { | |
| // Surface set by the owning activity | ||
| private volatile Surface surface; | ||
|
|
||
| /** Frames rendered so far (#82): with the frames received this gives the real decode rate. */ | ||
| private volatile int framesOut; | ||
| /** NAL units not fed because the decoder had no free input buffer: it is not keeping up. */ | ||
| private volatile int inputDropped; | ||
| private volatile int outWidth; | ||
| private volatile int outHeight; | ||
| /** MIME type whose instance slot this manager holds while it has a decoder (#83). */ | ||
| private String heldMime; | ||
|
|
||
| interface Listener { | ||
| void onResolutionChanged(int width, int height); | ||
| void onDecoderStarted(); | ||
| void onDecoderFailed(); | ||
| /** No decoder was created: {@code limit} decoders of {@code mime} already exist (#83). */ | ||
| default void onDecoderLimit(String mime, int limit) {} | ||
| } | ||
|
|
||
| int framesOut() { return framesOut; } | ||
| int inputDropped() { return inputDropped; } | ||
| int outWidth() { return outWidth; } | ||
| int outHeight() { return outHeight; } | ||
| boolean hasDecoder() { return decoder != null; } | ||
|
|
||
| /** | ||
| * Decoders alive in this process per MIME type, against the declared limit of the codec | ||
| * (MediaCodecInfo.CodecCapabilities#getMaxSupportedInstances, Android 6+). The limit is | ||
| * learnt from the first decoder of a type, so it is the codec that createDecoderByType | ||
| * really picks (#83). | ||
| */ | ||
| static final class InstanceBudget { | ||
| private static final Map<String, Integer> used = new HashMap<>(); | ||
| private static final Map<String, Integer> limit = new HashMap<>(); | ||
|
|
||
| static synchronized boolean tryAcquire(String mime) { | ||
| int u = used.containsKey(mime) ? used.get(mime) : 0; | ||
| Integer max = limit.get(mime); | ||
| if (max != null && u >= max) return false; | ||
| used.put(mime, u + 1); | ||
| return true; | ||
| } | ||
|
|
||
| static synchronized void release(String mime) { | ||
| int u = used.containsKey(mime) ? used.get(mime) : 0; | ||
| if (u > 0) used.put(mime, u - 1); | ||
| } | ||
|
|
||
| static synchronized void learn(String mime, int max) { | ||
| if (max > 0 && !limit.containsKey(mime)) limit.put(mime, max); | ||
| } | ||
|
|
||
| /** Declared limit, or -1 while unknown. */ | ||
| static synchronized int limit(String mime) { | ||
| Integer max = limit.get(mime); | ||
| return max != null ? max : -1; | ||
| } | ||
|
|
||
| static synchronized void reset() { | ||
| used.clear(); | ||
| limit.clear(); | ||
| } | ||
| } | ||
|
|
||
| /** Give the instance slot back; called wherever the decoder is detached (decoderLock held). */ | ||
| private void releaseSlot() { | ||
| if (heldMime != null) { | ||
| InstanceBudget.release(heldMime); | ||
| heldMime = null; | ||
| } | ||
| } | ||
|
|
||
| /** Learn the codec's instance limit and log its declared throughput, once per type. */ | ||
| private static void learnLimits(MediaCodec codec, String mime) { | ||
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return; | ||
| if (InstanceBudget.limit(mime) >= 0) return; | ||
| try { | ||
| MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime); | ||
| int max = caps.getMaxSupportedInstances(); | ||
| InstanceBudget.learn(mime, max); | ||
| MediaCodecInfo.VideoCapabilities video = caps.getVideoCapabilities(); | ||
| String fps1080 = video != null && video.isSizeSupported(1920, 1080) | ||
| ? String.valueOf(video.getSupportedFrameRatesFor(1920, 1080).getUpper()) : "?"; | ||
| Log.i(TAG, "Decoder " + codec.getName() + " (" + mime + "): up to " + max | ||
| + " instances, declared 1080p frame rate in total " + fps1080); | ||
| } catch (Exception e) { | ||
| Log.w(TAG, "Decoder limits unknown: " + e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| private Listener listener; | ||
|
|
@@ -76,6 +162,7 @@ void setEnabled(boolean enabled) { | |
| synchronized (decoderLock) { | ||
| if (decoder != target) return; // already replaced or released | ||
| decoder = null; | ||
| releaseSlot(); | ||
|
Comment on lines
164
to
+165
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5. A freed slot can still hold a decoder releaseSlot() decrements the shared instance count before the associated codec is stopped and released. When another cell starts during that interval, it can acquire the supposedly free slot and attempt to create a decoder while the device still has the maximum number alive. Agent Prompt
|
||
| retry.onSuccess(); | ||
| } | ||
| releaseNow(target); | ||
|
|
@@ -98,6 +185,7 @@ void closeDecoder() { | |
| MediaCodec codec = decoder; | ||
| if (codec == null) return; | ||
| decoder = null; | ||
| releaseSlot(); | ||
| Log.i(TAG, "Close video decoder"); | ||
| try { | ||
| codec.stop(); | ||
|
|
@@ -131,10 +219,21 @@ void createDecoder(boolean codecH265) { | |
| // Must hold the largest NAL unit the assembler can deliver | ||
| format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_NAL_SIZE); | ||
|
|
||
| if (!InstanceBudget.tryAcquire(type)) { | ||
| int limit = InstanceBudget.limit(type); | ||
| synchronized (decoderLock) { | ||
| retry.onFailure(nowMs()); | ||
|
Comment on lines
+222
to
+225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Two cells can exceed decoder limit createDecoder() reserves an instance slot before learnLimits() discovers the codec's declared maximum, so the initial reservations are allowed while the limit is unknown. If multiple cells start the same codec concurrently, they can all create decoders before the first limit is published and exceed that declared maximum. Agent Prompt
|
||
| } | ||
| Log.w(TAG, "Decoder limit reached: " + limit + " × " + type); | ||
| if (listener != null) listener.onDecoderLimit(type, limit); | ||
| return; | ||
| } | ||
|
|
||
| MediaCodec local; | ||
| try { | ||
| Log.i(TAG, "Start video decoder (" + type + ")"); | ||
| local = MediaCodec.createDecoderByType(type); | ||
| learnLimits(local, type); | ||
| try { | ||
| local.configure(format, s, null, 0); | ||
| local.start(); | ||
|
|
@@ -144,6 +243,7 @@ void createDecoder(boolean codecH265) { | |
| throw e; | ||
| } | ||
| } catch (Exception e) { | ||
| InstanceBudget.release(type); | ||
| synchronized (decoderLock) { | ||
| // A creation cancelled by a pause must not delay the next start | ||
| if (gen != lifecycle || !enabled) return; | ||
|
|
@@ -156,10 +256,12 @@ void createDecoder(boolean codecH265) { | |
| synchronized (decoderLock) { | ||
| // Disabled meanwhile (pause/stop) or another thread was faster | ||
| if (decoder != null || !enabled || gen != lifecycle) { | ||
| InstanceBudget.release(type); | ||
| releaseAsync(local); | ||
| return; | ||
| } | ||
| decoder = local; | ||
| heldMime = type; | ||
| awaitingConfig = true; | ||
| configSeen = 0; | ||
| } | ||
|
|
@@ -213,6 +315,8 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { | |
| codec.queueInputBuffer(inputBufferId, 0, | ||
| buffer.length(), System.nanoTime() / 1000, flag); | ||
| } | ||
| } else { | ||
| inputDropped++; | ||
| } | ||
|
|
||
| MediaCodec.BufferInfo info = bufferInfo; | ||
|
|
@@ -223,16 +327,20 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { | |
| MediaFormat format = codec.getOutputFormat(); | ||
| int mWidth = format.getInteger(MediaFormat.KEY_WIDTH); | ||
| int mHeight = format.getInteger(MediaFormat.KEY_HEIGHT); | ||
| outWidth = mWidth; | ||
| outHeight = mHeight; | ||
| if (listener != null) { | ||
| listener.onResolutionChanged(mWidth, mHeight); | ||
| } | ||
| } else { | ||
| codec.releaseOutputBuffer(outputBufferId, true); | ||
| framesOut++; | ||
| retry.onSuccess(); // a decoded frame: the decoder works | ||
| } | ||
| } | ||
| } catch (Exception e) { | ||
| decoder = null; | ||
| releaseSlot(); | ||
| retry.onFailure(nowMs()); | ||
| Log.e(TAG, "Codec exception, retry in " | ||
| + DecoderRetry.delayMs(retry.failures()) + " ms: " + e.getMessage()); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3. Disconnected cells can be paused
📎 Requirement gap☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools