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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18.
`docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they
were released.

## 2.18.0 — 2026-10-19

- Mirror in the camera settings, next to Turn: flips the picture left to right in the single
view and the grid, for H.264, H.265 and MJPEG; a mirrored H.264/H.265 slot is drawn with
OpenGL ES (PR #246, closes #226).

## 2.17 — 2026-10-17

- Turn in the camera settings (Auto, 0°, 90°, 180°, 270°), stored per slot; H.264/H.265 are
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,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. Below it, **Turn** turns the picture of a camera mounted sideways or upside down. 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, and **Mirror** flips it left to right. 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 @@ -65,7 +65,7 @@
```
rtsp://username:password@ip_address:554/stream=0
```
Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами. Другие виды адресов — в [Transports](docs/transports.md) (на английском).
Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами, а **Mirror** отражает её слева направо. Другие виды адресов — в [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 @@ -65,7 +65,7 @@
```
rtsp://username:password@ip_address:554/stream=0
```
地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。
地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机;**Mirror** 可将画面左右镜像。其他地址格式见 [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 42
versionName "2.17"
versionCode 43
versionName "2.18.0"

buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
Expand Down
42 changes: 31 additions & 11 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public class Decoder extends Activity
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];
/** Per slot (#226): the picture flipped left↔right. */
private final boolean[] mMirrors = new boolean[CAM_COUNT];
private int mActive;
private volatile String mHost;
private String mVersion;
Expand Down Expand Up @@ -212,12 +214,9 @@ protected void onCreate(Bundle savedInstanceState) {
// --- Create managers ---
videoDecoder = new MediaCodecManager(new MediaCodecManager.Listener() {
@Override public void onResolutionChanged(int w, int h) {
// The decoder turns the picture (#201, Android 6+ as in MediaCodecManager): a
// quarter turn swaps its sides
RtspClient c = rtspClient;
int r = c != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? c.rotate() : 0;
if (r == 90 || r == 270) updateResolution(h, w);
// A quarter turn by the decoder (#201, Android 6+) or by the mirror's GL path
// (#226, any version) swaps the picture's sides
if (videoDecoder.swapsSides()) updateResolution(h, w);
else updateResolution(w, h);
}
@Override public void onDecoderStarted() { removeClearOverlay(); }
Expand Down Expand Up @@ -591,7 +590,7 @@ private void restartClean() {

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

@Override
Expand All @@ -606,13 +605,17 @@ public void onShowSettings() {
private void configureClient() {
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
rtspClient.setSlotTurn(mRotates[mActive]);
rtspClient.setSlotMirror(mMirrors[mActive]);
}

private void createDecoder() {
/** A decoder for the stream of video thread {@code gen}, with that stream's turn and mirror. */
private void createDecoder(int gen) {
videoDecoder.setSurface(mVideoSurface);
RtspClient c = rtspClient;
videoDecoder.setRotation(c != null ? c.rotate() : 0);
videoDecoder.createDecoder(codecH265);
videoDecoder.setMirror(c != null && c.mirror());
// Restarted with new settings meanwhile: this decoder is dropped, the new thread makes one
videoDecoder.createDecoder(codecH265, () -> gen == listenerGen);
}

private void closeDecoder() { videoDecoder.closeDecoder(); }
Expand Down Expand Up @@ -652,6 +655,7 @@ private void loadSettings() {
// 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);
mMirrors[i] = pref.getBoolean("mirror_" + i, false);
// 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 @@ -715,6 +719,7 @@ private void saveSettings() {
credentials.sealForSlot(mHosts[i], mUnreadable[i], DEFAULT_URL));
edit.putString("transport_" + i, mTransports[i].name());
edit.putInt("rotate_" + i, mRotates[i]);
edit.putBoolean("mirror_" + i, mMirrors[i]);
}
edit.apply();

Expand Down Expand Up @@ -1025,6 +1030,7 @@ private void createQuadCells() {
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].setSlotMirror(i < CAM_COUNT && mMirrors[i]);
quadCells[i].start();
}
quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1);
Expand Down Expand Up @@ -1629,6 +1635,10 @@ private Map<String, Object> debugState() {
// Turned counter-clockwise, "size" is the turned one (#200, #201)
int rotate = rtspClient != null ? rtspClient.rotate() : 0;
if (rotate != 0) single.put("rotate", rotate);
if (rtspClient != null && rtspClient.mirror()) {
single.put("mirror", true);
if (videoDecoder.mirrorFailed()) single.put("mirror_failed", true);
}
m.put("single", single);
}
QuadCell[] cells = quadCells;
Expand Down Expand Up @@ -1674,6 +1684,10 @@ private Map<String, Object> debugState() {
Map<String, Object> hb = cell.heartbeatStats();
if (hb != null) c.put("heartbeat", hb);
if (cell.rotate() != 0) c.put("rotate", cell.rotate());
if (cell.mirror()) {
c.put("mirror", true);
if (cell.mirrorFailed()) c.put("mirror_failed", true);
}
}
list.add(c);
}
Expand Down Expand Up @@ -2291,7 +2305,7 @@ private boolean drawJpeg(Frame frame, int gen) {
Surface s = mVideoSurface;
RtspClient client = rtspClient;
int rotate = client != null ? client.rotate() : 0;
int[] size = jpegRenderer.render(frame, s, rotate,
int[] size = jpegRenderer.render(frame, s, rotate, client != null && client.mirror(),
() -> gen == listenerGen && s == mVideoSurface);
if (size != null) jpegShown(size[0], size[1]);
return size != null;
Expand Down Expand Up @@ -2380,7 +2394,13 @@ && drawJpeg(buffer, gen)) {
framePool.recycle(buffer);
} else if (buffer != null) {
if (videoDecoder.decodeFrame(buffer, codecH265, 0)) {
createDecoder();
// Restarted meanwhile (new settings): the new thread makes the
// decoder, with the new turn and mirror (#226)
if (gen != listenerGen) {
framePool.recycle(buffer);
break;
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
createDecoder(gen);
// The frame that triggered creation (often the SPS) goes in too
videoDecoder.decodeFrame(buffer, codecH265, 0);
}
Expand Down
Loading
Loading