diff --git a/README.md b/README.md index 4498ae86..2a87e26a 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ outgoing: While the app runs it serves its debug information over HTTP on port **8085**, for a browser or `curl` on the same network: - `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; -- `http://:8085/snapshot.jpg` (`?labels=0`: frames only) — a small picture laid out like the screen, 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/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/stats.json` — the same as JSON, for scripts; - `http://:8085/log` — the last 500 lines of the app's log. diff --git a/README.ru.md b/README.ru.md index 0726a284..cf794070 100644 --- a/README.ru.md +++ b/README.ru.md @@ -129,7 +129,7 @@ outgoing: Пока приложение работает, оно отдаёт отладочную информацию по HTTP на порту **8085** — для браузера или `curl` из той же сети: - `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм; -- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — маленькая картинка с раскладкой экрана из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна; +- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна; - `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов; - `http://<приставка>:8085/log` — последние 500 строк лога приложения. diff --git a/README.zh-CN.md b/README.zh-CN.md index fe1e841f..c1eab06f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -129,7 +129,7 @@ outgoing: 应用运行时会在 **8085** 端口通过 HTTP 提供调试信息,可在同一网络中用浏览器或 `curl` 访问: - `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长; -- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的小图,取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次; +- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次; - `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用; - `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 9de77731..011b5c28 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -394,98 +394,142 @@ private static void closeQuietly(Socket s) { // ====================== Page ====================== /** - * Static page: a header, the links, and the snapshot filling the rest with the text of - * each cell drawn over it. It fetches /stats.json every 1.5 s and the snapshot (without - * labels) every 3, 5 or 10 s, both only while the tab is visible (#98). + * Static page: a one-line header (state, version, device time, links, snapshot interval) + * and the snapshot filling the rest with the text of each cell drawn over it; on a narrow + * screen the snapshot takes the full width and the cells are listed below it (#103). It + * fetches /stats.json every 1.5 s and the snapshot (without labels) every 3, 5 or 10 s, + * both only while the tab is visible (#98). */ private static final String PAGE = "\n" - + "" - + "" - + "OpenIPC Decoder" - + "

OpenIPC Decoder" - + "

