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
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<activity android:name="Crash" android:exported="false" android:process=":crash" />
<activity
android:name="Decoder"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:screenOrientation="sensorLandscape"
Expand Down
30 changes: 26 additions & 4 deletions android/app/src/main/java/com/openipc/decoder/AudioManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class AudioManager {
private final BlockingQueue<byte[]> aacQueue = new ArrayBlockingQueue<>(32);

private volatile AudioConfig config = new AudioConfig();
/** While false no AudioTrack or AAC decoder is created (paused activity). */
private volatile boolean enabled = true;
private final Object trackLock = new Object();

/** Disabling also closes audio; a worker still running cannot start playback again. */
void setEnabled(boolean enabled) {
this.enabled = enabled;
if (!enabled) closeAudio();
}
private String lastUnsupported;

void shutdown() {
Expand Down Expand Up @@ -126,7 +135,10 @@ void closeAudio() {
aacRunning = false;
aacQueue.clear();
pcmQueue.clear();
AudioTrack track = audioTrackRef.getAndSet(null);
AudioTrack track;
synchronized (trackLock) {
track = audioTrackRef.getAndSet(null);
}
if (track != null) {
Log.i(TAG, "Close audio decoder");
try {
Expand Down Expand Up @@ -229,6 +241,7 @@ private void playAudio(Frame data) {
}

private AudioTrack createPcmAudio(int sample, int channels) {
if (!enabled) return null;
int mask = channels == 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO;

Log.d(TAG, "Create PCM audio (" + sample + "hz, " + channels + "ch)");
Expand Down Expand Up @@ -268,7 +281,16 @@ private AudioTrack createPcmAudio(int sample, int channels) {
return null;
}

AudioTrack old = audioTrackRef.getAndSet(track);
AudioTrack old;
synchronized (trackLock) {
// Paused while the track was being created: do not publish it
if (!enabled) {
try { track.stop(); } catch (Exception ignored) {}
track.release();
return null;
}
old = audioTrackRef.getAndSet(track);
}
if (old != null) {
try { old.stop(); } catch (Exception ignored) {}
old.release();
Expand Down Expand Up @@ -386,9 +408,9 @@ static byte[] buildAacConfig(int sampleRate, int channels) {
}

private void ensureAacDecoder(AudioConfig c, boolean adts) {
if (aacDecoder != null || audioFailed) return;
if (aacDecoder != null || audioFailed || !enabled) return;
synchronized (aacDecoderLock) {
if (aacDecoder != null || audioFailed) return;
if (aacDecoder != null || audioFailed || !enabled) return;

Log.d(TAG, "Creating AAC decoder (" + c.sampleRate + "hz, " + c.channels + "ch, "
+ (adts ? "ADTS" : "raw") + ")");
Expand Down
126 changes: 98 additions & 28 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,12 @@ public void onCodecChanged(boolean h265, AudioConfig audio) {
quadViews[idx].setOnClickListener(cv ->
menuManager.showMenu(menuAnchor, quadEnabled, mActive, mVersion,
BuildConfig.GIT_HASH, mHosts));
lr.addView(quadViews[idx], new LinearLayout.LayoutParams(
// Each cell is a FrameLayout so overlays can be stacked on the video
FrameLayout cell = new FrameLayout(this);
cell.addView(quadViews[idx], new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
lr.addView(cell, new LinearLayout.LayoutParams(
0, LinearLayout.LayoutParams.MATCH_PARENT, 1));
}
}
Expand Down Expand Up @@ -326,21 +331,32 @@ private void loadSettings() {
edit.apply();
}

mActive = 0;
quadEnabled = false;
// Restore the last camera and mode (#22); both are saved but were never read back
mActive = clampSlot(pref.getInt("active", 0));
quadEnabled = pref.getBoolean("quad_enabled", false);
for (int i = 0; i < CAM_COUNT; i++) {
mHosts[i] = pref.getString("host_" + i, DEFAULT_URL);
}
applyActiveCamera();

Intent intent = getIntent();
String link = intent.getStringExtra(Intent.EXTRA_TEXT);
if (link != null) {
Log.d(TAG, "Link: " + Uri.parse(link).getHost());
mHosts[mActive] = sanitizeUrl(link);
mHost = mHosts[mActive];
// A shared link is meant to be watched: single view, and saved so that it is
// still there after the next pause/resume
quadEnabled = false;
pref.edit()
.putString("host_" + mActive, mHosts[mActive])
.putBoolean("quad_enabled", false)
.apply();
intent.removeExtra(Intent.EXTRA_TEXT);
}
applyActiveCamera();
}

static int clampSlot(int slot) {
return slot >= 0 && slot < CAM_COUNT ? slot : 0;
}

static String sanitizeUrl(String url) {
Expand Down Expand Up @@ -464,13 +480,11 @@ private void startQuad() {
closeDecoder();
closeAudio();
nalQueue.clear();
if (executor != null) {
executor.shutdownNow();
executor = null;
}
stopExecutor();

mSurface.setVisibility(View.GONE);
quadContainer.setVisibility(View.VISIBLE);
ensureQuadLaidOut();
removeClearOverlay();

quadCells = new QuadCell[4];
Expand Down Expand Up @@ -502,6 +516,26 @@ private void stopQuad() {
mSurface.setVisibility(View.VISIBLE);
}

/**
* Seen on an Android 10 TV box: after the window briefly lost visibility (a task brought
* to the front by another start), the Quad container stayed at 0x0 with no layout pass
* scheduled, so the cells never got a surface. Check once it should have been laid out
* and force a pass if not.
*/
private void ensureQuadLaidOut() {
quadContainer.postDelayed(() -> {
if (!quadEnabled || quadContainer.getWidth() > 0) return;
Log.w(TAG, "Quad layout did not happen, forcing a layout pass");
View decor = getWindow().getDecorView();
for (View v = quadContainer; v != null && v != decor;
v = v.getParent() instanceof View ? (View) v.getParent() : null) {
v.forceLayout();
}
decor.requestLayout();
decor.invalidate();
}, 500);
}

/** Clear the video view so no stale frame lingers. */
private void clearVideo() {
if (mSurface == null) return;
Expand All @@ -528,15 +562,16 @@ private void clearQuadViews() {
for (int i = 0; i < 4; i++) {
TextureView t = quadViews[i];
if (t == null) continue;
View overlay = t.findViewWithTag("quad_overlay_" + i);
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);
((FrameLayout) t.getParent()).addView(overlay);
cell.addView(overlay);
}
overlay.setVisibility(View.VISIBLE);
overlay.bringToFront();
Expand All @@ -546,7 +581,7 @@ private void clearQuadViews() {
private void removeQuadOverlay(int index) {
if (index < 0 || index >= quadViews.length || quadViews[index] == null) return;
TextureView t = quadViews[index];
View overlay = t.findViewWithTag("quad_overlay_" + index);
View overlay = ((View) t.getParent()).findViewWithTag("quad_overlay_" + index);
if (overlay != null) {
overlay.setVisibility(View.GONE);
}
Expand Down Expand Up @@ -673,9 +708,10 @@ private int dp(float dp) {
// ====================== Video decode thread ======================

private void startListener() {
listenerGen++;
// Stop the previous pool first (#23): stopExecutor() bumps listenerGen itself, so the
// generation for the new threads must be taken after it
stopExecutor();
final int gen = listenerGen;

executor = Executors.newFixedThreadPool(2);

// Video decode thread
Expand All @@ -685,9 +721,12 @@ private void startListener() {
try {
Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS);
if (buffer != null) {
boolean needCreate = videoDecoder.decodeFrame(buffer, codecH265, 0);
if (videoDecoder.decodeFrame(buffer, codecH265, 0)) {
createDecoder();
// The frame that triggered creation (often the SPS) goes in too
videoDecoder.decodeFrame(buffer, codecH265, 0);
}
framePool.recycle(buffer);
if (needCreate) createDecoder();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Expand All @@ -701,7 +740,7 @@ private void startListener() {
// Audio playback thread
executor.execute(() -> {
Thread.currentThread().setName("rtsp-audio");
while (gen == listenerGen) {
while (gen == listenerGen && !Thread.currentThread().isInterrupted()) {
audioManager.playPcmFrame();
}
});
Expand All @@ -712,8 +751,12 @@ private void startListener() {
@Override
protected void onPause() {
super.onPause();
mResumed = false;
menuManager.onPauseDismissBrowser();
stopStreams();
}

private void stopStreams() {
if (quadCells != null) {
for (QuadCell cell : quadCells) {
if (cell != null) cell.stop();
Expand All @@ -725,21 +768,23 @@ protected void onPause() {
if (rtspClient != null) {
rtspClient.stop();
}
// Disabled first: the decode thread can no longer create a decoder after this
videoDecoder.setEnabled(false);
// Same for audio: a worker still running cannot start playback again. Nothing to wait
// for on the UI thread (#32: this used to block it for seconds)
audioManager.setEnabled(false);
stopExecutor();
nalQueue.clear();
}

private void stopExecutor() {
// Invalidate the loops first: shutdownNow() alone left the audio loop spinning on
// InterruptedException at 100% CPU (seen on the device after entering Quad)
listenerGen++;
if (executor != null) {
executor.shutdownNow();
try {
if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
executor = null;
}
closeDecoder();
closeAudio();
nalQueue.clear();
}

@Override
Expand All @@ -748,14 +793,36 @@ protected void onDestroy() {
audioManager.shutdown();
}

/** A link shared while the app is open arrives here; onResume() then applies it. */
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
// Android 10+ may deliver it to the resumed activity without onPause()/onResume():
// apply the shared link now
if (mResumed) {
stopStreams();
startStreams();
}
}

@Override
protected void onResume() {
super.onResume();
mResumed = true;
startStreams();
}

private boolean mResumed;

private void startStreams() {
loadSettings();
videoDecoder.setEnabled(true);
audioManager.setEnabled(true);
if (quadEnabled) {
mSurface.setVisibility(View.GONE);
quadContainer.setVisibility(View.VISIBLE);
ensureQuadLaidOut();
if (quadCells != null) {
for (QuadCell cell : quadCells) {
if (cell != null) cell.stop();
Expand All @@ -773,6 +840,9 @@ protected void onResume() {
getSharedPreferences("settings", MODE_PRIVATE).edit()
.putBoolean("quad_enabled", true).apply();
} else {
// The views may still be in Quad layout from before the pause
quadContainer.setVisibility(View.GONE);
mSurface.setVisibility(View.VISIBLE);
boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL);
if (configured) {
rtspClient.configure(mHost, mUserAgent);
Expand Down
44 changes: 44 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/DecoderRetry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
* DecoderRetry.java — back-off for re-creating a failed MediaCodec decoder
*
*/

package com.openipc.decoder;

/**
* After a decoder failure the next attempt waits 0.5 s, then 1, 2, 4, 8 s, at most 10 s.
* A decoded frame resets the back-off. Thread-safe.
*/
class DecoderRetry {
static final long MIN_DELAY_MS = 500;
static final long MAX_DELAY_MS = 10_000;

private int failures;
private long notBeforeMs;

synchronized void onFailure(long nowMs) {
failures++;
notBeforeMs = nowMs + delayMs(failures);
}

synchronized void onSuccess() {
failures = 0;
notBeforeMs = 0;
}

synchronized boolean mayCreate(long nowMs) {
return nowMs >= notBeforeMs;
}

synchronized int failures() {
return failures;
}

static long delayMs(int failures) {
if (failures <= 0) return 0;
int shift = Math.min(failures - 1, 5);
return Math.min(MAX_DELAY_MS, MIN_DELAY_MS << shift);
}
}
Loading
Loading