From 83a670f571580adb4a28de74f499d161963c6cd6 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 12:44:11 +0000 Subject: [PATCH 1/2] feat: Auto is the transport of a slot that has none stored (#112) --- README.md | 6 +++--- README.ru.md | 6 +++--- README.zh-CN.md | 6 +++--- .../app/src/main/java/com/openipc/decoder/Decoder.java | 3 ++- .../app/src/main/java/com/openipc/decoder/Transport.java | 8 ++++++++ .../src/test/java/com/openipc/decoder/TransportTest.java | 4 ++++ 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b85ebd00..9ca2b147 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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](#transports); TCP is the default. + Under the URL you can choose the [transport](#transports); Auto (UDP, falling back to TCP) is the default for a new slot. 4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops. ## Controls @@ -98,9 +98,9 @@ Each slot has its own transport, chosen under the URL in **Settings** (or with t | Transport | How the video arrives | Notes | |-------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| -| **TCP** (default) | RTP inside the RTSP connection | Works everywhere, also through NAT and firewalls | +| **TCP** | RTP inside the RTSP connection | Works everywhere, also through NAT and firewalls | | **UDP** | RTP over UDP, a port pair per track | Lower latency on a LAN. Lost packets are skipped, not resent. Never falls back | -| **Auto** | UDP, or TCP when UDP does not work | Falls back to TCP when the camera refuses UDP or no video arrives within 3 s, then stays on TCP until the slot is changed | +| **Auto** (default) | UDP, or TCP when UDP does not work | Falls back to TCP when the camera refuses UDP or no video arrives within 3 s, then stays on TCP until the slot is changed | | **Multicast** | RTSP multicast (Android 8.0+), or TCP when it is refused | For servers that offer RTSP multicast. OpenIPC Majestic and XM cameras do not: they reply 400 or 451, and the slot plays over TCP in the same connection | The transport actually in use is shown when a stream starts in single view (`Connected • UDP • H.265 …`), on the debug page and in `/stats.json` (`transport`; the slot's setting is `transport_mode`). After a fallback it reads, for example, `TCP (auto)`. diff --git a/README.ru.md b/README.ru.md index b8454db9..bb947db1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -50,7 +50,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - Под адресом можно выбрать [транспорт](#транспорты); по умолчанию TCP. + Под адресом можно выбрать [транспорт](#транспорты); у нового слота по умолчанию Auto (UDP с откатом на TCP). 4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. ## Управление @@ -98,9 +98,9 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ | Транспорт | Как приходит видео | Примечание | |------------------------|-------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| **TCP** (по умолчанию) | RTP внутри RTSP-соединения | Работает везде, в том числе через NAT и межсетевые экраны | +| **TCP** | RTP внутри RTSP-соединения | Работает везде, в том числе через NAT и межсетевые экраны | | **UDP** | RTP по UDP, пара портов на дорожку | Меньше задержка в локальной сети. Потерянные пакеты пропускаются, а не запрашиваются заново. На TCP не переходит | -| **Auto** | UDP, а если UDP не работает — TCP | Переходит на TCP, если камера отказала в UDP или видео не пришло за 3 с, и остаётся на TCP, пока слот не изменят | +| **Auto** (по умолчанию) | UDP, а если UDP не работает — TCP | Переходит на TCP, если камера отказала в UDP или видео не пришло за 3 с, и остаётся на TCP, пока слот не изменят | | **Multicast** | RTSP multicast (Android 8.0+), при отказе — TCP | Для серверов с RTSP multicast. OpenIPC Majestic и камеры XM его не поддерживают: отвечают 400 или 451, и слот играет по TCP в том же соединении | Фактический транспорт показывается при запуске потока в одиночном режиме (`Connected • UDP • H.265 …`), на отладочной странице и в `/stats.json` (`transport`; настройка слота — `transport_mode`). После отката там будет, например, `TCP (auto)`. diff --git a/README.zh-CN.md b/README.zh-CN.md index bd48f38a..745cee82 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,7 +50,7 @@ ``` rtsp://username:password@ip_address:554/stream=0 ``` - 地址下方可选择[传输方式](#传输方式),默认为 TCP。 + 地址下方可选择[传输方式](#传输方式),新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 @@ -98,9 +98,9 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ | 传输方式 | 视频如何到达 | 说明 | |-----------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------| -| **TCP**(默认) | RTP 封装在 RTSP 连接中 | 适用于所有环境,也可穿过 NAT 和防火墙 | +| **TCP** | RTP 封装在 RTSP 连接中 | 适用于所有环境,也可穿过 NAT 和防火墙 | | **UDP** | 通过 UDP 传输 RTP,每路一对端口 | 局域网内延迟更低。丢失的包直接跳过,不会重传。不会回退到 TCP | -| **Auto** | 优先 UDP,UDP 不可用时改用 TCP | 摄像机拒绝 UDP 或 3 秒内未收到视频时回退到 TCP,并保持 TCP 直到修改该槽位 | +| **Auto**(默认) | 优先 UDP,UDP 不可用时改用 TCP | 摄像机拒绝 UDP 或 3 秒内未收到视频时回退到 TCP,并保持 TCP 直到修改该槽位 | | **Multicast** | RTSP 组播(Android 8.0+),被拒绝时改用 TCP | 适用于提供 RTSP 组播的服务器。OpenIPC Majestic 和 XM 摄像机不支持:它们返回 400 或 451,槽位会在同一连接中改用 TCP 播放 | 实际使用的传输方式会在单画面模式开始播放时显示(`Connected • UDP • H.265 …`),也会显示在调试页面和 `/stats.json` 中(`transport`;槽位的设置为 `transport_mode`)。回退后会显示为例如 `TCP (auto)`。 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 10385c99..4e5b0e39 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -515,7 +515,8 @@ private void loadSettings() { String url = credentials.open(stored); mHosts[i] = url != null ? url : DEFAULT_URL; mUnreadable[i] = url == null && stored != null ? stored : null; - mTransports[i] = Transport.Mode.parse(pref.getString("transport_" + i, null)); + // Auto for a slot that has never stored one (#112) + mTransports[i] = Transport.Mode.stored(pref.getString("transport_" + i, null)); // Plain URLs from older versions are re-saved encrypted right away if (credentials.needsMigration(stored)) { if (migrate == null) migrate = pref.edit(); diff --git a/android/app/src/main/java/com/openipc/decoder/Transport.java b/android/app/src/main/java/com/openipc/decoder/Transport.java index 4eb82b72..acee5608 100644 --- a/android/app/src/main/java/com/openipc/decoder/Transport.java +++ b/android/app/src/main/java/com/openipc/decoder/Transport.java @@ -32,6 +32,14 @@ static Mode parse(String s) { } return TCP; } + + /** + * The transport of a slot from the settings: Auto when none is stored yet (a new + * install or slot, #112), an unknown value TCP as before. + */ + static Mode stored(String s) { + return s == null ? AUTO : parse(s); + } } /** diff --git a/android/app/src/test/java/com/openipc/decoder/TransportTest.java b/android/app/src/test/java/com/openipc/decoder/TransportTest.java index f577f28b..16604a84 100644 --- a/android/app/src/test/java/com/openipc/decoder/TransportTest.java +++ b/android/app/src/test/java/com/openipc/decoder/TransportTest.java @@ -89,6 +89,10 @@ public void modeFromSettings() { assertEquals(Transport.Mode.TCP, Transport.Mode.parse(null)); assertEquals(Transport.Mode.TCP, Transport.Mode.parse("quic")); assertEquals(Transport.Mode.MULTICAST, Transport.Mode.parse("multicast")); + // A slot without a stored transport is Auto (#112); stored values stay as they are + assertEquals(Transport.Mode.AUTO, Transport.Mode.stored(null)); + assertEquals(Transport.Mode.TCP, Transport.Mode.stored("TCP")); + assertEquals(Transport.Mode.UDP, Transport.Mode.stored("udp")); } @Test From 37c83ea1c22f010f5e7e261c19933cb21d5594a2 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 12:51:03 +0000 Subject: [PATCH 2/2] fix: Auto also falls back to TCP in single view without decoded frames, on UDP audio failure and on UDP port failure (Qodo on #128) --- .../java/com/openipc/decoder/Decoder.java | 35 +++++++++++++++++++ .../java/com/openipc/decoder/RtspClient.java | 4 +++ .../java/com/openipc/decoder/RtspSession.java | 22 ++++++++++-- 3 files changed, 59 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 4e5b0e39..380d7cb9 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1589,7 +1589,39 @@ protected void onPause() { stopStreams(); } + /** + * Single view over UDP (#128, like the multi-view cells in #125): packets arrive but no + * frame is decoded for UDP_STALL_SECONDS — a lossy UDP path keeps breaking the key + * frames. With Auto the session then goes over to TCP. Checked once a second. + */ + private static final int UDP_STALL_SECONDS = 8; + private int singleStallSeconds; + private int lastSingleFrames; + private long lastSingleBytes; + private final Runnable singleUdpCheck = new Runnable() { + @Override + public void run() { + if (!mResumed) return; + RtspClient client = rtspClient; + int frames = videoDecoder.framesOut(); + long bytes = client != null ? client.getStreamBytes() : 0; + boolean stalled = !quadEnabled && !udpViewShown && client != null + && client.playsOverUdp() && "connected".equals(singleState) + && bytes - lastSingleBytes > 10_000 && frames == lastSingleFrames; + lastSingleFrames = frames; + lastSingleBytes = bytes; + singleStallSeconds = stalled ? singleStallSeconds + 1 : 0; + if (singleStallSeconds >= UDP_STALL_SECONDS) { + singleStallSeconds = 0; + client.reportUdpBroken("packets but no decoded frame for " + UDP_STALL_SECONDS + + " s"); + } + mSurface.postDelayed(this, 1000); + } + }; + private void stopStreams() { + mSurface.removeCallbacks(singleUdpCheck); // The single view's stream ends here: it is no longer "connected" for the debug page singleState = "disconnected"; if (quadCells != null) { @@ -1655,6 +1687,9 @@ protected void onResume() { private boolean mResumed; private void startStreams() { + singleStallSeconds = 0; + mSurface.removeCallbacks(singleUdpCheck); + mSurface.postDelayed(singleUdpCheck, 1000); loadSettings(); videoDecoder.setEnabled(true); audioManager.setEnabled(true); 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 bab08e85..daa39f30 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -69,6 +69,10 @@ void configure(String host, String userAgent, Transport.Mode transport) { } boolean isActive() { return session.isActive(); } + /** RTP over UDP or multicast at the moment (#128). */ + boolean playsOverUdp() { return session.playsOverUdp(); } + /** Packets arrive but nothing decodes: with Auto the session goes over to TCP. */ + void reportUdpBroken(String why) { session.reportUdpBroken(why); } /** Transport the stream really uses now, or null (#115). */ String transportInUse() { return session.transportInUse(); } long getStreamBytes() { return session.getStreamBytes(); } 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 33a16bff..335d70e7 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -331,7 +331,16 @@ private boolean connect(int gen) throws IOException { } } - int videoPort = rx != null && !mc ? rx.addTrack(CHANNEL_VIDEO) : -1; + int videoPort = -1; + if (rx != null && !mc) { + try { + videoPort = rx.addTrack(CHANNEL_VIDEO); + } catch (IOException e) { + // No local UDP ports: with Auto the next attempt goes over TCP (Qodo on #128) + if (fallback) markUdpFailed(m); + throw e; + } + } String transport = rx == null ? Transport.tcpRequest(CHANNEL_VIDEO) : mc ? Transport.multicastRequest() : Transport.udpRequest(videoPort); r = exchange(c, "SETUP", sdp.videoUrl, "Transport: " + transport + "\r\n"); @@ -381,7 +390,12 @@ private boolean connect(int gen) throws IOException { "Transport: " + transport + "\r\n" + "Session: " + session + "\r\n"); Transport audioReply = Transport.parse(r.header("transport")); - if (!r.ok()) { + if (!r.ok() && auto && rx != null) { + // Auto: rather TCP for both tracks than UDP video without sound (#128) + markUdpFailed(m); + throw new IOException("audio SETUP over UDP failed (" + r.code + + "), using TCP"); + } else if (!r.ok()) { // Not fatal: play video without audio Log.w(TAG, tag + ": audio SETUP failed (" + r.code + "), no audio"); audio = false; @@ -394,6 +408,10 @@ private boolean connect(int gen) throws IOException { } } else if (rx != null && (audioReply.tcp || !udpReplyUsable(audioReply, audioPort))) { + if (auto) { + markUdpFailed(m); + throw new IOException("audio not over UDP like video, using TCP"); + } // Video over UDP, audio elsewhere: the UDP loop cannot play it Log.w(TAG, tag + ": audio not over UDP like video, no audio"); audio = false;