From e94ac24b8e0d72aed58b92fe12acf956858f72ce Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Fri, 16 Oct 2026 20:10:16 +0000 Subject: [PATCH 1/9] feat: ?rotate= turns H.264/H.265 too, by the decoder (KEY_ROTATION, #201) --- .../src/main/java/com/openipc/decoder/Decoder.java | 14 +++++++++++--- .../com/openipc/decoder/MediaCodecManager.java | 12 ++++++++++++ .../main/java/com/openipc/decoder/QuadCell.java | 3 ++- .../main/java/com/openipc/decoder/RtspSession.java | 14 +++----------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index de6097e4..6ec2c035 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -209,7 +209,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); + } @Override public void onDecoderStarted() { removeClearOverlay(); } @Override public void onDecoderFailed() {} }); @@ -592,6 +598,8 @@ private void restartClean() { private void createDecoder() { videoDecoder.setSurface(mVideoSurface); + RtspClient c = rtspClient; + videoDecoder.setRotation(c != null ? c.rotate() : 0); videoDecoder.createDecoder(codecH265); } @@ -1603,8 +1611,8 @@ private Map debugState() { if (jl != null) single.put("jieli", jl); Map 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); } diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index f75d6ca0..f02f0991 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -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; @@ -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 @@ -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(); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index aa8b4ef7..5c18d2d0 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -438,7 +438,7 @@ static boolean isKeyFrameNal(int type, boolean h265) { java.util.Map heartbeatStats() { return session.heartbeatStats(); } /** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */ - int rotate() { return jpeg ? session.rotate() : 0; } + int rotate() { return session.rotate(); } /** Transport in use, with the mode after a fallback (#112). */ String transportLabel() { return Transport.label(session.transportInUse(), transport); } @@ -694,6 +694,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); diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index dd496e98..784e7bc0 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -119,7 +119,6 @@ default void onDetail(String text) {} /** Degrees counter-clockwise the MJPEG frames of this connection are turned (#200). */ private volatile int rotate; private volatile boolean rotateLogged; - private volatile boolean rotateIgnoredLogged; private Thread networkThread; private Thread watchdogThread; @@ -146,7 +145,6 @@ void configure(String url, String userAgent, boolean withAudio, Transport.Mode m // of the previous one leave /stats.json (Qodo on #188). A heartbeat still running // is closed by its connection, without adding to the new totals. rotateLogged = false; - rotateIgnoredLogged = false; synchronized (lock) { heartbeat = null; heartbeatUsed = false; @@ -678,17 +676,11 @@ private void stopHeartbeat(UdpHeartbeat h) { */ private int rotation(int wanted, boolean jpeg) { if (wanted == 0) return 0; - if (!jpeg) { - if (!rotateIgnoredLogged) { - rotateIgnoredLogged = true; - Log.i(TAG, tag + ": rotate= only applies to MJPEG streams for now (#201)"); - } - return 0; - } if (!rotateLogged) { rotateLogged = true; - Log.i(TAG, tag + ": MJPEG frames turned " + wanted + "\u00b0 counter-clockwise" - + " (#200; ?rotate=0 turns it off)"); + // MJPEG is turned while drawing (#200), H.264/H.265 by the decoder (#201) + Log.i(TAG, tag + ": " + (jpeg ? "MJPEG frames" : "video") + " turned " + wanted + + "\u00b0 counter-clockwise (?rotate=0 turns it off)"); } return wanted; } From c2dab1b0082e33198367d4ad34aab27ee22ce48b Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Fri, 16 Oct 2026 20:21:40 +0000 Subject: [PATCH 2/9] feat: per-slot Turn and Mirror in the camera settings, for H.264/H.265 and MJPEG, single view and grid (#201) --- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- .../java/com/openipc/decoder/Decoder.java | 38 +++++++++++++++++-- .../java/com/openipc/decoder/JpegCodec.java | 15 ++++++-- .../com/openipc/decoder/JpegRenderer.java | 11 ++++-- .../java/com/openipc/decoder/MenuManager.java | 27 ++++++++++++- .../java/com/openipc/decoder/QuadCell.java | 2 + .../java/com/openipc/decoder/RtspClient.java | 2 + .../java/com/openipc/decoder/RtspSession.java | 17 ++++++++- docs/debug.md | 2 +- docs/toys.md | 2 +- docs/transports.md | 2 +- docs/usage.md | 3 +- 14 files changed, 107 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0ec963e2..b0930c97 100644 --- a/README.md +++ b/README.md @@ -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** and **Mirror** set the picture for 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 diff --git a/README.ru.md b/README.ru.md index 4040d840..9044a19b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -63,7 +63,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Другие виды адресов — в [Transports](docs/transports.md) (на английском). + Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройки **Turn** и **Mirror** поворачивают и отражают картинку камеры, установленной боком или вверх ногами. Другие виды адресов — в [Transports](docs/transports.md) (на английском). 4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. ## Управление diff --git a/README.zh-CN.md b/README.zh-CN.md index c3f08dec..b2e7eb74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,7 +63,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其他地址格式见 [Transports](docs/transports.md)(英文)。 + 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 和 **Mirror** 可旋转和镜像画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 6ec2c035..9961cceb 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -138,6 +138,9 @@ 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), and mirror. */ + private final int[] mRotates = new int[CAM_COUNT]; + private final boolean[] mMirrors = new boolean[CAM_COUNT]; private int mActive; private volatile String mHost; private String mVersion; @@ -402,7 +405,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(); } @@ -586,7 +589,9 @@ private void restartClean() { } @Override - public void onShowSettings() { menuManager.showUrlEditor(mActive, mHosts, mTransports); } + public void onShowSettings() { + menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates, mMirrors); + } @Override public void onWebUI() { menuManager.startBrowser(mHost); } @@ -596,6 +601,22 @@ private void restartClean() { // ====================== Video lifecycle ====================== + /** The single view's client for the active slot, with the slot's turn and mirror (#201). */ + private void configureClient() { + rtspClient.configure(mHost, mUserAgent, mTransports[mActive]); + rtspClient.setSlotTurn(mRotates[mActive], mMirrors[mActive]); + runOnUiThread(() -> mirrorView(mSurface, mMirrors[mActive])); + } + + /** + * Mirrors a video view left to right (#201). The surface of a SurfaceView follows the + * view's scale from Android 7 on; before that there is no mirror. + */ + static void mirrorView(View v, boolean mirror) { + if (v == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return; + v.setScaleX(mirror ? -1f : 1f); + } + private void createDecoder() { videoDecoder.setSurface(mVideoSurface); RtspClient c = rtspClient; @@ -639,6 +660,8 @@ 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); + 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(); @@ -701,6 +724,8 @@ 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.putBoolean("mirror_" + i, mMirrors[i]); } edit.apply(); @@ -723,7 +748,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(); } @@ -1010,6 +1035,9 @@ 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))); + boolean slot = i < CAM_COUNT; + quadCells[i].setSlotTurn(slot ? mRotates[i] : -1, slot && mMirrors[i]); + mirrorView(quadViews[i], slot && mMirrors[i]); quadCells[i].start(); } quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1); @@ -1614,6 +1642,7 @@ private Map 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); m.put("single", single); } QuadCell[] cells = quadCells; @@ -1659,6 +1688,7 @@ private Map debugState() { Map 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); } list.add(c); } @@ -2608,7 +2638,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(); } diff --git a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java index 40acf0c7..ca738108 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java @@ -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; @@ -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. */ @@ -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; @@ -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) { @@ -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); diff --git a/android/app/src/main/java/com/openipc/decoder/JpegRenderer.java b/android/app/src/main/java/com/openipc/decoder/JpegRenderer.java index 0149da83..deb38f86 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegRenderer.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegRenderer.java @@ -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; @@ -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 diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 8cfafd4b..dc26854e 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -27,6 +27,7 @@ import android.webkit.HttpAuthHandler; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.widget.CheckBox; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.PopupWindow; @@ -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, boolean[] mMirrors) { AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle("Camera " + (mActive + 1) + " URL"); @@ -564,11 +566,31 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) { if (modes[i] == mTransports[mActive]) b.setChecked(true); } + // Turn and mirror 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° ↺"}; + 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); + } + CheckBox mirror = new CheckBox(activity); + mirror.setText("Mirror (left ↔ right)"); + mirror.setChecked(mMirrors[mActive]); + LinearLayout content = new LinearLayout(activity); content.setOrientation(LinearLayout.VERTICAL); content.setPadding(dp(16), dp(8), dp(16), 0); content.addView(input); content.addView(transport); + content.addView(turn); + content.addView(mirror); builder.setView(content); final int slot = mActive; @@ -577,6 +599,9 @@ 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(); + mMirrors[slot] = mirror.isChecked(); if (listener != null) listener.onSaveSettings(); // triggers save + reconnect }); builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 5c18d2d0..61cbca84 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -439,6 +439,8 @@ static boolean isKeyFrameNal(int type, boolean h265) { /** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */ int rotate() { return session.rotate(); } + void setSlotTurn(int rotate, boolean mirror) { session.setSlotTurn(rotate, mirror); } + boolean mirror() { return session.mirror(); } /** Transport in use, with the mode after a fallback (#112). */ String transportLabel() { return Transport.label(session.transportInUse(), transport); } diff --git a/android/app/src/main/java/com/openipc/decoder/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java index d0f550cb..24f39a1f 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -97,6 +97,8 @@ 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, boolean mirror) { session.setSlotTurn(rotate, mirror); } + boolean mirror() { return session.mirror(); } void start() { nalAssembler.reset(); diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index 784e7bc0..518aeda7 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -118,6 +118,9 @@ default void onDetail(String text) {} private volatile boolean heartbeatLogged; /** Degrees counter-clockwise the MJPEG frames of this connection are turned (#200). */ private volatile int rotate; + /** The slot's setting (#201), over the URL's ?rotate= and the toys' defaults; -1: none. */ + private volatile int slotRotate = -1; + private volatile boolean slotMirror; private volatile boolean rotateLogged; private Thread networkThread; private Thread watchdogThread; @@ -686,7 +689,19 @@ private int rotation(int wanted, boolean jpeg) { } /** Degrees counter-clockwise the MJPEG frames are turned now (#200), 0 for none. */ - int rotate() { return rotate; } + int rotate() { + int t = slotRotate; + return t >= 0 ? t : rotate; + } + + /** The slot's own turn (#201): -1 leaves it to the URL or the toy's default. */ + void setSlotTurn(int rotate, boolean mirror) { + slotRotate = rotate == 0 || rotate == 90 || rotate == 180 || rotate == 270 ? rotate : -1; + slotMirror = mirror; + } + + /** Whether the slot's picture is mirrored left to right (#201). */ + boolean mirror() { return slotMirror; } /** Heartbeat statistics for /stats.json once an RTSP slot used one (#187), else null. */ Map heartbeatStats() { diff --git a/docs/debug.md b/docs/debug.md index ead0156c..61967f7a 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -171,7 +171,7 @@ Single view and U view: every cell of the grid as the debug page shows it (3×3 | `codec`, `size` | when found | | `fps`, `kbps`, `key_frames` | measured during the check | -Toy drone slots ([toys.md](toys.md#flow-ufo)) add a `flowufo` object per cell and in `single` (`jieli` for `jieli://` slots, [toys.md](toys.md#hf--jieli-toys)), and a `heartbeat` object for RTSP slots that send the KY UFO heartbeat, and `rotate` (degrees counter-clockwise) where MJPEG frames are turned ([toys.md](toys.md#ky-ufo-rtsp-toys)). +Toy drone slots ([toys.md](toys.md#flow-ufo)) add a `flowufo` object per cell and in `single` (`jieli` for `jieli://` slots, [toys.md](toys.md#hf--jieli-toys)), and a `heartbeat` object for RTSP slots that send the KY UFO heartbeat, and `rotate` (degrees counter-clockwise) and `mirror` where the picture is turned or mirrored ([toys.md](toys.md#ky-ufo-rtsp-toys)). ## Log diff --git a/docs/toys.md b/docs/toys.md index 243ce2b8..2c3886dd 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -89,7 +89,7 @@ rtsp://192.168.1.1:7070/webcam Their apps send a heartbeat, the two bytes `01 01`, to UDP port 7099 of the toy every second while the video plays. For an RTSP slot with port 7070 and path `/webcam` (any host) the app does the same: from the address of the RTSP connection, once a second, until the slot stops. Nothing else is sent to port 7099 (no flight command, no "stop" command). Other RTSP slots send no heartbeat unless asked with `?heartbeat=`, e.g. `rtsp://192.168.1.1:554/live?heartbeat=7099`; `?heartbeat=off` turns it off for a `:7070/webcam` slot. The option is the app's own and is not sent to the camera. The log has one line when it starts (*heartbeat 01 01 every 1000 ms to …*), and `/stats.json` a `heartbeat` object (`target`, `running`, `sent`) for such a slot. -The camera of some of these toys (the K200) is mounted sideways: its frames come turned, a mug lies on its side as a portrait strip in a landscape screen. For a `:7070/webcam` slot the app therefore turns the MJPEG frames 270° counter-clockwise (90° clockwise) when drawing them ([#200](https://github.com/OpenIPC/decoder/issues/200)), and fits the turned frame (a 480×640 frame shows as 640×480 and fills a landscape screen). `?rotate=0|90|180|270` (degrees counter-clockwise) overrides it, e.g. `rtsp://192.168.1.1:7070/webcam?rotate=0` for a unit mounted upright, and also turns other RTSP MJPEG slots; any other value is an error. The option is the app's own and is not sent to the camera. It turns MJPEG only: an H.264/H.265 stream is shown as it comes and the log says *rotate= only applies to MJPEG streams for now (#201)*. The turn is in the picture itself, so zoom, the long-press screenshot and the debug snapshot show it turned too. The log has one line when it is active (*MJPEG frames turned 270° counter-clockwise*), and `/stats.json` a `rotate` (degrees) for such a slot; its `size` is the turned one. +The camera of some of these toys (the K200) is mounted sideways: its frames come turned, a mug lies on its side as a portrait strip in a landscape screen. For a `:7070/webcam` slot the app therefore turns the MJPEG frames 270° counter-clockwise (90° clockwise) when drawing them ([#200](https://github.com/OpenIPC/decoder/issues/200)), and fits the turned frame (a 480×640 frame shows as 640×480 and fills a landscape screen). `?rotate=0|90|180|270` (degrees counter-clockwise) overrides it, e.g. `rtsp://192.168.1.1:7070/webcam?rotate=0` for a unit mounted upright, and also turns other RTSP MJPEG slots; any other value is an error. The option is the app's own and is not sent to the camera. Since 2.18 it turns H.264/H.265 too, and the slot's **Turn** setting overrides it ([usage.md](usage.md), [#201](https://github.com/OpenIPC/decoder/issues/201)). The turn is in the picture itself, so zoom, the long-press screenshot and the debug snapshot show it turned too. The log has one line when it is active (*MJPEG frames turned 270° counter-clockwise*), and `/stats.json` a `rotate` (degrees) for such a slot; its `size` is the turned one. Some of these toys use the FLOW-UFO protocol instead (the KY UFO app has both); if RTSP shows nothing, try `flowufo://192.168.1.1`. diff --git a/docs/transports.md b/docs/transports.md index 768c7516..48afc7ac 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -104,7 +104,7 @@ For example Majestic's `http://user:password@/mjpeg`: a `multipart/x-mix `jieli://192.168.80.1` (or `jieli://` alone) plays toy drones with an `HF_xxxxxxxx` Wi‑Fi (Jieli AC79 camera): CTP commands on TCP 3333 ask for the stream, which arrives as JPEG frames behind a 20-byte header on the phone's UDP port 2224 (seen from a YL3) or as RTP/JPEG on UDP 6666; works with a YL3 since 2.16. Options: `?w=`, `?h=`, `?fps=`, `?rtp=`, `?format=0|1`, `?rotate=`. See [toys.md](toys.md#hf--jieli-toys). -RTSP slots on `rtsp://:7070/webcam` (KY UFO and other cooingdv toys) also send the toy's UDP heartbeat `01 01` to port 7099 once a second; `?heartbeat=` turns it on for other RTSP URLs, `?heartbeat=off` off. Their MJPEG frames are drawn turned 270° counter-clockwise (90° clockwise) (the K200's camera is mounted sideways, [#200](https://github.com/OpenIPC/decoder/issues/200)); `?rotate=0|90|180|270` (degrees counter-clockwise, any RTSP MJPEG slot) overrides it and is not sent to the camera either. See [toys.md](toys.md#ky-ufo-rtsp-toys). +RTSP slots on `rtsp://:7070/webcam` (KY UFO and other cooingdv toys) also send the toy's UDP heartbeat `01 01` to port 7099 once a second; `?heartbeat=` turns it on for other RTSP URLs, `?heartbeat=off` off. Their MJPEG frames are drawn turned 270° counter-clockwise (90° clockwise) (the K200's camera is mounted sideways, [#200](https://github.com/OpenIPC/decoder/issues/200)); `?rotate=0|90|180|270` (degrees counter-clockwise, any RTSP slot) overrides it, and the slot's **Turn** setting overrides both ([#201](https://github.com/OpenIPC/decoder/issues/201)) and is not sent to the camera either. See [toys.md](toys.md#ky-ufo-rtsp-toys). ## Codecs diff --git a/docs/usage.md b/docs/usage.md index e1094a37..b60d4737 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,6 +17,7 @@ 1. Open the menu: tap the screen, or press **OK** on a remote. 2. Choose a slot (**1**–**8**), then **Settings**. 3. Enter the camera URL, for example `rtsp://user:password@192.168.1.10:554/stream=0`, and choose the transport under it (**TCP / UDP / Auto / Multicast**, a new slot starts on Auto). URL formats: [transports.md](transports.md). + Below them, **Turn** (**Auto**, **0°**, **90° ↺**, **180°**, **270° ↺**, counter-clockwise) and **Mirror** set the picture for a camera mounted sideways or upside down, or seen in a mirror ([#201](https://github.com/OpenIPC/decoder/issues/201)). **Auto** leaves it to the URL's `?rotate=` and the toys' defaults. They apply in the single view and in the grid, to H.264, H.265 and MJPEG; the decoder turns the picture (Android 6+), and the mirror needs Android 7+. 4. **Save**. The app connects at once and reconnects by itself when the stream drops (exponential back-off, 1 s up to 30 s). The URL dialog is titled `Camera N URL`. The picture is fitted to the screen whatever its shape, without cropping. URLs are stored encrypted, see [security.md](security.md#credential-store). @@ -38,7 +39,7 @@ The menu is one grid: the eight slots on top, two rows of four below. Every word | Item | Action | |--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **1**–**8** | Switch to a camera slot (single view). The number of the active slot shows the connection quality: green under 100 ms of jitter, yellow 100–300 ms, red above. A grey number is an empty slot | -| **Settings** | URL and transport of the active slot. Single view only, grey elsewhere | +| **Settings** | URL, transport, turn and mirror of the active slot. Single view only, grey elsewhere | | **WebUI** | The camera's web interface, see [WebUI](#webui). Single view only, grey elsewhere | | **Grid** | The view: [multi-view](#multi-view) (2×2 or 3×3) or the single camera. Does not change the playback mode | | **Slides** | The playback mode: every frame, or key frames only, see [Slides](#slides). Does not change the view | From 7fc37d6c0a57b465e3e2374da2ecaefbd3c738b2 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Fri, 16 Oct 2026 20:21:40 +0000 Subject: [PATCH 3/9] chore: version 2.18 (2.17 is #220) --- android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index a45a4439..d572c7df 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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()}\"" } From 7ba271d04adc0910ba42e3dcdf74727b9bd046fe Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Fri, 16 Oct 2026 20:29:25 +0000 Subject: [PATCH 4/9] fix: snapshots and screenshots follow the decoder's turn; no mirror for now, a SurfaceView cannot be mirrored (#201) --- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- .../java/com/openipc/decoder/Decoder.java | 48 +++++++++---------- .../java/com/openipc/decoder/MenuManager.java | 13 ++--- .../java/com/openipc/decoder/QuadCell.java | 14 +++++- .../java/com/openipc/decoder/RtspClient.java | 3 +- .../java/com/openipc/decoder/RtspSession.java | 7 +-- .../java/com/openipc/decoder/Snapshots.java | 38 ++++++++++++--- docs/debug.md | 2 +- docs/usage.md | 4 +- 11 files changed, 78 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index b0930c97..bc55fc3f 100644 --- a/README.md +++ b/README.md @@ -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. Below it, **Turn** and **Mirror** set the picture for 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. Other URL types: [Transports](docs/transports.md). 4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops. ## Controls diff --git a/README.ru.md b/README.ru.md index 9044a19b..6386edb1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -63,7 +63,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройки **Turn** и **Mirror** поворачивают и отражают картинку камеры, установленной боком или вверх ногами. Другие виды адресов — в [Transports](docs/transports.md) (на английском). + Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами. Другие виды адресов — в [Transports](docs/transports.md) (на английском). 4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. ## Управление diff --git a/README.zh-CN.md b/README.zh-CN.md index b2e7eb74..a9e6bc72 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,7 +63,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 和 **Mirror** 可旋转和镜像画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。 + 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 9961cceb..0ea2d36f 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -138,9 +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), and mirror. */ + /** Per slot (#201): degrees counter-clockwise, -1 for Auto (from the URL). */ private final int[] mRotates = new int[CAM_COUNT]; - private final boolean[] mMirrors = new boolean[CAM_COUNT]; private int mActive; private volatile String mHost; private String mVersion; @@ -590,7 +589,7 @@ private void restartClean() { @Override public void onShowSettings() { - menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates, mMirrors); + menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates); } @Override @@ -601,20 +600,17 @@ public void onShowSettings() { // ====================== Video lifecycle ====================== - /** The single view's client for the active slot, with the slot's turn and mirror (#201). */ - private void configureClient() { - rtspClient.configure(mHost, mUserAgent, mTransports[mActive]); - rtspClient.setSlotTurn(mRotates[mActive], mMirrors[mActive]); - runOnUiThread(() -> mirrorView(mSurface, mMirrors[mActive])); + /** As QuadCell.decoderTurn() for the single view (#201). */ + private int singleDecoderTurn() { + RtspClient c = rtspClient; + if (c == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return 0; + return c.isJpeg() && jpegRenderer.isSoftware() ? 0 : c.rotate(); } - /** - * Mirrors a video view left to right (#201). The surface of a SurfaceView follows the - * view's scale from Android 7 on; before that there is no mirror. - */ - static void mirrorView(View v, boolean mirror) { - if (v == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return; - v.setScaleX(mirror ? -1f : 1f); + /** 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() { @@ -661,7 +657,6 @@ 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(); @@ -725,7 +720,6 @@ 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(); @@ -1035,9 +1029,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))); - boolean slot = i < CAM_COUNT; - quadCells[i].setSlotTurn(slot ? mRotates[i] : -1, slot && mMirrors[i]); - mirrorView(quadViews[i], slot && mMirrors[i]); + quadCells[i].setSlotTurn(i < CAM_COUNT ? mRotates[i] : -1); quadCells[i].start(); } quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1); @@ -1505,6 +1497,7 @@ private List snapshotCells(int[] side) { ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; // Copied with the grid cells, off the UI thread (#76) if (c.live) c.view = mSurface; + c.turn = singleDecoderTurn(); } c.onScreen = onScreen(i); cells.add(c); @@ -1531,6 +1524,7 @@ private Snapshots.Cell snapshotCell(int i) { c.label = stats != null ? stats.getText().toString() : ""; c.live = cell.showsVideo(SNAPSHOT_LIVE_MS); c.view = quadViews[i]; + c.turn = cell.decoderTurn(); if (i == U_CELL && !c.live) { c.label = "U \u00b7 waiting for RTP on port " + UDP_RTP_PORT; } @@ -1642,7 +1636,6 @@ private Map 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); m.put("single", single); } QuadCell[] cells = quadCells; @@ -1688,7 +1681,6 @@ private Map debugState() { Map 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); } list.add(c); } @@ -1860,9 +1852,14 @@ && checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) double fit = Math.min(1, Math.sqrt((double) SCREENSHOT_MAX_PIXELS / ((double) mVideoWidth * mVideoHeight))); Bitmap bitmap; + // A surface the decoder turns holds the frame as decoded: copied unturned (#201) + final int turn = singleDecoderTurn(); + boolean quarter = turn == 90 || turn == 270; + int shotW = Math.max(1, (int) (mVideoWidth * fit)); + int shotH = Math.max(1, (int) (mVideoHeight * fit)); try { - bitmap = Bitmap.createBitmap(Math.max(1, (int) (mVideoWidth * fit)), - Math.max(1, (int) (mVideoHeight * fit)), Bitmap.Config.ARGB_8888); + bitmap = Bitmap.createBitmap(quarter ? shotH : shotW, quarter ? shotW : shotH, + Bitmap.Config.ARGB_8888); } catch (OutOfMemoryError e) { Log.w(TAG, "No memory for a screenshot of " + mVideoWidth + "x" + mVideoHeight); Toast.makeText(this, "Failed to save screenshot", Toast.LENGTH_SHORT).show(); @@ -1873,7 +1870,8 @@ && checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) boolean started = Snapshots.copy(mSurface, bitmap, result -> { if (result == PixelCopy.SUCCESS) { // Encoding a full-resolution PNG takes long: on a thread of its own (#35) - new Thread(() -> saveScreenshot(bitmap), "screenshot").start(); + new Thread(() -> saveScreenshot(Snapshots.turned(bitmap, turn)), "screenshot") + .start(); } else { Log.d(TAG, "Screenshot copy failed: " + result); bitmap.recycle(); diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index dc26854e..292db659 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -27,7 +27,6 @@ import android.webkit.HttpAuthHandler; import android.webkit.WebView; import android.webkit.WebViewClient; -import android.widget.CheckBox; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.PopupWindow; @@ -542,7 +541,7 @@ private EditText createEdit(String title) { } void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, - int[] mRotates, boolean[] mMirrors) { + int[] mRotates) { AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle("Camera " + (mActive + 1) + " URL"); @@ -566,8 +565,8 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, if (modes[i] == mTransports[mActive]) b.setChecked(true); } - // Turn and mirror 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= + // 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}; @@ -580,17 +579,12 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, turn.addView(b); if (turns[i] == mRotates[mActive]) b.setChecked(true); } - CheckBox mirror = new CheckBox(activity); - mirror.setText("Mirror (left ↔ right)"); - mirror.setChecked(mMirrors[mActive]); - LinearLayout content = new LinearLayout(activity); content.setOrientation(LinearLayout.VERTICAL); content.setPadding(dp(16), dp(8), dp(16), 0); content.addView(input); content.addView(transport); content.addView(turn); - content.addView(mirror); builder.setView(content); final int slot = mActive; @@ -601,7 +595,6 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, if (checked != null) mTransports[slot] = (Transport.Mode) checked.getTag(); View turned = turn.findViewById(turn.getCheckedRadioButtonId()); if (turned != null) mRotates[slot] = (Integer) turned.getTag(); - mMirrors[slot] = mirror.isChecked(); if (listener != null) listener.onSaveSettings(); // triggers save + reconnect }); builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 61cbca84..3295a309 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -7,6 +7,7 @@ package com.openipc.decoder; +import android.os.Build; import android.util.Log; import android.view.Surface; import android.view.SurfaceHolder; @@ -439,8 +440,17 @@ static boolean isKeyFrameNal(int type, boolean h265) { /** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */ int rotate() { return session.rotate(); } - void setSlotTurn(int rotate, boolean mirror) { session.setSlotTurn(rotate, mirror); } - boolean mirror() { return session.mirror(); } + void setSlotTurn(int rotate) { session.setSlotTurn(rotate); } + + /** + * Degrees counter-clockwise the decoder turns this cell's frames on the surface (#201), 0 + * when the frames are drawn already turned (MJPEG in software) or not turned at all. A copy + * of the surface gets the frame as decoded, so snapshots turn it themselves. + */ + int decoderTurn() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return 0; + return jpeg && jpegRenderer.isSoftware() ? 0 : session.rotate(); + } /** Transport in use, with the mode after a fallback (#112). */ String transportLabel() { return Transport.label(session.transportInUse(), transport); } diff --git a/android/app/src/main/java/com/openipc/decoder/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java index 24f39a1f..eabc4c66 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -97,8 +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, boolean mirror) { session.setSlotTurn(rotate, mirror); } - boolean mirror() { return session.mirror(); } + void setSlotTurn(int rotate) { session.setSlotTurn(rotate); } void start() { nalAssembler.reset(); diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index 518aeda7..b3f3c140 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -120,7 +120,6 @@ default void onDetail(String text) {} private volatile int rotate; /** The slot's setting (#201), over the URL's ?rotate= and the toys' defaults; -1: none. */ private volatile int slotRotate = -1; - private volatile boolean slotMirror; private volatile boolean rotateLogged; private Thread networkThread; private Thread watchdogThread; @@ -695,14 +694,10 @@ int rotate() { } /** The slot's own turn (#201): -1 leaves it to the URL or the toy's default. */ - void setSlotTurn(int rotate, boolean mirror) { + void setSlotTurn(int rotate) { slotRotate = rotate == 0 || rotate == 90 || rotate == 180 || rotate == 270 ? rotate : -1; - slotMirror = mirror; } - /** Whether the slot's picture is mirrored left to right (#201). */ - boolean mirror() { return slotMirror; } - /** Heartbeat statistics for /stats.json once an RTSP slot used one (#187), else null. */ Map heartbeatStats() { if (!heartbeatUsed) return null; diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index e26068a1..516904a0 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -10,6 +10,7 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Rect; import android.os.Build; @@ -60,6 +61,8 @@ static final class Cell { boolean ok; /** What the TV shows, in a grid drawn for the single view (#100): framed. */ boolean onScreen; + /** Degrees counter-clockwise the decoder turns the frames (#201): the copy is turned. */ + int turn; } private static HandlerThread copyThread; @@ -105,7 +108,7 @@ static byte[] render(List cells, int side, int width, boolean labels) { for (int i = 0; i < n; i++) { Cell c = cells.get(i); if (c != null && c.live && c.view != null) { - copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height()); + copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height(), c.turn); } } long deadline = SystemClock.elapsedRealtime() + COPY_TIMEOUT_MS; @@ -204,18 +207,41 @@ static boolean copy(SurfaceView view, Bitmap into, PixelCopy.OnPixelCopyFinished } } + /** + * {@code b} turned {@code ccw} degrees counter-clockwise (#201); {@code b} itself when not + * turned, else a new bitmap and {@code b} is recycled. + */ + static Bitmap turned(Bitmap b, int ccw) { + if (ccw == 0) return b; + Matrix m = new Matrix(); + m.postRotate(-ccw); + Bitmap t = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), m, true); + if (t != b) b.recycle(); + return t; + } + /** One PixelCopy in flight. */ private static final class Copy { final Bitmap bitmap; + final int turn; final CountDownLatch done = new CountDownLatch(1); final AtomicInteger result = new AtomicInteger(-1); - private Copy(Bitmap bitmap) { this.bitmap = bitmap; } + private Copy(Bitmap bitmap, int turn) { + this.bitmap = bitmap; + this.turn = turn; + } - /** Starts copying what the SurfaceView shows, scaled to w×h; null if it cannot. */ - static Copy start(SurfaceView view, int w, int h) { + /** + * Starts copying what the SurfaceView shows, scaled to w×h; null if it cannot. A + * surface the decoder turns (#201) holds the frame as decoded: it is copied at the + * unturned size and turned afterwards. + */ + static Copy start(SurfaceView view, int w, int h, int turn) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null; - Copy c = new Copy(Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)); + boolean quarter = turn == 90 || turn == 270; + Copy c = new Copy(Bitmap.createBitmap(quarter ? h : w, quarter ? w : h, + Bitmap.Config.ARGB_8888), turn); if (copy(view, c.bitmap, r -> { c.result.set(r); c.done.countDown(); @@ -239,7 +265,7 @@ Bitmap await(long deadlineMs) { Thread.currentThread().interrupt(); return null; } - if (result.get() == PixelCopy.SUCCESS) return bitmap; + if (result.get() == PixelCopy.SUCCESS) return turned(bitmap, turn); Log.d(TAG, "Snapshot copy failed: " + result.get()); bitmap.recycle(); return null; diff --git a/docs/debug.md b/docs/debug.md index 61967f7a..083d0efd 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -171,7 +171,7 @@ Single view and U view: every cell of the grid as the debug page shows it (3×3 | `codec`, `size` | when found | | `fps`, `kbps`, `key_frames` | measured during the check | -Toy drone slots ([toys.md](toys.md#flow-ufo)) add a `flowufo` object per cell and in `single` (`jieli` for `jieli://` slots, [toys.md](toys.md#hf--jieli-toys)), and a `heartbeat` object for RTSP slots that send the KY UFO heartbeat, and `rotate` (degrees counter-clockwise) and `mirror` where the picture is turned or mirrored ([toys.md](toys.md#ky-ufo-rtsp-toys)). +Toy drone slots ([toys.md](toys.md#flow-ufo)) add a `flowufo` object per cell and in `single` (`jieli` for `jieli://` slots, [toys.md](toys.md#hf--jieli-toys)), and a `heartbeat` object for RTSP slots that send the KY UFO heartbeat, and `rotate` (degrees counter-clockwise) where the picture is turned ([toys.md](toys.md#ky-ufo-rtsp-toys)). ## Log diff --git a/docs/usage.md b/docs/usage.md index b60d4737..46223468 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,7 +17,7 @@ 1. Open the menu: tap the screen, or press **OK** on a remote. 2. Choose a slot (**1**–**8**), then **Settings**. 3. Enter the camera URL, for example `rtsp://user:password@192.168.1.10:554/stream=0`, and choose the transport under it (**TCP / UDP / Auto / Multicast**, a new slot starts on Auto). URL formats: [transports.md](transports.md). - Below them, **Turn** (**Auto**, **0°**, **90° ↺**, **180°**, **270° ↺**, counter-clockwise) and **Mirror** set the picture for a camera mounted sideways or upside down, or seen in a mirror ([#201](https://github.com/OpenIPC/decoder/issues/201)). **Auto** leaves it to the URL's `?rotate=` and the toys' defaults. They apply in the single view and in the grid, to H.264, H.265 and MJPEG; the decoder turns the picture (Android 6+), and the mirror needs Android 7+. + Below them, **Turn** (**Auto**, **0°**, **90° ↺**, **180°**, **270° ↺**, counter-clockwise) turns the picture of a camera mounted sideways or upside down ([#201](https://github.com/OpenIPC/decoder/issues/201)). **Auto** leaves it to the URL's `?rotate=` and the toys' defaults. It applies in the single view and in the grid, to H.264, H.265 and MJPEG, and to snapshots and screenshots; the decoder turns the picture (Android 6+; before that only MJPEG is turned). A mirror setting is not there yet: the video surface cannot be mirrored without a separate drawing path. 4. **Save**. The app connects at once and reconnects by itself when the stream drops (exponential back-off, 1 s up to 30 s). The URL dialog is titled `Camera N URL`. The picture is fitted to the screen whatever its shape, without cropping. URLs are stored encrypted, see [security.md](security.md#credential-store). @@ -39,7 +39,7 @@ The menu is one grid: the eight slots on top, two rows of four below. Every word | Item | Action | |--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **1**–**8** | Switch to a camera slot (single view). The number of the active slot shows the connection quality: green under 100 ms of jitter, yellow 100–300 ms, red above. A grey number is an empty slot | -| **Settings** | URL, transport, turn and mirror of the active slot. Single view only, grey elsewhere | +| **Settings** | URL, transport and turn of the active slot. Single view only, grey elsewhere | | **WebUI** | The camera's web interface, see [WebUI](#webui). Single view only, grey elsewhere | | **Grid** | The view: [multi-view](#multi-view) (2×2 or 3×3) or the single camera. Does not change the playback mode | | **Slides** | The playback mode: every frame, or key frames only, see [Slides](#slides). Does not change the view | From 056c794f3347c680817d2b1f2e1a97bfceef30b7 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 15:36:54 +0000 Subject: [PATCH 5/9] fix: snapshots and screenshots copy the turned picture as is, PixelCopy already applies the decoder's turn (#201) --- .../java/com/openipc/decoder/Decoder.java | 21 ++-------- .../java/com/openipc/decoder/QuadCell.java | 13 +------ .../java/com/openipc/decoder/Snapshots.java | 38 +++---------------- 3 files changed, 10 insertions(+), 62 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 0ea2d36f..4aba5323 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -600,13 +600,6 @@ public void onShowSettings() { // ====================== Video lifecycle ====================== - /** As QuadCell.decoderTurn() for the single view (#201). */ - private int singleDecoderTurn() { - RtspClient c = rtspClient; - if (c == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return 0; - return c.isJpeg() && jpegRenderer.isSoftware() ? 0 : c.rotate(); - } - /** The single view's client for the active slot, with the slot's turn (#201). */ private void configureClient() { rtspClient.configure(mHost, mUserAgent, mTransports[mActive]); @@ -1497,7 +1490,6 @@ private List snapshotCells(int[] side) { ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; // Copied with the grid cells, off the UI thread (#76) if (c.live) c.view = mSurface; - c.turn = singleDecoderTurn(); } c.onScreen = onScreen(i); cells.add(c); @@ -1524,7 +1516,6 @@ private Snapshots.Cell snapshotCell(int i) { c.label = stats != null ? stats.getText().toString() : ""; c.live = cell.showsVideo(SNAPSHOT_LIVE_MS); c.view = quadViews[i]; - c.turn = cell.decoderTurn(); if (i == U_CELL && !c.live) { c.label = "U \u00b7 waiting for RTP on port " + UDP_RTP_PORT; } @@ -1852,14 +1843,9 @@ && checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) double fit = Math.min(1, Math.sqrt((double) SCREENSHOT_MAX_PIXELS / ((double) mVideoWidth * mVideoHeight))); Bitmap bitmap; - // A surface the decoder turns holds the frame as decoded: copied unturned (#201) - final int turn = singleDecoderTurn(); - boolean quarter = turn == 90 || turn == 270; - int shotW = Math.max(1, (int) (mVideoWidth * fit)); - int shotH = Math.max(1, (int) (mVideoHeight * fit)); try { - bitmap = Bitmap.createBitmap(quarter ? shotH : shotW, quarter ? shotW : shotH, - Bitmap.Config.ARGB_8888); + bitmap = Bitmap.createBitmap(Math.max(1, (int) (mVideoWidth * fit)), + Math.max(1, (int) (mVideoHeight * fit)), Bitmap.Config.ARGB_8888); } catch (OutOfMemoryError e) { Log.w(TAG, "No memory for a screenshot of " + mVideoWidth + "x" + mVideoHeight); Toast.makeText(this, "Failed to save screenshot", Toast.LENGTH_SHORT).show(); @@ -1870,8 +1856,7 @@ && checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) boolean started = Snapshots.copy(mSurface, bitmap, result -> { if (result == PixelCopy.SUCCESS) { // Encoding a full-resolution PNG takes long: on a thread of its own (#35) - new Thread(() -> saveScreenshot(Snapshots.turned(bitmap, turn)), "screenshot") - .start(); + new Thread(() -> saveScreenshot(bitmap), "screenshot").start(); } else { Log.d(TAG, "Screenshot copy failed: " + result); bitmap.recycle(); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 3295a309..79659739 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -7,7 +7,6 @@ package com.openipc.decoder; -import android.os.Build; import android.util.Log; import android.view.Surface; import android.view.SurfaceHolder; @@ -438,20 +437,10 @@ static boolean isKeyFrameNal(int type, boolean h265) { /** The cooingdv heartbeat of an RTSP cell once it used one (#187), else null. */ java.util.Map heartbeatStats() { return session.heartbeatStats(); } - /** Degrees counter-clockwise the MJPEG frames are turned (#200), 0 for none. */ + /** Degrees counter-clockwise the frames are turned (#200, #201), 0 for none. */ int rotate() { return session.rotate(); } void setSlotTurn(int rotate) { session.setSlotTurn(rotate); } - /** - * Degrees counter-clockwise the decoder turns this cell's frames on the surface (#201), 0 - * when the frames are drawn already turned (MJPEG in software) or not turned at all. A copy - * of the surface gets the frame as decoded, so snapshots turn it themselves. - */ - int decoderTurn() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return 0; - return jpeg && jpegRenderer.isSoftware() ? 0 : session.rotate(); - } - /** Transport in use, with the mode after a fallback (#112). */ String transportLabel() { return Transport.label(session.transportInUse(), transport); } diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index 516904a0..e26068a1 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -10,7 +10,6 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; -import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Rect; import android.os.Build; @@ -61,8 +60,6 @@ static final class Cell { boolean ok; /** What the TV shows, in a grid drawn for the single view (#100): framed. */ boolean onScreen; - /** Degrees counter-clockwise the decoder turns the frames (#201): the copy is turned. */ - int turn; } private static HandlerThread copyThread; @@ -108,7 +105,7 @@ static byte[] render(List cells, int side, int width, boolean labels) { for (int i = 0; i < n; i++) { Cell c = cells.get(i); if (c != null && c.live && c.view != null) { - copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height(), c.turn); + copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height()); } } long deadline = SystemClock.elapsedRealtime() + COPY_TIMEOUT_MS; @@ -207,41 +204,18 @@ static boolean copy(SurfaceView view, Bitmap into, PixelCopy.OnPixelCopyFinished } } - /** - * {@code b} turned {@code ccw} degrees counter-clockwise (#201); {@code b} itself when not - * turned, else a new bitmap and {@code b} is recycled. - */ - static Bitmap turned(Bitmap b, int ccw) { - if (ccw == 0) return b; - Matrix m = new Matrix(); - m.postRotate(-ccw); - Bitmap t = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), m, true); - if (t != b) b.recycle(); - return t; - } - /** One PixelCopy in flight. */ private static final class Copy { final Bitmap bitmap; - final int turn; final CountDownLatch done = new CountDownLatch(1); final AtomicInteger result = new AtomicInteger(-1); - private Copy(Bitmap bitmap, int turn) { - this.bitmap = bitmap; - this.turn = turn; - } + private Copy(Bitmap bitmap) { this.bitmap = bitmap; } - /** - * Starts copying what the SurfaceView shows, scaled to w×h; null if it cannot. A - * surface the decoder turns (#201) holds the frame as decoded: it is copied at the - * unturned size and turned afterwards. - */ - static Copy start(SurfaceView view, int w, int h, int turn) { + /** Starts copying what the SurfaceView shows, scaled to w×h; null if it cannot. */ + static Copy start(SurfaceView view, int w, int h) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null; - boolean quarter = turn == 90 || turn == 270; - Copy c = new Copy(Bitmap.createBitmap(quarter ? h : w, quarter ? w : h, - Bitmap.Config.ARGB_8888), turn); + Copy c = new Copy(Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)); if (copy(view, c.bitmap, r -> { c.result.set(r); c.done.countDown(); @@ -265,7 +239,7 @@ Bitmap await(long deadlineMs) { Thread.currentThread().interrupt(); return null; } - if (result.get() == PixelCopy.SUCCESS) return turned(bitmap, turn); + if (result.get() == PixelCopy.SUCCESS) return bitmap; Log.d(TAG, "Snapshot copy failed: " + result.get()); bitmap.recycle(); return null; From 22f4d0a58647791e182b410b4ad2b91c9a368650 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 15:52:37 +0000 Subject: [PATCH 6/9] fix: the turn is set before any frame of a connection, the Turn row scrolls, the log names the turn in effect (Qodo on #225) --- .../java/com/openipc/decoder/MenuManager.java | 6 ++- .../java/com/openipc/decoder/RtspSession.java | 48 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 292db659..7a63b808 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -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; @@ -584,7 +585,10 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, content.setPadding(dp(16), dp(8), dp(16), 0); content.addView(input); content.addView(transport); - content.addView(turn); + // 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; diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index b3f3c140..fbd1789d 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -329,13 +329,28 @@ private void watchdogLoop(int gen) { /** One session from connect to disconnect. Returns true if PLAY succeeded. */ private boolean connect(int gen) throws IOException { - // Only an RTSP MJPEG stream is turned (#200); the other transports draw it as it comes - rotate = 0; - if (RtpListener.Url.matches(url)) return listen(gen, RtpListener.Url.parse(url)); - if (HttpMjpeg.matches(url)) return httpMjpeg(gen, HttpMjpeg.Url.parse(url)); - if (FlowUfo.Url.matches(url)) return flowUfo(gen, FlowUfo.Url.parse(url)); - if (Jieli.Url.matches(url)) return jieli(gen, Jieli.Url.parse(url)); + // RTSP and Jieli streams take the URL's turn (#200, #201), the other transports draw + // the picture as it comes. Set before any frame of this connection, not at the SDP: a + // decoder made in between would keep a wrong turn (Qodo on #225) + if (RtpListener.Url.matches(url)) { + rotate = 0; + return listen(gen, RtpListener.Url.parse(url)); + } + if (HttpMjpeg.matches(url)) { + rotate = 0; + return httpMjpeg(gen, HttpMjpeg.Url.parse(url)); + } + if (FlowUfo.Url.matches(url)) { + rotate = 0; + return flowUfo(gen, FlowUfo.Url.parse(url)); + } + if (Jieli.Url.matches(url)) { + Jieli.Url j = Jieli.Url.parse(url); + rotate = j.rotate; + return jieli(gen, j); + } Url u = Url.parse(url); + rotate = u.rotate; Socket s = new Socket(); synchronized (lock) { if (gen != generation) return false; @@ -673,21 +688,23 @@ private void stopHeartbeat(UdpHeartbeat h) { } /** - * The turn for frames of this connection: {@code wanted} for MJPEG, 0 for H.264/H.265, - * which have no rotation yet (#201). Logged once each. + * The URL's turn {@code wanted} for frames of this connection. The turn in effect, with + * the slot's setting over it (#201), is logged once (Qodo on #225). */ private int rotation(int wanted, boolean jpeg) { - if (wanted == 0) return 0; - if (!rotateLogged) { + int slot = slotRotate; + int turn = slot >= 0 ? slot : wanted; + if (turn != 0 && !rotateLogged) { rotateLogged = true; // MJPEG is turned while drawing (#200), H.264/H.265 by the decoder (#201) - Log.i(TAG, tag + ": " + (jpeg ? "MJPEG frames" : "video") + " turned " + wanted - + "\u00b0 counter-clockwise (?rotate=0 turns it off)"); + Log.i(TAG, tag + ": " + (jpeg ? "MJPEG frames" : "video") + " turned " + turn + + "\u00b0 counter-clockwise" + (slot >= 0 ? " (the slot's Turn setting)" + : " (?rotate=0 or the slot's Turn 0\u00b0 turns it off)")); } return wanted; } - /** Degrees counter-clockwise the MJPEG frames are turned now (#200), 0 for none. */ + /** Degrees counter-clockwise the frames are turned now (#200, #201), 0 for none. */ int rotate() { int t = slotRotate; return t >= 0 ? t : rotate; @@ -695,7 +712,10 @@ int rotate() { /** The slot's own turn (#201): -1 leaves it to the URL or the toy's default. */ void setSlotTurn(int rotate) { - slotRotate = rotate == 0 || rotate == 90 || rotate == 180 || rotate == 270 ? rotate : -1; + int t = rotate == 0 || rotate == 90 || rotate == 180 || rotate == 270 ? rotate : -1; + // Another turn is logged again + if (t != slotRotate) rotateLogged = false; + slotRotate = t; } /** Heartbeat statistics for /stats.json once an RTSP slot used one (#187), else null. */ From f2a3af494cf8498fac2765e5671dea8cc8ca8b17 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 15:58:57 +0000 Subject: [PATCH 7/9] fix: the single view swaps the sides only where the decoder turns the picture, Android 6+ (Qodo on #225) --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 4aba5323..33896c1e 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -212,9 +212,11 @@ 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): a quarter turn swaps its sides + // The decoder turns the picture (#201, Android 6+ as in MediaCodecManager): a + // quarter turn swaps its sides RtspClient c = rtspClient; - int r = c != null ? c.rotate() : 0; + int r = c != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + ? c.rotate() : 0; if (r == 90 || r == 270) updateResolution(h, w); else updateResolution(w, h); } From fe086651e7d90ce5d41c23c266db61f32d6732cc Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 16:04:18 +0000 Subject: [PATCH 8/9] fix: a grid cell's label gives the size as shown, sides swapped by a quarter turn (Qodo on #225) --- .../java/com/openipc/decoder/MediaCodecManager.java | 10 ++++++++++ .../src/main/java/com/openipc/decoder/QuadCell.java | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index f02f0991..99b0119b 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -58,6 +58,8 @@ class MediaCodecManager { private volatile Surface surface; /** Degrees counter-clockwise the decoder turns the picture (#201); 0 for none. */ private volatile int rotation; + /** The turn the current decoder applies: {@link #rotation} where Android 6+ can, else 0. */ + private volatile int appliedRotation; /** Frames rendered so far (#82): with the frames received this gives the real decode rate. */ private volatile int framesOut; @@ -97,6 +99,11 @@ boolean renderedWithin(long ms) { int inputDropped() { return inputDropped; } int outWidth() { return outWidth; } int outHeight() { return outHeight; } + /** The picture on the surface has its sides swapped: a quarter turn applied (#201). */ + boolean swapsSides() { + int t = appliedRotation; + return t == 90 || t == 270; + } boolean hasDecoder() { return decoder != null; } /** @@ -386,7 +393,10 @@ void createDecoder(boolean codecH265) { 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); + } else { + turn = 0; } + appliedRotation = turn; try { local.configure(format, s, null, 0); local.start(); diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 79659739..46a93507 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -365,8 +365,10 @@ String sample(long nowMs) { if ("disconnected".equals(st)) return "Disconnected"; return "buffering".equals(st) ? "Buffering…" : "Connecting…"; } - int w = width(); - int h = height(); + // As shown: the decoder's output size, turned by a quarter turn (Qodo on #225) + boolean swap = !jpeg && decoder.swapsSides(); + int w = swap ? height() : width(); + int h = swap ? width() : height(); String size = w > 0 ? w + "×" + h + " " : ""; String codec = jpeg ? "MJPEG" : codecH265 ? "H.265" : "H.264"; if (keyFramesOnly) { From 56790a5403df4ffd1465e2a3593f2b1dc1808d1a Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 16:12:11 +0000 Subject: [PATCH 9/9] chore: version 2.17 (#225 merges before #220, which becomes 2.18) --- android/app/build.gradle | 4 ++-- docs/toys.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d572c7df..f6bbe65f 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 43 - versionName "2.18" + versionCode 42 + versionName "2.17" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/docs/toys.md b/docs/toys.md index 2c3886dd..29e33e24 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -89,7 +89,7 @@ rtsp://192.168.1.1:7070/webcam Their apps send a heartbeat, the two bytes `01 01`, to UDP port 7099 of the toy every second while the video plays. For an RTSP slot with port 7070 and path `/webcam` (any host) the app does the same: from the address of the RTSP connection, once a second, until the slot stops. Nothing else is sent to port 7099 (no flight command, no "stop" command). Other RTSP slots send no heartbeat unless asked with `?heartbeat=`, e.g. `rtsp://192.168.1.1:554/live?heartbeat=7099`; `?heartbeat=off` turns it off for a `:7070/webcam` slot. The option is the app's own and is not sent to the camera. The log has one line when it starts (*heartbeat 01 01 every 1000 ms to …*), and `/stats.json` a `heartbeat` object (`target`, `running`, `sent`) for such a slot. -The camera of some of these toys (the K200) is mounted sideways: its frames come turned, a mug lies on its side as a portrait strip in a landscape screen. For a `:7070/webcam` slot the app therefore turns the MJPEG frames 270° counter-clockwise (90° clockwise) when drawing them ([#200](https://github.com/OpenIPC/decoder/issues/200)), and fits the turned frame (a 480×640 frame shows as 640×480 and fills a landscape screen). `?rotate=0|90|180|270` (degrees counter-clockwise) overrides it, e.g. `rtsp://192.168.1.1:7070/webcam?rotate=0` for a unit mounted upright, and also turns other RTSP MJPEG slots; any other value is an error. The option is the app's own and is not sent to the camera. Since 2.18 it turns H.264/H.265 too, and the slot's **Turn** setting overrides it ([usage.md](usage.md), [#201](https://github.com/OpenIPC/decoder/issues/201)). The turn is in the picture itself, so zoom, the long-press screenshot and the debug snapshot show it turned too. The log has one line when it is active (*MJPEG frames turned 270° counter-clockwise*), and `/stats.json` a `rotate` (degrees) for such a slot; its `size` is the turned one. +The camera of some of these toys (the K200) is mounted sideways: its frames come turned, a mug lies on its side as a portrait strip in a landscape screen. For a `:7070/webcam` slot the app therefore turns the MJPEG frames 270° counter-clockwise (90° clockwise) when drawing them ([#200](https://github.com/OpenIPC/decoder/issues/200)), and fits the turned frame (a 480×640 frame shows as 640×480 and fills a landscape screen). `?rotate=0|90|180|270` (degrees counter-clockwise) overrides it, e.g. `rtsp://192.168.1.1:7070/webcam?rotate=0` for a unit mounted upright, and also turns other RTSP MJPEG slots; any other value is an error. The option is the app's own and is not sent to the camera. Since 2.17 it turns H.264/H.265 too, and the slot's **Turn** setting overrides it ([usage.md](usage.md), [#201](https://github.com/OpenIPC/decoder/issues/201)). The turn is in the picture itself, so zoom, the long-press screenshot and the debug snapshot show it turned too. The log has one line when it is active (*MJPEG frames turned 270° counter-clockwise*), and `/stats.json` a `rotate` (degrees) for such a slot; its `size` is the turned one. Some of these toys use the FLOW-UFO protocol instead (the KY UFO app has both); if RTSP shows nothing, try `flowufo://192.168.1.1`.