" - + "" - + "
\"\"" - + "
" + + "
" + + "
OpenIPC Decoder" + + "
" + + "
" + + "" + + "
" + + "
\"\"
" + + "
" + "\n"; 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 19946ebd..fb31c8be 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -64,6 +64,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public class Decoder extends Activity @@ -953,28 +954,53 @@ private void removeQuadOverlay(int index) { /** Called on a server thread (#98): the cells are chosen on the UI thread, copied here. */ private byte[] debugSnapshotJpeg(boolean labels) { AtomicReference> ref = new AtomicReference<>(); - int[] side = new int[1]; + AtomicBoolean abandoned = new AtomicBoolean(); + int[] side = new int[2]; CountDownLatch done = new CountDownLatch(1); - new Handler(Looper.getMainLooper()).post(() -> { + Handler main = new Handler(Looper.getMainLooper()); + Runnable pick = () -> { try { + // The request may have given up already: then no capture at all (#103) + if (abandoned.get()) return; ref.set(snapshotCells(side)); + if (abandoned.get()) recycleFrames(ref.getAndSet(null)); } finally { done.countDown(); } - }); + }; + main.post(pick); + boolean inTime = false; try { - if (!done.await(DEBUG_UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return null; + inTime = done.await(DEBUG_UI_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + } + if (!inTime) { + abandoned.set(true); + main.removeCallbacks(pick); + recycleFrames(ref.getAndSet(null)); return null; } List cells = ref.get(); - return cells != null ? Snapshots.render(cells, side[0], labels) : null; + return cells != null ? Snapshots.render(cells, side[0], side[1], labels) : null; + } + + /** Frames taken for a snapshot that will not be drawn. */ + private static void recycleFrames(List cells) { + if (cells == null) return; + for (Snapshots.Cell c : cells) { + if (c.frame != null) c.frame.recycle(); + } } - /** What to draw, UI thread only; side[0] gets the grid side (1 for a single picture). */ + /** + * What to draw, UI thread only; side[0] gets the grid side (1 for a single picture), + * side[1] the width of the picture. + */ private List snapshotCells(int[] side) { List cells = new ArrayList<>(); + int width = Snapshots.width(getResources().getDisplayMetrics().widthPixels); + side[1] = width; if (!mResumed || udpViewShown) { Snapshots.Cell c = new Snapshots.Cell(); c.label = !mResumed ? "App in background" : "U \u00b7 UDP RTP port " + UDP_RTP_PORT; @@ -990,7 +1016,7 @@ private List snapshotCells(int[] side) { c.label = statusText != null && statusText.getVisibility() == View.VISIBLE ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; if (c.live) { - c.frame = mSurface.getBitmap(Snapshots.CELL_W * 2, Snapshots.CELL_H * 2); + c.frame = mSurface.getBitmap(width, Snapshots.height(width)); } cells.add(c); side[0] = 1; diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index db49892b..003f44ce 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -1,7 +1,7 @@ /* * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * - * Snapshots.java — a small JPEG of what the screen shows, for the debug page (#98) + * Snapshots.java — a JPEG of what the screen shows, for the debug page (#98) * */ @@ -16,6 +16,8 @@ import android.os.Handler; import android.os.HandlerThread; import android.os.SystemClock; +import android.text.TextPaint; +import android.text.TextUtils; import android.util.Log; import android.view.PixelCopy; import android.view.SurfaceView; @@ -31,15 +33,17 @@ /** * Made only when the debug page asks for it. Cells are copied from their SurfaceViews at - * thumbnail size with PixelCopy (the GPU scales them, no extra decoder, nothing from the - * camera) and laid out like the screen. A cell that is not playing right now is drawn black - * with its status, never with a stale last frame: the point is to see whether video is there. + * the size they have in the picture with PixelCopy (the GPU scales them, no extra decoder, + * nothing from the camera) and laid out like the screen. A cell that is not playing right now + * is drawn black with its status, never with a stale last frame: the point is to see whether + * video is there. */ final class Snapshots { private static final String TAG = "OpenIPCDecoder"; - static final int CELL_W = 256; - static final int CELL_H = 144; - private static final int JPEG_QUALITY = 60; + /** The picture is as wide as the screen, but not wider than this; always 16:9. */ + static final int MAX_WIDTH = 1920; + private static final int MIN_WIDTH = 640; + private static final int JPEG_QUALITY = 85; /** For all cells together: what is not copied by then is shown as "no picture". */ private static final long COPY_TIMEOUT_MS = 2000; @@ -68,48 +72,69 @@ private static synchronized Handler copyHandler() { return new Handler(copyThread.getLooper()); } + /** Width of the picture for a screen of the given width (#103). */ + static int width(int screenWidth) { + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, screenWidth)); + } + + /** Height of a picture of the given width: 16:9, like the wall on the debug page. */ + static int height(int width) { + return width * 9 / 16; + } + /** - * JPEG of the cells laid out in a grid of the given side (1 for the single view). Without - * labels the picture has only the frames: the debug page draws the text itself. + * JPEG of the cells laid out in a grid of the given side (1 for the single view), the + * whole picture {@code width} wide. Without labels the picture has only the frames: the + * debug page draws the text itself. */ - static byte[] render(List cells, int side, boolean labels) { + static byte[] render(List cells, int side, int width, boolean labels) { long t0 = SystemClock.elapsedRealtime(); - int cw = side == 1 ? CELL_W * 2 : CELL_W; - int ch = side == 1 ? CELL_H * 2 : CELL_H; + int height = height(width); int n = Math.min(cells.size(), side * side); + // Cell edges at i * size / side: the cells differ by at most a pixel and fill the picture + Rect[] rects = new Rect[n]; + for (int i = 0; i < n; i++) { + int col = i % side, row = i / side; + rects[i] = new Rect(col * width / side, row * height / side, + (col + 1) * width / side, (row + 1) * height / side); + } // All copies are requested at once and run side by side, then awaited together Copy[] copies = new Copy[n]; for (int i = 0; i < n; i++) { Cell c = cells.get(i); if (c != null && c.frame == null && c.live && c.view != null) { - copies[i] = Copy.start(c.view, cw, ch); + copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height()); } } long deadline = SystemClock.elapsedRealtime() + COPY_TIMEOUT_MS; - Bitmap out = Bitmap.createBitmap(cw * side, ch * side, Bitmap.Config.ARGB_8888); + Bitmap out = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(out); canvas.drawColor(Color.BLACK); - Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); - text.setTextSize(14); + TextPaint text = new TextPaint(Paint.ANTI_ALIAS_FLAG); + // As large compared with the cell as the 14 px on the former 256×144 cells + float textSize = Math.max(14, height / side / 10f); + text.setTextSize(textSize); + int bandH = Math.round(textSize + 6); Paint band = new Paint(); band.setColor(0xA0000000); + Paint frames = new Paint(Paint.FILTER_BITMAP_FLAG); Paint grid = new Paint(); grid.setColor(Color.DKGRAY); int copied = 0; for (int i = 0; i < n; i++) { Cell c = cells.get(i); if (c == null) continue; - int x = (i % side) * cw; - int y = (i / side) * ch; + Rect r = rects[i]; + int x = r.left, y = r.top, cw = r.width(), ch = r.height(); Bitmap frame = copies[i] != null ? copies[i].await(deadline) : c.frame; if (copies[i] != null && frame != null) copied++; boolean shown = c.live && frame != null; if (shown) { - canvas.drawBitmap(frame, null, new Rect(x, y, x + cw, y + ch), null); + canvas.drawBitmap(frame, null, r, frames); } if (frame != null) frame.recycle(); if (labels) { - drawLabel(canvas, c, shown, x, y, cw, ch, text, band); + drawLabel(canvas, c, shown, x, y, cw, ch, bandH, text, band); canvas.drawRect(x, y, x + cw, y + 1, grid); canvas.drawRect(x, y, x + 1, y + ch, grid); } @@ -119,10 +144,10 @@ static byte[] render(List cells, int side, boolean labels) { String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date()); text.setColor(Color.WHITE); float tw = text.measureText(time); - canvas.drawRect(out.getWidth() - tw - 8, 0, out.getWidth(), 20, band); - canvas.drawText(time, out.getWidth() - tw - 4, 15, text); + canvas.drawRect(out.getWidth() - tw - 8, 0, out.getWidth(), bandH, band); + canvas.drawText(time, out.getWidth() - tw - 4, bandH - 5, text); } - ByteArrayOutputStream jpeg = new ByteArrayOutputStream(64 * 1024); + ByteArrayOutputStream jpeg = new ByteArrayOutputStream(256 * 1024); out.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, jpeg); out.recycle(); long t2 = SystemClock.elapsedRealtime(); @@ -132,18 +157,21 @@ static byte[] render(List cells, int side, boolean labels) { } private static void drawLabel(Canvas canvas, Cell c, boolean shown, int x, int y, int cw, - int ch, Paint text, Paint band) { - String label = (c.slot > 0 ? c.slot + " \u00b7 " : "") + (c.label != null ? c.label : ""); - if (c.live && !shown) label += " \u00b7 no picture"; + int ch, int bandH, TextPaint text, Paint band) { + String full = (c.slot > 0 ? c.slot + " \u00b7 " : "") + (c.label != null ? c.label : ""); + if (c.live && !shown) full += " \u00b7 no picture"; + // Cut to the cell with an ellipsis rather than run into the next one + String label = TextUtils.ellipsize(full, text, cw - 8, TextUtils.TruncateAt.END).toString(); if (shown) { - canvas.drawRect(x, y + ch - 20, x + cw, y + ch, band); + canvas.drawRect(x, y + ch - bandH, x + cw, y + ch, band); text.setColor(Color.WHITE); canvas.drawText(label, x + 4, y + ch - 6, text); } else { // No video: slot number and status in the middle, in red text.setColor(0xFFFF6060); float w = text.measureText(label); - canvas.drawText(label, x + Math.max(4, (cw - w) / 2), y + ch / 2f + 5, text); + canvas.drawText(label, x + Math.max(4, (cw - w) / 2), + y + (ch + text.getTextSize()) / 2f, text); } }