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/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()}\"" } 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..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; @@ -68,6 +69,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 +186,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); @@ -290,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); @@ -405,14 +418,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), @@ -421,9 +436,37 @@ 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; } + /** + * 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. + */ + 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 TextView createItem(String title) { TextView text = new TextView(activity); text.setText(title);