diff --git a/README.md b/README.md index 0ec963e2..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. 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 4040d840..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). Другие виды адресов — в [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 c3f08dec..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)。其他地址格式见 [Transports](docs/transports.md)(英文)。 + 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机。其他地址格式见 [Transports](docs/transports.md)(英文)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 diff --git a/android/app/build.gradle b/android/app/build.gradle index a45a4439..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 41 - versionName "2.16" + versionCode 42 + versionName "2.17" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } 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..33896c1e 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,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; @@ -209,7 +211,15 @@ 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, 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); + else updateResolution(w, h); + } @Override public void onDecoderStarted() { removeClearOverlay(); } @Override public void onDecoderFailed() {} }); @@ -396,7 +406,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(); } @@ -580,7 +590,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); } @@ -590,8 +602,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); } @@ -631,6 +651,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(); @@ -693,6 +714,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(); @@ -715,7 +737,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(); } @@ -1002,6 +1024,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); @@ -1603,8 +1626,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); } @@ -2600,7 +2623,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/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index f75d6ca0..99b0119b 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,10 @@ 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; + /** 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; @@ -95,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; } /** @@ -255,6 +264,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 +389,14 @@ 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); + } 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/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 8cfafd4b..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; @@ -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"); @@ -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° ↺"}; + 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; @@ -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()); 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..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) { @@ -437,8 +439,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 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); } @@ -694,6 +697,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/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java index d0f550cb..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,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(); 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..fbd1789d 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -118,8 +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 rotateLogged; - private volatile boolean rotateIgnoredLogged; private Thread networkThread; private Thread watchdogThread; @@ -146,7 +147,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; @@ -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,28 +688,35 @@ 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 (!jpeg) { - if (!rotateIgnoredLogged) { - rotateIgnoredLogged = true; - Log.i(TAG, tag + ": rotate= only applies to MJPEG streams for now (#201)"); - } - return 0; - } - if (!rotateLogged) { + int slot = slotRotate; + int turn = slot >= 0 ? slot : wanted; + if (turn != 0 && !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 " + 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. */ - int rotate() { return rotate; } + /** Degrees counter-clockwise the frames are turned now (#200, #201), 0 for none. */ + 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) { + 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. */ Map heartbeatStats() { diff --git a/docs/debug.md b/docs/debug.md index ead0156c..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) 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) where the picture is turned ([toys.md](toys.md#ky-ufo-rtsp-toys)). ## Log diff --git a/docs/toys.md b/docs/toys.md index 243ce2b8..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. 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.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`. 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..46223468 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) 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). @@ -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 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 |