From be06fada9472935af9e11700471a45a130824565 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 19:41:50 +0000 Subject: [PATCH 1/6] fix: menu dividers under the gap between slots 6 and 7; debug page opens with Info Off (#174) --- README.md | 4 +-- README.ru.md | 4 +-- README.zh-CN.md | 4 +-- .../java/com/openipc/decoder/DebugServer.java | 5 ++-- .../java/com/openipc/decoder/MenuManager.java | 30 +++++++++++++++++-- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 26d60abb..754985c9 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,8 @@ A slot also takes an HTTP MJPEG URL, such as Majestic's `http://user:password@:8085/` — the snapshot fills the page, with each cell's camera, size, fps and losses (in red) drawn over it; the header shows the version, the device time and uptime. **Info On / Off** in the header hides the text over the cells (and the list below them on a narrow screen), leaving only the picture; the choice is remembered in the browser. A red note in the header says when the device's video decoder stopped responding (`decoder_stuck`); -- `http://:8085/snapshot.jpg` (`?labels=0`: frames only) — a picture laid out like the screen, at the screen's resolution (at most 1920×1080), made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 or 10 s while the tab is visible; +- `http://:8085/` — the snapshot fills the page, with each cell's camera, size, fps and losses (in red) drawn over it; the header shows the version, the device time and uptime. **Info On / Off** in the header hides the text over the cells (and the list below them on a narrow screen), leaving only the picture; it is **Off** until switched on, and the choice is remembered in the browser. A red note in the header says when the device's video decoder stopped responding (`decoder_stuck`); +- `http://:8085/snapshot.jpg` (`?labels=0`: frames only) — a picture laid out like the screen, at the screen's resolution (at most 1920×1080), made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 (default) or 10 s while the tab is visible; - `http://:8085/stats.json` — the same as JSON, for scripts; - In single view the page and the snapshot still show the whole grid (#100): the camera on screen is framed in blue, every other configured slot gets a short stream check (connect, a few seconds of packets, no decoding) about every 45 s while the page is open, and its cell says what was found, e.g. `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, or the error. A slot of the camera on screen is never checked (some cameras take one client only), nor a `udp://` slot. `stats.json` has these cells under `slots`; - `http://:8085/log` — the last 500 lines of the app's log. diff --git a/README.ru.md b/README.ru.md index 8d62df0a..8348a9be 100644 --- a/README.ru.md +++ b/README.ru.md @@ -145,8 +145,8 @@ outgoing: Пока приложение работает, оно отдаёт отладочную информацию по HTTP на порту **8085** — для браузера или `curl` из той же сети: -- `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм. Переключатель **Info On / Off** в шапке убирает текст поверх ячеек (и список под ними на узком экране), остаётся только картинка; выбор запоминается в браузере. Красная пометка в шапке сообщает, что видеодекодер устройства перестал отвечать (`decoder_stuck`); -- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна; +- `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм. Переключатель **Info On / Off** в шапке убирает текст поверх ячеек (и список под ними на узком экране), остаётся только картинка; по умолчанию **Off**, выбор запоминается в браузере. Красная пометка в шапке сообщает, что видеодекодер устройства перестал отвечать (`decoder_stuck`); +- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 (по умолчанию) или 10 с, пока вкладка видна; - `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов; - В одиночном виде страница и снимок всё равно показывают всю сетку (#100): камера на экране обведена синей рамкой, а каждый другой настроенный слот, пока страница открыта, примерно раз в 45 с получает короткую проверку потока (подключение, несколько секунд пакетов, без декодирования); в его ячейке — результат, например `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, или ошибка. Слот с камерой, которая на экране, не проверяется (некоторые камеры принимают только одного клиента), как и слоты `udp://`. В `stats.json` эти ячейки — в поле `slots`; - `http://<приставка>:8085/log` — последние 500 строк лога приложения. diff --git a/README.zh-CN.md b/README.zh-CN.md index 82700195..0178ccf6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -145,8 +145,8 @@ outgoing: 应用运行时会在 **8085** 端口通过 HTTP 提供调试信息,可在同一网络中用浏览器或 `curl` 访问: -- `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长。页头的 **Info On / Off** 开关可隐藏画面上的文字(以及窄屏时下方的列表),只保留画面;选择会保存在浏览器中。设备视频解码器停止响应时,页头会出现红色提示(`decoder_stuck`); -- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次; +- `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长。页头的 **Info On / Off** 开关可隐藏画面上的文字(以及窄屏时下方的列表),只保留画面;默认为 **Off**,选择会保存在浏览器中。设备视频解码器停止响应时,页头会出现红色提示(`decoder_stuck`); +- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5(默认)或 10 秒刷新一次; - `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用; - 单画面模式下,页面和快照仍显示完整网格(#100):屏幕上的摄像机用蓝框标出;页面打开期间,其他每个已配置的槽位约每 45 秒进行一次简短的流检测(连接、接收几秒数据包、不解码),其画面显示检测结果,例如 `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`,或错误信息。与屏幕上相同的摄像机的槽位不检测(部分摄像机只接受一个客户端),`udp://` 槽位也不检测。`stats.json` 中这些画面位于 `slots` 字段; - `http://<设备>:8085/log` — 应用日志的最后 500 行。 diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 083c00ae..d7f0411b 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -551,8 +551,9 @@ private static void closeQuietly(Socket s) { + "try{localStorage.setItem('every',every)}catch(x){}};mark();\n" + "var img=$('snap');img.onload=img.onerror=function(){later(snap,every*1000)};\n" + "function snap(){img.src='/snapshot.jpg?labels=0&t='+Date.now()}\n" - // Info on or off (#141), remembered like the interval - + "var shown=1,iseg=$('info');try{shown=localStorage.getItem('info')==='0'?0:1}catch(x){}\n" + // Info on or off (#141), remembered like the interval; Off until chosen: the page + // opens on the clean picture, every 5 s + + "var shown=0,iseg=$('info');try{shown=localStorage.getItem('info')==='1'?1:0}catch(x){}\n" + "function imark(){[].forEach.call(iseg.children,function(b){b.className=+b.value===sh" + "own?'on':''});document.body.classList.toggle('clean',!shown)}\n" + "iseg.onclick=function(e){if(!e.target.value)return;shown=+e.target.value;imark();" 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 830bb8d9..c82775d0 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -68,6 +68,8 @@ class MenuManager { private TextView mSettingsBtn; /** Settings | WebUI, shown in the single view only (#173). */ private LinearLayout mSettingsRow; + /** Left items of the two-item rows, aligned with the slot row's middle (#173). */ + private final java.util.List mPairLefts = new java.util.ArrayList<>(); private TextView mWebUiBtn; TextView[] camButtons; private Dialog mBrowserDialog; @@ -183,6 +185,9 @@ public boolean dispatchKeyEvent(KeyEvent event) { LinearLayout camRow = new LinearLayout(activity); camRow.setOrientation(LinearLayout.HORIZONTAL); + // Not inside the layout pass: changing a size there would be lost or loop + camRow.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> + camRow.post(() -> alignPairs(camRow))); // 8 slots + U D Q (#13, #93) may not fit a narrow screen or a large font: the row scrolls, // and follows the focus when moving with a remote HorizontalScrollView camScroll = new HorizontalScrollView(activity); @@ -405,14 +410,16 @@ static boolean isNavigationKey(int keyCode) { } /** - * Two menu items side by side, split by a thin line exactly in the middle (#173): both get - * half the width. Left / Right move between them with a remote. + * Two menu items side by side, split by a thin line (#173), which {@link #alignPairs} puts + * under the gap between slots 6 and 7 of the row above. Left / Right move between them + * with a remote. */ private LinearLayout pairRow(TextView left, TextView right) { LinearLayout row = new LinearLayout(activity); row.setOrientation(LinearLayout.HORIZONTAL); row.addView(left, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + mPairLefts.add(left); View line = new View(activity); line.setBackgroundColor(Color.DKGRAY); row.addView(line, new LinearLayout.LayoutParams(dp(1), @@ -424,6 +431,25 @@ private LinearLayout pairRow(TextView left, TextView right) { return row; } + /** + * The left item of every two-item row ends where slot 7 begins, so the dividing lines run + * on from the gap between slots 6 and 7: the slot buttons differ in width, half the row + * is not that gap. Until the slot row is laid out, both halves stay equal. + */ + private void alignPairs(LinearLayout camRow) { + if (camButtons == null || camButtons.length < 7 || camButtons[6] == null) return; + int gap = (camButtons[5].getRight() + camButtons[6].getLeft()) / 2; + int width = gap - dp(1) / 2; + if (gap <= 0 || width <= 0) return; + for (TextView left : mPairLefts) { + LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) left.getLayoutParams(); + if (lp.width == width && lp.weight == 0) continue; + lp.width = width; + lp.weight = 0; + left.setLayoutParams(lp); + } + } + private TextView createItem(String title) { TextView text = new TextView(activity); text.setText(title); From cef4ef082e1ca330e13263a32584d04e75c80335 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 20:07:40 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20Qodo=20on=20#175=20=E2=80=94=20divid?= =?UTF-8?q?ers=20follow=20slots=206|7=20only=20while=20the=20slot=20row=20?= =?UTF-8?q?fits;=20a=20scrolling=20row=20keeps=20equal=20halves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/openipc/decoder/MenuManager.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 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 c82775d0..70f90ae1 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -434,18 +434,24 @@ private LinearLayout pairRow(TextView left, TextView right) { /** * The left item of every two-item row ends where slot 7 begins, so the dividing lines run * on from the gap between slots 6 and 7: the slot buttons differ in width, half the row - * is not that gap. Until the slot row is laid out, both halves stay equal. + * is not that gap. Until the slot row is laid out, and while it is too wide for the menu, + * both halves stay equal. */ private void alignPairs(LinearLayout camRow) { if (camButtons == null || camButtons.length < 7 || camButtons[6] == null) return; int gap = (camButtons[5].getRight() + camButtons[6].getLeft()) / 2; int width = gap - dp(1) / 2; - if (gap <= 0 || width <= 0) return; + // A slot row wider than the menu scrolls, and its gap moves with it: then plain halves, + // which keep both items whole (Qodo on #175) + View scroll = (View) camRow.getParent(); + boolean fits = scroll != null && camRow.getWidth() <= scroll.getWidth(); for (TextView left : mPairLefts) { LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) left.getLayoutParams(); - if (lp.width == width && lp.weight == 0) continue; - lp.width = width; - lp.weight = 0; + int w = fits && width > 0 ? width : 0; + float weight = fits && width > 0 ? 0 : 1; + if (lp.width == w && lp.weight == weight) continue; + lp.width = w; + lp.weight = weight; left.setLayoutParams(lp); } } From 8b7c5f905236f228cefd6fa29e37e2bf02cb7eeb Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 20:33:25 +0000 Subject: [PATCH 3/6] feat: menu labels centred in their halves, the word Exit in the middle (#176) --- .../java/com/openipc/decoder/MenuManager.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 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 70f90ae1..ce14d811 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -16,6 +16,7 @@ import android.os.Build; import android.text.InputType; import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.text.style.SuperscriptSpan; import android.util.Log; @@ -295,10 +296,17 @@ public boolean dispatchKeyEvent(KeyEvent event) { if (listener != null) listener.onToggleSlides(); }); - String code = "Exit [v" + version + ", " + gitHash + "]"; + // "Exit" centred in its half (#176): the small version text follows it, and an + // invisible copy of it in front balances the line, so the word itself is in the middle + String ver = "[v" + version + ", " + gitHash + "]"; + String code = ver + " Exit " + ver; + int exitEnd = ver.length() + 5; SpannableString s = new SpannableString(code); - s.setSpan(new SuperscriptSpan(), 5, s.length(), 0); - s.setSpan(new RelativeSizeSpan(0.5f), 5, s.length(), 0); + s.setSpan(new SuperscriptSpan(), 0, ver.length(), 0); + s.setSpan(new RelativeSizeSpan(0.5f), 0, ver.length(), 0); + s.setSpan(new ForegroundColorSpan(Color.TRANSPARENT), 0, ver.length(), 0); + s.setSpan(new SuperscriptSpan(), exitEnd, s.length(), 0); + s.setSpan(new RelativeSizeSpan(0.5f), exitEnd, s.length(), 0); View divider = new View(activity); divider.setBackgroundColor(Color.DKGRAY); @@ -428,6 +436,9 @@ private LinearLayout pairRow(TextView left, TextView right) { LinearLayout.LayoutParams.WRAP_CONTENT, 1)); left.setSingleLine(true); right.setSingleLine(true); + // Labels in the middle of their halves (#176) + left.setGravity(Gravity.CENTER); + right.setGravity(Gravity.CENTER); return row; } From d729f5242ecc2a649c8860fafc2b3f9931ea7f45 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 20:47:24 +0000 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20menu=20as=20an=208=20/=204=20/=204?= =?UTF-8?q?=20grid=20=E2=80=94=20slots=20on=20top,=20Settings=20WebUI=20Gr?= =?UTF-8?q?id=20Slides,=20Exit=20Reset=20Debug=20UDP;=20grey=20Settings/We?= =?UTF-8?q?bUI=20outside=20the=20single=20view;=20remote=20links=20between?= =?UTF-8?q?=20the=20rows=20(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LABORATORY.md | 4 +- README.md | 26 +- README.ru.md | 26 +- README.zh-CN.md | 26 +- .../java/com/openipc/decoder/MenuManager.java | 271 +++++++----------- 5 files changed, 164 insertions(+), 189 deletions(-) diff --git a/LABORATORY.md b/LABORATORY.md index 1b829015..69191807 100644 --- a/LABORATORY.md +++ b/LABORATORY.md @@ -259,7 +259,7 @@ The link goes into the active slot and is saved. The transport extra exists from - Transport in the UI: menu → URL dialog of the slot → **TCP / UDP / Auto / Multicast** under the URL. - udp:// slots (#72) are a URL, not a transport: `udp://@:5600`, `udp://@239.1.2.3:5600`, `udp://@239.1.2.3:5600`, optional `?codec=h265&pt=97`. - The share intent writes into the **active** slot (and switches to the single view): select the slot first. -- Filling slots on the box: tap a menu button by its text rather than by coordinates (the row is `1 2 3 4 5 6 7 8 U D Q S`): `uiautomator dump`, take the `bounds` of `text="N"`, `input tap` its centre; the URL dialog of a slot: **Settings** (single view only) → `TCP` / `UDP` / `Auto` / `Multicast` → `SAVE`. +- Filling slots on the box: tap a menu button by its text rather than by coordinates (from 2.07 the menu is a grid: slots `1`–`8` on top, then `Settings WebUI Grid Slides`, then `Exit Reset Debug UDP`, #177): `uiautomator dump`, take the `bounds` of `text="N"`, `input tap` its centre; the URL dialog of a slot: **Settings** (single view only) → `TCP` / `UDP` / `Auto` / `Multicast` → `SAVE`. ## Methods @@ -304,7 +304,7 @@ The script uninstalls first. To test an upgrade that keeps the settings, use `$A | UDP socket leak | `$ADB shell cat /proc/$(pidof com.openipc.decoder)/net/udp`, count lines before and after | | CPU per thread | `$ADB shell top -H -p ` (a spinning `rtsp-audio` thread was found this way) | | Screen off / on | `input keyevent KEYCODE_SLEEP` / `KEYCODE_WAKEUP` | -| Grid ↔ single stress (#76) | loop: menu → tap `4` (grid → single), 4 s, menu → tap `Q` (single → grid), 8 s; afterwards count `Davey! duration=` ≥ 1000 ms and `ANR in` in logcat; 8–10 cycles reproduce #76 today | +| Grid ↔ single stress (#76) | loop: menu → tap `4` (grid → single), 4 s, menu → tap `Grid` (single → grid; `Q` before 2.07), 8 s; afterwards count `Davey! duration=` ≥ 1000 ms and `ANR in` in logcat; 8–10 cycles reproduce #76 today | | Transport in use | `/stats.json`: `transport` (in use, e.g. `TCP (auto)` after a fallback) and `transport_mode` (setting) per cell; `nal_dropped` = NAL units lost to packet loss | | Menu with a remote (#91) | `input keyevent KEYCODE_DPAD_CENTER` opens the menu with the focus on the active item (slot, Q or U); `DPAD_RIGHT` ×n, `DPAD_DOWN` (Exit), `DPAD_UP`, then `DPAD_CENTER` must switch to that slot; `KEYCODE_BACK` closes it. The focused item: `uiautomator dump /sdcard/m.xml` and look for `focused="true"`. Check in single view and multi-view | | 4:3 screen | `wm size 960x720`, back with `wm size reset` | diff --git a/README.md b/README.md index 754985c9..8316ddc0 100644 --- a/README.md +++ b/README.md @@ -59,19 +59,27 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern ### Menu +The menu is a grid: the eight slots on top, two rows of four below. + +``` +[ 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 ] +[ Settings | WebUI | Grid | Slides ] +[ Exit | Reset | Debug | UDP ] +``` + | Item | Action | |--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **1**–**8** | Switch to a camera slot. The active slot's number shows the connection quality: green under 100 ms of jitter, yellow 100–300 ms, red above 300 ms. A grey number is an empty slot. | -| **U** | The U cell full-screen: the camera that pushes RTP over UDP to port 5600 | -| **D** | Text in the corner of every multi-view cell (resolution, codec, decoded/received fps, key frames, status), or in none | -| **Q** | The view: multi-view (Quad) or the single camera. Q does not change the playback mode (S) | -| **S** | The playback mode: every frame, or key frames only (the picture updates once per key frame interval, MJPEG once a second), the lowest decoder load. Works in multi-view and in the single view and never changes the view | -| **Settings** | Edit the URL and the transport of the active slot (single-camera mode only) | -| **WebUI** | Open the camera's web interface; credentials come from the RTSP URL (single-camera mode only) | +| **Settings** | Edit the URL and the transport of the active slot (single view only; grey elsewhere) | +| **WebUI** | Open the camera's web interface; credentials come from the RTSP URL (single view only; grey elsewhere) | +| **Grid** | The view: multi-view (2×2 or 3×3) or the single camera. Grid does not change the playback mode (Slides) | +| **Slides** | The playback mode: every frame, or key frames only (the picture updates once per key frame interval, MJPEG once a second), the lowest decoder load. Works in multi-view and in the single view and never changes the view | +| **Exit** | Close the app. The small text under it is the app version and build hash. | | **Reset** | Clear all settings after a confirmation (**Cancel** has the focus) to set the app up again from scratch: camera slots, transports and the other settings are removed, and the app opens as right after installing. The cameras are not changed | -| **Exit** | Close the app. The small text next to it is the app version and build hash. | +| **Debug** | Text in the corner of every multi-view cell (resolution, codec, decoded/received fps, key frames, status), or in none | +| **UDP** | The U cell full-screen: the camera that pushes RTP over UDP to port 5600 | -Items that are on — D, Q, S, U and the active slot — have an orange fill; the item the remote is on has a light blue frame. +Items that are on — Grid, Slides, Debug, UDP and the active slot — have an orange fill; the item the remote is on has a light blue frame. ### Gestures and keys @@ -95,7 +103,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ ### Multi-view (Quad) -Shows the configured slots in a 2×2 grid, or in a 3×3 grid once any of slots 5–8 is used. The ninth cell of the 3×3 grid, the **U** cell, plays a camera that pushes RTP over UDP to port 5600 of the device (`udp://@:5600`, see [udp:// slots](#udp-slots-majestic-outgoing)); until a stream arrives it shows every address of the device to send to. **U** in the menu opens it full-screen. Other ports or multicast groups go into a slot as a `udp://` URL. Each cell has its own connection and decoder, and the device's video decoder has a fixed budget: the AG-216 test box, for example, decodes about two 1080p30 streams at full rate. So the grid adapts: cells start on key frames only, then decode every frame in priority order (the U cell while it receives a stream, the cell whose sound plays, then slot order) while the decoder keeps up. Cells beyond that stay on key frames, with the picture updated once per key frame interval (`key frames` in the corner text, menu **D**). The limit is learnt on each device by measurement. Substreams of the cameras, where available, let more cells play at full rate. A key-frame cell over the hardware decoder's instance limit (the AG-216 has 4 × H.265) uses a software decoder instead (`· software` in the corner); only beyond that budget too does it show `Decoder limit: N × H.265`. When even key frames are too much, the lowest-priority cell is paused (`Paused: decoder overloaded`) and tried again after a quiet period; losses in the first seconds after the grid starts, when every decoder starts at once, do not count. A cell whose UDP stream loses a piece of every key frame shows the frames with the damage concealed (`· damaged`) rather than a black picture. Some cameras accept only one RTSP client at a time. Long-press a cell to play its audio (long-press it again to mute); the selected cell shows an AUDIO badge and is remembered. Tap any cell to open the menu. +Shows the configured slots in a 2×2 grid, or in a 3×3 grid once any of slots 5–8 is used. The ninth cell of the 3×3 grid, the **U** cell, plays a camera that pushes RTP over UDP to port 5600 of the device (`udp://@:5600`, see [udp:// slots](#udp-slots-majestic-outgoing)); until a stream arrives it shows every address of the device to send to. **UDP** in the menu opens it full-screen. Other ports or multicast groups go into a slot as a `udp://` URL. Each cell has its own connection and decoder, and the device's video decoder has a fixed budget: the AG-216 test box, for example, decodes about two 1080p30 streams at full rate. So the grid adapts: cells start on key frames only, then decode every frame in priority order (the U cell while it receives a stream, the cell whose sound plays, then slot order) while the decoder keeps up. Cells beyond that stay on key frames, with the picture updated once per key frame interval (`key frames` in the corner text, menu **Debug**). The limit is learnt on each device by measurement. Substreams of the cameras, where available, let more cells play at full rate. A key-frame cell over the hardware decoder's instance limit (the AG-216 has 4 × H.265) uses a software decoder instead (`· software` in the corner); only beyond that budget too does it show `Decoder limit: N × H.265`. When even key frames are too much, the lowest-priority cell is paused (`Paused: decoder overloaded`) and tried again after a quiet period; losses in the first seconds after the grid starts, when every decoder starts at once, do not count. A cell whose UDP stream loses a piece of every key frame shows the frames with the damage concealed (`· damaged`) rather than a black picture. Some cameras accept only one RTSP client at a time. Long-press a cell to play its audio (long-press it again to mute); the selected cell shows an AUDIO badge and is remembered. Tap any cell to open the menu. ## Transports diff --git a/README.ru.md b/README.ru.md index 8348a9be..e7c38a71 100644 --- a/README.ru.md +++ b/README.ru.md @@ -59,19 +59,27 @@ ### Меню +Меню — это сетка: восемь слотов сверху, ниже два ряда по четыре кнопки. + +``` +[ 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 ] +[ Settings | WebUI | Grid | Slides ] +[ Exit | Reset | Debug | UDP ] +``` + | Пункт | Действие | |--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **1**–**8** | Переключиться на слот камеры. Цвет номера активного слота показывает качество связи: зелёный — джиттер меньше 100 мс, жёлтый — 100–300 мс, красный — больше 300 мс. Серый номер — пустой слот. | -| **U** | Ячейка U на весь экран: камера, которая сама шлёт RTP по UDP на порт 5600 | -| **D** | Текст в углу каждой ячейки мультивида (разрешение, кодек, декодировано/получено кадр/с, ключевые кадры, статус) — или ни в одной | -| **Q** | Вид: мультивид (Quad) или одна камера. Q не меняет режим воспроизведения (S) | -| **S** | Режим воспроизведения: все кадры или только ключевые (картинка обновляется раз в интервал ключевых кадров, MJPEG — раз в секунду), минимальная нагрузка на декодер. Работает и в мультивиде, и в одиночном режиме, вид не меняет | -| **Settings** | Изменить адрес и транспорт активного слота (только в режиме одной камеры) | -| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) | +| **Settings** | Изменить адрес и транспорт активного слота (только в режиме одной камеры; в остальных — серая) | +| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры; в остальных — серая) | +| **Grid** | Вид: мультивид (2×2 или 3×3) или одна камера. Grid не меняет режим воспроизведения (Slides) | +| **Slides** | Режим воспроизведения: все кадры или только ключевые (картинка обновляется раз в интервал ключевых кадров, MJPEG — раз в секунду), минимальная нагрузка на декодер. Работает и в мультивиде, и в одиночном режиме, вид не меняет | +| **Exit** | Закрыть приложение. Мелкий текст под ним — версия приложения и хеш сборки. | | **Reset** | Очистить все настройки после подтверждения (фокус на **Cancel**), чтобы настроить приложение с чистого листа: удаляются слоты камер, транспорты и остальные настройки, приложение открывается как сразу после установки. Сами камеры не меняются | -| **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. | +| **Debug** | Текст в углу каждой ячейки мультивида (разрешение, кодек, декодировано/получено кадр/с, ключевые кадры, состояние) или ни в одной | +| **UDP** | Ячейка U на весь экран: камера, которая сама шлёт RTP по UDP на порт 5600 | -Включённые пункты — D, Q, S, U и активный слот — залиты оранжевым; пункт, на котором стоит пульт, обведён голубой рамкой. +Включённые пункты — Grid, Slides, Debug, UDP и активный слот — залиты оранжевым; пункт, на котором стоит пульт, обведён голубой рамкой. ### Жесты и кнопки @@ -95,7 +103,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ ### Мультивид (Quad) -Показывает настроенные слоты сеткой 2×2, а если занят хотя бы один из слотов 5–8 — сеткой 3×3. Девятая ячейка сетки 3×3, ячейка **U**, показывает камеру, которая сама отправляет RTP по UDP на порт 5600 устройства (`udp://@:5600`, см. [слоты udp://](#слоты-udp-majestic-outgoing)); пока поток не пришёл, в ней перечислены все адреса устройства, куда его слать. Кнопка **U** в меню открывает её на весь экран. Другие порты и multicast-группы указываются в слоте адресом `udp://`. У каждой ячейки своё подключение и свой декодер, а у видеодекодера устройства постоянный бюджет: тестовая приставка AG-216, например, декодирует в полном темпе примерно два потока 1080p30. Поэтому сетка подстраивается: ячейки стартуют на ключевых кадрах, затем по приоритету (ячейка U, пока в неё идёт поток, ячейка со звуком, потом по номеру слота) переходят на все кадры, пока декодер успевает. Остальные остаются на ключевых кадрах — картинка обновляется раз в интервал между ключевыми кадрами (в тексте в углу — `key frames`, меню **D**). Предел определяется замером на каждом устройстве. Дополнительные потоки камер (substream), если они есть, позволяют показать в полном темпе больше ячеек. Ячейка на ключевых кадрах сверх лимита экземпляров аппаратного декодера (у AG-216 — 4 × H.265) работает на программном декодере (`· software` в углу); только сверх и этого бюджета она показывает `Decoder limit: N × H.265`. Если не справляются даже ключевые кадры, ячейка с наименьшим приоритетом ставится на паузу (`Paused: decoder overloaded`) и после спокойного периода пробуется снова; потери в первые секунды после запуска сетки, когда все декодеры стартуют разом, не учитываются. Ячейка, у которой поток UDP теряет кусок каждого ключевого кадра, показывает кадры с замаскированными повреждениями (`· damaged`), а не чёрный экран. Некоторые камеры допускают только одно RTSP-подключение одновременно. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню. +Показывает настроенные слоты сеткой 2×2, а если занят хотя бы один из слотов 5–8 — сеткой 3×3. Девятая ячейка сетки 3×3, ячейка **U**, показывает камеру, которая сама отправляет RTP по UDP на порт 5600 устройства (`udp://@:5600`, см. [слоты udp://](#слоты-udp-majestic-outgoing)); пока поток не пришёл, в ней перечислены все адреса устройства, куда его слать. Кнопка **UDP** в меню открывает её на весь экран. Другие порты и multicast-группы указываются в слоте адресом `udp://`. У каждой ячейки своё подключение и свой декодер, а у видеодекодера устройства постоянный бюджет: тестовая приставка AG-216, например, декодирует в полном темпе примерно два потока 1080p30. Поэтому сетка подстраивается: ячейки стартуют на ключевых кадрах, затем по приоритету (ячейка U, пока в неё идёт поток, ячейка со звуком, потом по номеру слота) переходят на все кадры, пока декодер успевает. Остальные остаются на ключевых кадрах — картинка обновляется раз в интервал между ключевыми кадрами (в тексте в углу — `key frames`, меню **Debug**). Предел определяется замером на каждом устройстве. Дополнительные потоки камер (substream), если они есть, позволяют показать в полном темпе больше ячеек. Ячейка на ключевых кадрах сверх лимита экземпляров аппаратного декодера (у AG-216 — 4 × H.265) работает на программном декодере (`· software` в углу); только сверх и этого бюджета она показывает `Decoder limit: N × H.265`. Если не справляются даже ключевые кадры, ячейка с наименьшим приоритетом ставится на паузу (`Paused: decoder overloaded`) и после спокойного периода пробуется снова; потери в первые секунды после запуска сетки, когда все декодеры стартуют разом, не учитываются. Ячейка, у которой поток UDP теряет кусок каждого ключевого кадра, показывает кадры с замаскированными повреждениями (`· damaged`), а не чёрный экран. Некоторые камеры допускают только одно RTSP-подключение одновременно. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню. ## Транспорты diff --git a/README.zh-CN.md b/README.zh-CN.md index 0178ccf6..77be65c4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,19 +59,27 @@ ### 菜单 +菜单是一个网格:上方为八个槽位,下方两行各四个按钮。 + +``` +[ 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 ] +[ Settings | WebUI | Grid | Slides ] +[ Exit | Reset | Debug | UDP ] +``` + | 菜单项 | 功能 | |--------------|-----------------------------------------------------------------------------------------------------------------------------------------------| | **1**–**8** | 切换摄像机槽位。当前槽位编号的颜色表示连接质量:绿色为抖动低于 100 毫秒,黄色为 100–300 毫秒,红色为高于 300 毫秒。灰色编号表示该槽位未配置。 | -| **U** | 全屏显示 U 画面:通过 UDP 向 5600 端口推送 RTP 的摄像机 | -| **D** | 在每个多画面格子的角落显示文字(分辨率、编码、已解码/已接收帧率、关键帧、状态),或全部不显示 | -| **Q** | 视图:多画面(Quad)或单路画面。Q 不改变播放模式(S) | -| **S** | 播放模式:全部帧或仅关键帧(画面按关键帧间隔更新,MJPEG 每秒一帧),解码器负载最低。多画面和单画面均适用,不切换视图 | -| **Settings** | 编辑当前槽位的地址和传输方式(仅单路模式) | -| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) | +| **Settings** | 编辑当前槽位的地址和传输方式(仅单路模式;其他模式下为灰色) | +| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式;其他模式下为灰色) | +| **Grid** | 视图:多画面(2×2 或 3×3)或单路画面。Grid 不改变播放模式(Slides) | +| **Slides** | 播放模式:全部帧或仅关键帧(画面按关键帧间隔更新,MJPEG 每秒一帧),解码器负载最低。多画面和单画面均适用,不切换视图 | +| **Exit** | 退出应用。下方的小字是应用版本号和构建哈希。 | | **Reset** | 确认后清除所有设置(默认焦点在 **Cancel**),以便从头重新配置:删除摄像机槽位、传输方式和其他设置,应用像刚安装后一样打开。摄像机本身不受影响 | -| **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 | +| **Debug** | 在每个多画面格子的角落显示文字(分辨率、编码、已解码/已接收帧率、关键帧、状态),或全部不显示 | +| **UDP** | 全屏显示 U 画面:通过 UDP 向 5600 端口推送 RTP 的摄像机 | -已开启的项目(D、Q、S、U 和当前槽位)以橙色填充;遥控器焦点所在的项目带浅蓝色边框。 +已开启的项目(Grid、Slides、Debug、UDP 和当前槽位)以橙色填充;遥控器焦点所在的项目带浅蓝色边框。 ### 手势与按键 @@ -95,7 +103,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ ### 多画面模式(Quad) -以 2×2 网格显示已配置的槽位;只要使用了第 5–8 个槽位中的任意一个,就改为 3×3 网格。3×3 网格的第九格即 **U** 画面,播放通过 UDP 向本设备 5600 端口主动推送 RTP 的摄像机(`udp://@:5600`,参见 [udp:// 槽位](#udp-槽位-majestic-outgoing));收到流之前,其中列出本设备可用于推流的所有地址。菜单中的 **U** 可将其全屏显示。其他端口或组播组请在槽位中填写 `udp://` 地址。每个画面都有独立的连接和解码器,而设备视频解码器的处理能力是固定的:例如测试用的 AG-216 盒子以全帧率大约只能解码两路 1080p30。因此网格会自适应:各画面先只解码关键帧,然后按优先级(正在接收流的 U 画面,其次是正在播放声音的画面,再按槽位顺序)在解码器跟得上时切换为全帧率。其余画面保持只解码关键帧,画面按关键帧间隔更新(角落文字显示 `key frames`,菜单 **D**)。上限在每台设备上通过实测确定。如摄像机提供子码流,可让更多画面以全帧率播放。超出硬件解码器实例上限(AG-216 为 4 × H.265)的仅关键帧画面改用软件解码器(角落显示 `· software`);只有连这一预算也超出时才显示 `Decoder limit: N × H.265`。如果连关键帧都处理不过来,优先级最低的画面会被暂停(`Paused: decoder overloaded`),平稳一段时间后再重试;网格启动后最初几秒(所有解码器同时启动)的丢帧不计入。若某画面的 UDP 流每个关键帧都丢失一部分,会显示掩盖了损坏的画面(`· damaged`),而不是黑屏。部分摄像机同一时间只允许一个 RTSP 客户端。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。 +以 2×2 网格显示已配置的槽位;只要使用了第 5–8 个槽位中的任意一个,就改为 3×3 网格。3×3 网格的第九格即 **U** 画面,播放通过 UDP 向本设备 5600 端口主动推送 RTP 的摄像机(`udp://@:5600`,参见 [udp:// 槽位](#udp-槽位-majestic-outgoing));收到流之前,其中列出本设备可用于推流的所有地址。菜单中的 **UDP** 可将其全屏显示。其他端口或组播组请在槽位中填写 `udp://` 地址。每个画面都有独立的连接和解码器,而设备视频解码器的处理能力是固定的:例如测试用的 AG-216 盒子以全帧率大约只能解码两路 1080p30。因此网格会自适应:各画面先只解码关键帧,然后按优先级(正在接收流的 U 画面,其次是正在播放声音的画面,再按槽位顺序)在解码器跟得上时切换为全帧率。其余画面保持只解码关键帧,画面按关键帧间隔更新(角落文字显示 `key frames`,菜单 **Debug**)。上限在每台设备上通过实测确定。如摄像机提供子码流,可让更多画面以全帧率播放。超出硬件解码器实例上限(AG-216 为 4 × H.265)的仅关键帧画面改用软件解码器(角落显示 `· software`);只有连这一预算也超出时才显示 `Decoder limit: N × H.265`。如果连关键帧都处理不过来,优先级最低的画面会被暂停(`Paused: decoder overloaded`),平稳一段时间后再重试;网格启动后最初几秒(所有解码器同时启动)的丢帧不计入。若某画面的 UDP 流每个关键帧都丢失一部分,会显示掩盖了损坏的画面(`· damaged`),而不是黑屏。部分摄像机同一时间只允许一个 RTSP 客户端。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。 ## 传输方式 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 ce14d811..b7e852f7 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -16,9 +16,7 @@ import android.os.Build; import android.text.InputType; import android.text.SpannableString; -import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; -import android.text.style.SuperscriptSpan; import android.util.Log; import android.util.TypedValue; import android.view.Gravity; @@ -30,8 +28,6 @@ import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.HorizontalScrollView; import android.widget.LinearLayout; import android.widget.PopupWindow; import android.widget.RadioButton; @@ -64,13 +60,9 @@ class MenuManager { /** The UDP RTP view is shown: neither a slot nor multi-view is active. */ private boolean udpShown; private boolean mMenuIsCached; - /** The item a remote starts from: the active slot, Q or U (#91). */ + /** The item a remote starts from: the active slot, Grid or UDP (#91, #177). */ private TextView mFocusItem; private TextView mSettingsBtn; - /** Settings | WebUI, shown in the single view only (#173). */ - private LinearLayout mSettingsRow; - /** Left items of the two-item rows, aligned with the slot row's middle (#173). */ - private final java.util.List mPairLefts = new java.util.ArrayList<>(); private TextView mWebUiBtn; TextView[] camButtons; private Dialog mBrowserDialog; @@ -184,35 +176,14 @@ public boolean dispatchKeyEvent(KeyEvent event) { mMenuPopup = popup; popup.setOnDismissListener(() -> mMenuPopup = null); - LinearLayout camRow = new LinearLayout(activity); - camRow.setOrientation(LinearLayout.HORIZONTAL); - // Not inside the layout pass: changing a size there would be lost or loop - camRow.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> - camRow.post(() -> alignPairs(camRow))); - // 8 slots + U D Q (#13, #93) may not fit a narrow screen or a large font: the row scrolls, - // and follows the focus when moving with a remote - HorizontalScrollView camScroll = new HorizontalScrollView(activity); - camScroll.setHorizontalScrollBarEnabled(false); - camScroll.addView(camRow, new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT)); - layout.addView(camScroll, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - - mSettingsBtn = createItem("Settings"); - - final TextView quadBtn = createItem("Q"); - quadBtn.setGravity(Gravity.CENTER); - quadBtn.setPadding(dp(12), dp(8), dp(12), dp(8)); - - mWebUiBtn = createItem("WebUI"); - // Two items per row keep the menu short (#173); the row is for the single view only - mSettingsRow = pairRow(mSettingsBtn, mWebUiBtn); - layout.addView(mSettingsRow, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - mSettingsRow.setVisibility(single ? View.VISIBLE : View.GONE); + // One grid (#177): the 8 slots on top, two rows of four words below, each word cell + // exactly two slots wide. Every cell has the width of the widest word, so the columns + // line up and nothing is clipped + int cell = wordCellWidth(); + int half = (cell + 1) / 2; + cell = half * 2; + LinearLayout camRow = gridRow(layout); camButtons = new TextView[CAM_COUNT]; mCachedCamButtons = new TextView[CAM_COUNT]; for (int i = 0; i < CAM_COUNT; i++) { @@ -220,18 +191,11 @@ public boolean dispatchKeyEvent(KeyEvent event) { camButtons[i] = createItem(String.valueOf(i + 1)); mCachedCamButtons[i] = camButtons[i]; camButtons[i].setGravity(Gravity.CENTER); - camButtons[i].setPadding(dp(12), dp(8), dp(12), dp(8)); - camRow.addView(camButtons[i], new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - - if (i == mActive && single) { - highlightItem(camButtons[i]); - applyQualityColor(camButtons[i], i, !single, mActive, mHosts); - } else { - applyQualityColor(camButtons[i], i, !single, mActive, mHosts); - } - + camButtons[i].setPadding(0, dp(8), 0, dp(8)); + camRow.addView(camButtons[i], new LinearLayout.LayoutParams(half, + LinearLayout.LayoutParams.MATCH_PARENT)); + if (i == mActive && single) highlightItem(camButtons[i]); + applyQualityColor(camButtons[i], i, !single, mActive, mHosts); camButtons[i].setOnClickListener(v -> { if (listener != null) listener.onSwitchCamera(slot); PopupWindow p = mMenuPopup; @@ -239,41 +203,12 @@ public boolean dispatchKeyEvent(KeyEvent event) { }); } - // U after the slots: the reserved UDP RTP cell of the 3x3 grid, full-screen (#68) - final TextView udpBtn = createItem("U"); - udpBtn.setGravity(Gravity.CENTER); - udpBtn.setPadding(dp(12), dp(8), dp(12), dp(8)); - camRow.addView(udpBtn, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - if (udpShown) highlightItem(udpBtn); - mCachedUdpBtn = udpBtn; - udpBtn.setOnClickListener(v -> { - PopupWindow p = mMenuPopup; - if (p != null) p.dismiss(); - if (listener != null) listener.onShowUdp(); - }); - - // D after U: the debug text in the multi-view cells, highlighted while on - final TextView statsBtn = createItem("D"); - statsBtn.setGravity(Gravity.CENTER); - statsBtn.setPadding(dp(12), dp(8), dp(12), dp(8)); - camRow.addView(statsBtn, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - if (statsShown) highlightItem(statsBtn); - mCachedStatsBtn = statsBtn; - statsBtn.setOnClickListener(v -> { - PopupWindow p = mMenuPopup; - if (p != null) p.dismiss(); - if (listener != null) listener.onToggleStats(); - }); - - // Q, then S last: 1 2 3 4 5 6 7 8 U D Q S (#93, #108) - camRow.addView(quadBtn, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - // Q is the view, S the playback mode: both may be on (#153) + // Settings | WebUI | Grid | Slides + LinearLayout modeRow = gridRow(layout); + mSettingsBtn = wordItem(modeRow, "Settings", cell); + mWebUiBtn = wordItem(modeRow, "WebUI", cell); + // Grid is the view, Slides the playback mode: both may be on (#153) + final TextView quadBtn = wordItem(modeRow, "Grid", cell); if (quadEnabled) highlightItem(quadBtn); mCachedQuadBtn = quadBtn; quadBtn.setOnClickListener(v -> { @@ -281,13 +216,8 @@ public boolean dispatchKeyEvent(KeyEvent event) { if (p != null) p.dismiss(); if (listener != null) listener.onToggleQuad(); }); - - final TextView slidesBtn = createItem("S"); - slidesBtn.setGravity(Gravity.CENTER); - slidesBtn.setPadding(dp(12), dp(8), dp(12), dp(8)); - camRow.addView(slidesBtn, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); + // Slides: every cell on key frames only (#108) + final TextView slidesBtn = wordItem(modeRow, "Slides", cell); if (slidesShown) highlightItem(slidesBtn); mCachedSlidesBtn = slidesBtn; slidesBtn.setOnClickListener(v -> { @@ -296,37 +226,42 @@ public boolean dispatchKeyEvent(KeyEvent event) { if (listener != null) listener.onToggleSlides(); }); - // "Exit" centred in its half (#176): the small version text follows it, and an - // invisible copy of it in front balances the line, so the word itself is in the middle - String ver = "[v" + version + ", " + gitHash + "]"; - String code = ver + " Exit " + ver; - int exitEnd = ver.length() + 5; - SpannableString s = new SpannableString(code); - s.setSpan(new SuperscriptSpan(), 0, ver.length(), 0); - s.setSpan(new RelativeSizeSpan(0.5f), 0, ver.length(), 0); - s.setSpan(new ForegroundColorSpan(Color.TRANSPARENT), 0, ver.length(), 0); - s.setSpan(new SuperscriptSpan(), exitEnd, s.length(), 0); - s.setSpan(new RelativeSizeSpan(0.5f), exitEnd, s.length(), 0); - - View divider = new View(activity); - divider.setBackgroundColor(Color.DKGRAY); - layout.addView(divider, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, dp(1))); - + // Exit | Reset | Debug | UDP + LinearLayout actionRow = gridRow(layout); + // The version under Exit, small (#176): beside it, it would widen every cell + String ver = "v" + version + ", " + gitHash; + SpannableString s = new SpannableString("Exit\n" + ver); + s.setSpan(new RelativeSizeSpan(0.5f), 5, s.length(), 0); + TextView exit = wordItem(actionRow, "Exit", cell); + exit.setSingleLine(false); + exit.setText(s); + exit.setOnClickListener(v -> { + if (listener != null) listener.onExit(); + }); // Two deliberate presses: Reset, then Clear settings in the confirmation (#171) - TextView reset = createItem("Reset"); + TextView reset = wordItem(actionRow, "Reset", cell); reset.setOnClickListener(v -> { PopupWindow p = mMenuPopup; if (p != null) p.dismiss(); confirmReset(); }); - - TextView exit = createItem("Exit"); - exit.setText(s); - layout.addView(pairRow(exit, reset), new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - exit.setOnClickListener(v -> { - if (listener != null) listener.onExit(); + // Debug: the text in the multi-view cells, highlighted while on + final TextView statsBtn = wordItem(actionRow, "Debug", cell); + if (statsShown) highlightItem(statsBtn); + mCachedStatsBtn = statsBtn; + statsBtn.setOnClickListener(v -> { + PopupWindow p = mMenuPopup; + if (p != null) p.dismiss(); + if (listener != null) listener.onToggleStats(); + }); + // UDP: the reserved UDP RTP cell full screen (#68, #87) + final TextView udpBtn = wordItem(actionRow, "UDP", cell); + if (udpShown) highlightItem(udpBtn); + mCachedUdpBtn = udpBtn; + udpBtn.setOnClickListener(v -> { + PopupWindow p = mMenuPopup; + if (p != null) p.dismiss(); + if (listener != null) listener.onShowUdp(); }); mSettingsBtn.setOnClickListener(v -> { @@ -334,12 +269,15 @@ public boolean dispatchKeyEvent(KeyEvent event) { if (p != null) p.dismiss(); if (listener != null) listener.onShowSettings(); }); - mWebUiBtn.setOnClickListener(v -> { PopupWindow p = mMenuPopup; if (p != null) p.dismiss(); if (listener != null) listener.onWebUI(); }); + setSingleViewItems(single); + linkRemote(camButtons, + new TextView[]{mSettingsBtn, mWebUiBtn, quadBtn, slidesBtn}, + new TextView[]{exit, reset, statsBtn, udpBtn}); mMenuLayout = layout; mMenuIsCached = true; @@ -349,9 +287,7 @@ public boolean dispatchKeyEvent(KeyEvent event) { private void updateCachedMenu(boolean quadEnabled, int mActive, String[] mHosts) { boolean single = !quadEnabled && !udpShown; - if (mSettingsRow != null) { - mSettingsRow.setVisibility(single ? View.VISIBLE : View.GONE); - } + setSingleViewItems(single); if (mCachedQuadBtn != null) { if (quadEnabled) highlightItem(mCachedQuadBtn); else resetItem(mCachedQuadBtn); @@ -417,53 +353,68 @@ static boolean isNavigationKey(int keyCode) { } } - /** - * Two menu items side by side, split by a thin line (#173), which {@link #alignPairs} puts - * under the gap between slots 6 and 7 of the row above. Left / Right move between them - * with a remote. - */ - private LinearLayout pairRow(TextView left, TextView right) { + /** A row of the menu grid (#177), as wide as the menu. */ + private LinearLayout gridRow(LinearLayout menu) { LinearLayout row = new LinearLayout(activity); row.setOrientation(LinearLayout.HORIZONTAL); - row.addView(left, new LinearLayout.LayoutParams(0, - LinearLayout.LayoutParams.WRAP_CONTENT, 1)); - mPairLefts.add(left); - View line = new View(activity); - line.setBackgroundColor(Color.DKGRAY); - row.addView(line, new LinearLayout.LayoutParams(dp(1), - LinearLayout.LayoutParams.MATCH_PARENT)); - row.addView(right, new LinearLayout.LayoutParams(0, - LinearLayout.LayoutParams.WRAP_CONTENT, 1)); - left.setSingleLine(true); - right.setSingleLine(true); - // Labels in the middle of their halves (#176) - left.setGravity(Gravity.CENTER); - right.setGravity(Gravity.CENTER); + menu.addView(row, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); return row; } + /** A word cell of the menu grid: one width for all, the label centred (#176, #177). */ + private TextView wordItem(LinearLayout row, String title, int width) { + TextView item = createItem(title); + item.setGravity(Gravity.CENTER); + item.setSingleLine(true); + item.setPadding(0, dp(8), 0, dp(8)); + row.addView(item, new LinearLayout.LayoutParams(width, + LinearLayout.LayoutParams.MATCH_PARENT)); + return item; + } + + /** Width of a word cell: the widest label of the lower rows, or two slots if wider. */ + private int wordCellWidth() { + android.text.TextPaint paint = new android.text.TextPaint(); + paint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 18, + activity.getResources().getDisplayMetrics())); + float widest = 0; + for (String w : new String[]{"Settings", "WebUI", "Grid", "Slides", + "Exit", "Reset", "Debug", "UDP"}) { + widest = Math.max(widest, paint.measureText(w)); + } + int slot = (int) Math.ceil(paint.measureText("8")) + 2 * dp(12); + return Math.max((int) Math.ceil(widest) + 2 * dp(10), 2 * slot); + } + /** - * The left item of every two-item row ends where slot 7 begins, so the dividing lines run - * on from the gap between slots 6 and 7: the slot buttons differ in width, half the row - * is not that gap. Until the slot row is laid out, and while it is too wide for the menu, - * both halves stay equal. + * Settings and WebUI work in the single view only. Elsewhere they stay in place, grey and + * skipped by the remote, so the grid does not change shape (#177). */ - private void alignPairs(LinearLayout camRow) { - if (camButtons == null || camButtons.length < 7 || camButtons[6] == null) return; - int gap = (camButtons[5].getRight() + camButtons[6].getLeft()) / 2; - int width = gap - dp(1) / 2; - // A slot row wider than the menu scrolls, and its gap moves with it: then plain halves, - // which keep both items whole (Qodo on #175) - View scroll = (View) camRow.getParent(); - boolean fits = scroll != null && camRow.getWidth() <= scroll.getWidth(); - for (TextView left : mPairLefts) { - LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) left.getLayoutParams(); - int w = fits && width > 0 ? width : 0; - float weight = fits && width > 0 ? 0 : 1; - if (lp.width == w && lp.weight == weight) continue; - lp.width = w; - lp.weight = weight; - left.setLayoutParams(lp); + private void setSingleViewItems(boolean single) { + for (TextView item : new TextView[]{mSettingsBtn, mWebUiBtn}) { + if (item == null) continue; + item.setEnabled(single); + item.setFocusable(single); + item.setTextColor(single ? Color.WHITE : 0xFF555555); + } + } + + /** + * Remote moves in the grid (#177): Down from a slot to the word cell under it, Up from a + * word cell to the left one of its two slots; a grey cell passes the move on. + */ + private static void linkRemote(TextView[] slots, TextView[] mid, TextView[] low) { + for (TextView v : slots) v.setId(View.generateViewId()); + for (TextView v : mid) v.setId(View.generateViewId()); + for (TextView v : low) v.setId(View.generateViewId()); + for (int i = 0; i < slots.length; i++) { + slots[i].setNextFocusDownId(mid[Math.min(i / 2, mid.length - 1)].getId()); + } + for (int j = 0; j < mid.length; j++) { + mid[j].setNextFocusUpId(slots[Math.min(2 * j, slots.length - 1)].getId()); + mid[j].setNextFocusDownId(low[j].getId()); + low[j].setNextFocusUpId(mid[j].getId()); } } From bc406f1b7b3fb1f48239389f83b480df61d2eea2 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 21:04:11 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20Qodo=20on=20#178=20=E2=80=94=20the?= =?UTF-8?q?=20menu=20grid=20never=20wider=20than=20the=20screen=20(labels?= =?UTF-8?q?=20ellipsized),=20LABORATORY:=20the=20grid=20menu=20from=202.06?= =?UTF-8?q?,=20remote=20check=20for=20the=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LABORATORY.md | 6 +++--- .../src/main/java/com/openipc/decoder/MenuManager.java | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/LABORATORY.md b/LABORATORY.md index 69191807..24093aec 100644 --- a/LABORATORY.md +++ b/LABORATORY.md @@ -259,7 +259,7 @@ The link goes into the active slot and is saved. The transport extra exists from - Transport in the UI: menu → URL dialog of the slot → **TCP / UDP / Auto / Multicast** under the URL. - udp:// slots (#72) are a URL, not a transport: `udp://@:5600`, `udp://@239.1.2.3:5600`, `udp://@239.1.2.3:5600`, optional `?codec=h265&pt=97`. - The share intent writes into the **active** slot (and switches to the single view): select the slot first. -- Filling slots on the box: tap a menu button by its text rather than by coordinates (from 2.07 the menu is a grid: slots `1`–`8` on top, then `Settings WebUI Grid Slides`, then `Exit Reset Debug UDP`, #177): `uiautomator dump`, take the `bounds` of `text="N"`, `input tap` its centre; the URL dialog of a slot: **Settings** (single view only) → `TCP` / `UDP` / `Auto` / `Multicast` → `SAVE`. +- Filling slots on the box: tap a menu button by its text rather than by coordinates (from 2.06 the menu is a grid: slots `1`–`8` on top, then `Settings WebUI Grid Slides`, then `Exit Reset Debug UDP`, #177): `uiautomator dump`, take the `bounds` of `text="N"`, `input tap` its centre; the URL dialog of a slot: **Settings** (single view only) → `TCP` / `UDP` / `Auto` / `Multicast` → `SAVE`. ## Methods @@ -304,9 +304,9 @@ The script uninstalls first. To test an upgrade that keeps the settings, use `$A | UDP socket leak | `$ADB shell cat /proc/$(pidof com.openipc.decoder)/net/udp`, count lines before and after | | CPU per thread | `$ADB shell top -H -p ` (a spinning `rtsp-audio` thread was found this way) | | Screen off / on | `input keyevent KEYCODE_SLEEP` / `KEYCODE_WAKEUP` | -| Grid ↔ single stress (#76) | loop: menu → tap `4` (grid → single), 4 s, menu → tap `Grid` (single → grid; `Q` before 2.07), 8 s; afterwards count `Davey! duration=` ≥ 1000 ms and `ANR in` in logcat; 8–10 cycles reproduce #76 today | +| Grid ↔ single stress (#76) | loop: menu → tap `4` (grid → single), 4 s, menu → tap `Grid` (single → grid; `Q` before 2.06), 8 s; afterwards count `Davey! duration=` ≥ 1000 ms and `ANR in` in logcat; 8–10 cycles reproduce #76 today | | Transport in use | `/stats.json`: `transport` (in use, e.g. `TCP (auto)` after a fallback) and `transport_mode` (setting) per cell; `nal_dropped` = NAL units lost to packet loss | -| Menu with a remote (#91) | `input keyevent KEYCODE_DPAD_CENTER` opens the menu with the focus on the active item (slot, Q or U); `DPAD_RIGHT` ×n, `DPAD_DOWN` (Exit), `DPAD_UP`, then `DPAD_CENTER` must switch to that slot; `KEYCODE_BACK` closes it. The focused item: `uiautomator dump /sdcard/m.xml` and look for `focused="true"`. Check in single view and multi-view | +| Menu with a remote (#91) | `input keyevent KEYCODE_DPAD_CENTER` opens the menu with the focus on the active item (slot, Grid or UDP; Q or U before 2.06); `DPAD_RIGHT` ×n along the slots, `DPAD_DOWN` to the word cell under the slot (Settings, WebUI, Grid or Slides; a grey one passes on to the row below), `DPAD_UP` back to the left slot above it, then `DPAD_CENTER` must switch to that slot; `KEYCODE_BACK` closes it. The focused item: `uiautomator dump /sdcard/m.xml` and look for `focused="true"`. Check in single view and multi-view | | 4:3 screen | `wm size 960x720`, back with `wm size reset` | | Audio playback (sound cannot be heard remotely) | `dumpsys media.audio_flinger`: server position of the app's track; frames/s ≈ sample rate, no underruns | | Screenshot | `$ADB exec-out screencap -p > shot.png`; long-press screenshot in the app saves to `Pictures/OpenIPC/` | 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 b7e852f7..413b74db 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -180,7 +180,11 @@ public boolean dispatchKeyEvent(KeyEvent event) { // exactly two slots wide. Every cell has the width of the widest word, so the columns // line up and nothing is clipped int cell = wordCellWidth(); - int half = (cell + 1) / 2; + // Never wider than the screen allows (4 cells and the popup's margins): a large font + // or a narrow screen shortens the labels with an ellipsis instead (Qodo on #178) + int avail = activity.getResources().getDisplayMetrics().widthPixels - 2 * dp(12); + if (avail > 0) cell = Math.min(cell, avail / 4); + int half = cell / 2; cell = half * 2; LinearLayout camRow = gridRow(layout); @@ -367,7 +371,8 @@ private TextView wordItem(LinearLayout row, String title, int width) { TextView item = createItem(title); item.setGravity(Gravity.CENTER); item.setSingleLine(true); - item.setPadding(0, dp(8), 0, dp(8)); + item.setEllipsize(android.text.TextUtils.TruncateAt.END); + item.setPadding(dp(2), dp(8), dp(2), dp(8)); row.addView(item, new LinearLayout.LayoutParams(width, LinearLayout.LayoutParams.MATCH_PARENT)); return item; From 006c1d46adcf06d2bacbadddf72693dc25731daa Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 21:04:11 +0000 Subject: [PATCH 6/6] chore: version 2.06 --- 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 00cccadb..d9b3437b 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 30 - versionName "2.05" + versionCode 31 + versionName "2.06" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" }