Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern
```
rtsp://username:password@ip_address:554/stream=0
```
Under the URL you can choose the transport; Auto (UDP, falling back to TCP) is the default for a new slot. Other URL types: [Transports](docs/transports.md).
Under the URL you can choose the transport; Auto (UDP, falling back to TCP) is the default for a new slot. Below it, **Turn** turns the picture of a camera mounted sideways or upside down. Other URL types: [Transports](docs/transports.md).
4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops.

## Controls
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
```
rtsp://username:password@ip_address:554/stream=0
```
Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Другие виды адресов — в [Transports](docs/transports.md) (на английском).
Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами. Другие виды адресов — в [Transports](docs/transports.md) (на английском).
4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся.

## Управление
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
```
rtsp://username:password@ip_address:554/stream=0
```
地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其他地址格式见 [Transports](docs/transports.md)(英文)。
地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。
4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。

## 操作说明
Expand Down
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 41
versionName "2.16"
versionCode 43
versionName "2.18"

buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
Expand Down
35 changes: 28 additions & 7 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ public class Decoder extends Activity
private final String[] mHosts = new String[CAM_COUNT];
/** RTP transport per slot (#67): TCP, UDP or Auto (UDP with fallback to TCP). */
private final Transport.Mode[] mTransports = new Transport.Mode[CAM_COUNT];
/** Per slot (#201): degrees counter-clockwise, -1 for Auto (from the URL). */
private final int[] mRotates = new int[CAM_COUNT];
private int mActive;
private volatile String mHost;
private String mVersion;
Expand Down Expand Up @@ -209,7 +211,13 @@ protected void onCreate(Bundle savedInstanceState) {

// --- Create managers ---
videoDecoder = new MediaCodecManager(new MediaCodecManager.Listener() {
@Override public void onResolutionChanged(int w, int h) { updateResolution(w, h); }
@Override public void onResolutionChanged(int w, int h) {
// The decoder turns the picture (#201): a quarter turn swaps its sides
RtspClient c = rtspClient;
int r = c != null ? c.rotate() : 0;
if (r == 90 || r == 270) updateResolution(h, w);
else updateResolution(w, h);
Comment on lines +218 to +219

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. Older android streams get the wrong fit 🐞 Bug ≡ Correctness

The single-view resolution callback swaps width and height for quarter turns even when
MediaCodecManager cannot apply decoder rotation. On supported Android 5 devices, a 90° or 270°
setting therefore fits unturned H.264/H.265 video using the opposite aspect ratio.
Agent Prompt
## Issue description
On Android API 21–22, H.264/H.265 decoder rotation is unavailable, but the single view still swaps its dimensions for quarter turns and fits the unturned picture to the wrong aspect ratio.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[214-220]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[385-389]

## Recommended Fix
Use the same Android version condition for the single-view dimension swap as for applying decoder rotation. Keep the MJPEG sizing path unchanged because its software renderer can turn frames on older devices.

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

}
@Override public void onDecoderStarted() { removeClearOverlay(); }
@Override public void onDecoderFailed() {}
});
Expand Down Expand Up @@ -396,7 +404,7 @@ public void onToggleQuad() {
if (configured) {
// A probe of this camera ends first: some take one client only (#100)
streamProbes.willShow(mHost);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
configureClient();
startListener();
rtspClient.start();
}
Expand Down Expand Up @@ -580,7 +588,9 @@ private void restartClean() {
}

@Override
public void onShowSettings() { menuManager.showUrlEditor(mActive, mHosts, mTransports); }
public void onShowSettings() {
menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates);
}

@Override
public void onWebUI() { menuManager.startBrowser(mHost); }
Expand All @@ -590,8 +600,16 @@ private void restartClean() {

// ====================== Video lifecycle ======================

/** The single view's client for the active slot, with the slot's turn (#201). */
private void configureClient() {
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
rtspClient.setSlotTurn(mRotates[mActive]);
}

private void createDecoder() {
videoDecoder.setSurface(mVideoSurface);
RtspClient c = rtspClient;
videoDecoder.setRotation(c != null ? c.rotate() : 0);
videoDecoder.createDecoder(codecH265);
Comment on lines +611 to 613

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

2. Reconnects can leave video unturned 🐞 Bug ≡ Correctness

createDecoder() and the grid decode path capture rotate() when creating a codec, but
RtspSession.connect() temporarily resets URL-derived rotation to zero before the new stream
description arrives. If a queued frame creates a decoder during that interval, the later description
restores the rotation value without recreating the H.264/H.265 decoder, so that decoder keeps the
wrong turn.
Agent Prompt
## Issue description
Queued frames can create a codec while reconnect has reset URL-derived rotation but has not yet parsed the new stream description.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[330-334]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[609-614]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[694-701]
## Recommended Fix
Prevent decoder creation from old queued frames during negotiation, or recreate an existing codec when the effective rotation established by the new stream description differs from its configured rotation.

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

}

Expand Down Expand Up @@ -631,6 +649,7 @@ private void loadSettings() {
mUnreadable[i] = url == null && stored != null ? stored : null;
// Auto for a slot that has never stored one (#112)
mTransports[i] = Transport.Mode.stored(pref.getString("transport_" + i, null));
mRotates[i] = pref.getInt("rotate_" + i, -1);
// Plain URLs from older versions are re-saved encrypted right away
if (credentials.needsMigration(stored)) {
if (migrate == null) migrate = pref.edit();
Expand Down Expand Up @@ -693,6 +712,7 @@ private void saveSettings() {
edit.putString("host_" + i,
credentials.sealForSlot(mHosts[i], mUnreadable[i], DEFAULT_URL));
edit.putString("transport_" + i, mTransports[i].name());
edit.putInt("rotate_" + i, mRotates[i]);
}
edit.apply();

Expand All @@ -715,7 +735,7 @@ private void saveSettings() {

// A probe of this camera ends first: some take one client only (#100)
streamProbes.willShow(mHost);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
configureClient();
startListener();
rtspClient.start();
}
Expand Down Expand Up @@ -1002,6 +1022,7 @@ private void createQuadCells() {
Transport.Mode mode = i == U_CELL ? Transport.Mode.UDP : mTransports[i];
quadCells[i] = new QuadCell(i, url, mode, quadViews[i], framePool, mUserAgent,
index -> runOnUiThread(() -> removeQuadOverlay(index)));
quadCells[i].setSlotTurn(i < CAM_COUNT ? mRotates[i] : -1);
quadCells[i].start();
}
quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1);
Expand Down Expand Up @@ -1603,8 +1624,8 @@ private Map<String, Object> debugState() {
if (jl != null) single.put("jieli", jl);
Map<String, Object> hb = rtspClient != null ? rtspClient.heartbeatStats() : null;
if (hb != null) single.put("heartbeat", hb);
// MJPEG turned counter-clockwise, "size" is the turned one (#200)
int rotate = rtspClient != null && rtspClient.isJpeg() ? rtspClient.rotate() : 0;
// Turned counter-clockwise, "size" is the turned one (#200, #201)
int rotate = rtspClient != null ? rtspClient.rotate() : 0;
if (rotate != 0) single.put("rotate", rotate);
m.put("single", single);
}
Expand Down Expand Up @@ -2600,7 +2621,7 @@ private void startStreams() {
if (configured) {
// A probe of this camera ends first: some take one client only (#100)
streamProbes.willShow(mHost);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
configureClient();
startListener();
rtspClient.start();
}
Expand Down
15 changes: 12 additions & 3 deletions android/app/src/main/java/com/openipc/decoder/JpegCodec.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.media.MediaFormat;
import android.os.Build;
import android.util.Log;
import android.view.Surface;

Expand Down Expand Up @@ -42,6 +43,8 @@ final class JpegCodec {
private Surface surface;
private int width;
private int height;
/** Degrees counter-clockwise the decoder turns its output (#201). */
private int rotation;
private final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

/** {name, mime} of a hardware-or-vendor JPEG decoder, or null; looked up once. */
Expand Down Expand Up @@ -123,10 +126,11 @@ interface Current {
boolean holds();
}

synchronized boolean decode(Frame frame, Surface target, int w, int h, Current current)
throws Exception {
synchronized boolean decode(Frame frame, Surface target, int w, int h, int rotate,
Current current) throws Exception {
if (!current.holds()) return false;
if (codec == null || target != surface || w != width || h != height) {
if (codec == null || target != surface || w != width || h != height
|| rotate != rotation) {
releaseLocked();
MediaCodec c;
String[] dec;
Expand All @@ -149,6 +153,10 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h, Current c
MediaFormat format = MediaFormat.createVideoFormat(dec[1], w, h);
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE,
Math.max(frame.length() * 2, 512 * 1024));
// Turned by the decoder on the surface, clockwise degrees here (#201)
if (rotate != 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.setInteger(MediaFormat.KEY_ROTATION, (360 - rotate) % 360);
}
c.configure(format, target, null, 0);
c.start();
} catch (Exception e) {
Expand All @@ -164,6 +172,7 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h, Current c
surface = target;
width = w;
height = h;
rotation = rotate;
Log.i(TAG, "Start MJPEG decoder " + dec[0] + " " + w + "x" + h);
}
int in = codec.dequeueInputBuffer(40_000);
Expand Down
11 changes: 7 additions & 4 deletions android/app/src/main/java/com/openipc/decoder/JpegRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.util.Log;
import android.view.Surface;

Expand Down Expand Up @@ -49,14 +50,16 @@ final class JpegRenderer {
*/
int[] render(Frame frame, Surface s, int rotate, JpegCodec.Current current) {
if (s == null || !s.isValid() || !current.holds()) return null;
// The hardware JPEG decoder first, where there is one (#132); a turned frame is drawn
// by the app, which turns it while drawing (#200)
if (rotate == 0 && !hwFailed && !drawn && JpegCodec.candidate() != null) {
// The hardware JPEG decoder first, where there is one (#132); it turns the frame itself
// from Android 6 on (#201), before that a turned frame is drawn by the app (#200)
boolean hwTurns = rotate == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.M;
if (hwTurns && !hwFailed && !drawn && JpegCodec.candidate() != null) {
int[] size = JpegAssembler.size(frame.data(), frame.length());
if (size != null) {
try {
software = false;
return codec.decode(frame, s, size[0], size[1], current) ? size : null;
return codec.decode(frame, s, size[0], size[1], rotate, current)
? shownSize(size[0], size[1], rotate) : null;
} catch (Exception e) {
codec.release();
// The surface was replaced meanwhile: not the decoder's fault, try again
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class MediaCodecManager {

// Surface set by the owning activity
private volatile Surface surface;
/** Degrees counter-clockwise the decoder turns the picture (#201); 0 for none. */
private volatile int rotation;

/** Frames rendered so far (#82): with the frames received this gives the real decode rate. */
private volatile int framesOut;
Expand Down Expand Up @@ -255,6 +257,11 @@ void setSurface(Surface s) {
this.surface = s;
}

/** See {@link #rotation}; takes effect with the next decoder. */
void setRotation(int degreesCcw) {
rotation = ((degreesCcw % 360) + 360) % 360;
}

/** Enable or disable decoding; disabling also releases the decoder. */
/**
* Enable or disable decoding. Never blocks the caller (the UI thread): disabling only
Expand Down Expand Up @@ -375,6 +382,11 @@ void createDecoder(boolean codecH265) {
local = MediaCodec.createDecoderByType(type);
learnLimits(local, type);
}
int turn = rotation;
if (turn != 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// The decoder turns its output on the surface: clockwise degrees here (#201)
format.setInteger(MediaFormat.KEY_ROTATION, (360 - turn) % 360);
}
try {
local.configure(format, s, null, 0);
local.start();
Expand Down
24 changes: 23 additions & 1 deletion android/app/src/main/java/com/openipc/decoder/MenuManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.EditText;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import android.widget.RadioButton;
Expand Down Expand Up @@ -540,7 +541,8 @@ private EditText createEdit(String title) {
return text;
}

void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) {
void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports,
int[] mRotates) {
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle("Camera " + (mActive + 1) + " URL");

Expand All @@ -564,11 +566,29 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) {
if (modes[i] == mTransports[mActive]) b.setChecked(true);
}

// Turn of the picture (#201): Auto leaves it to the URL's ?rotate= or a toy's default;
// the degrees are counter-clockwise, as in ?rotate=
RadioGroup turn = new RadioGroup(activity);
turn.setOrientation(RadioGroup.HORIZONTAL);
int[] turns = {-1, 0, 90, 180, 270};
String[] turnLabels = {"Auto", "0°", "90° ↺", "180°", "270° ↺"};
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
for (int i = 0; i < turns.length; i++) {
RadioButton b = new RadioButton(activity);
b.setId(View.generateViewId());
b.setText(turnLabels[i]);
b.setTag(turns[i]);
turn.addView(b);
if (turns[i] == mRotates[mActive]) b.setChecked(true);
}
LinearLayout content = new LinearLayout(activity);
content.setOrientation(LinearLayout.VERTICAL);
content.setPadding(dp(16), dp(8), dp(16), 0);
content.addView(input);
content.addView(transport);
// Five choices do not fit a phone's width: the row scrolls (Qodo on #225)
HorizontalScrollView turnRow = new HorizontalScrollView(activity);
turnRow.addView(turn);
content.addView(turnRow);
builder.setView(content);

final int slot = mActive;
Expand All @@ -577,6 +597,8 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) {
mHosts[slot] = Decoder.sanitizeUrl(url);
View checked = transport.findViewById(transport.getCheckedRadioButtonId());
if (checked != null) mTransports[slot] = (Transport.Mode) checked.getTag();
View turned = turn.findViewById(turn.getCheckedRadioButtonId());
if (turned != null) mRotates[slot] = (Integer) turned.getTag();
if (listener != null) listener.onSaveSettings(); // triggers save + reconnect
});
builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel());
Expand Down
6 changes: 4 additions & 2 deletions android/app/src/main/java/com/openipc/decoder/QuadCell.java
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,9 @@ static boolean isKeyFrameNal(int type, boolean h265) {
/** The cooingdv heartbeat of an RTSP cell once it used one (#187), else null. */
java.util.Map<String, Object> heartbeatStats() { return session.heartbeatStats(); }

/** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */
int rotate() { return jpeg ? session.rotate() : 0; }
/** Degrees counter-clockwise the frames are turned (#200, #201), 0 for none. */
int rotate() { return session.rotate(); }
void setSlotTurn(int rotate) { session.setSlotTurn(rotate); }

/** Transport in use, with the mode after a fallback (#112). */
String transportLabel() { return Transport.label(session.transportInUse(), transport); }
Expand Down Expand Up @@ -694,6 +695,7 @@ private void decode(Frame buffer) {
Surface s = surface;
if (s == null || !s.isValid()) return;
decoder.setSurface(s);
decoder.setRotation(session.rotate());
decoder.createDecoder(codecH265);
// Feed the frame that triggered creation (often the SPS) to the new decoder
decoder.decodeFrame(buffer, codecH265, 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ void configure(String host, String userAgent, Transport.Mode transport) {

/** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */
int rotate() { return session.rotate(); }
void setSlotTurn(int rotate) { session.setSlotTurn(rotate); }

void start() {
nalAssembler.reset();
Expand Down
Loading
Loading