diff --git a/README.md b/README.md index 2c4350f9..5d1d9c3e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A small Android app for watching RTSP streams from IP cameras, with hardware H.264/H.265 decoding. It is built for cameras running [OpenIPC](https://openipc.org) firmware and works on phones, tablets, TVs, set-top boxes and car devices.

- Quad view with three cameras and the menu + Multi-view with eight cameras and the menu

## Download @@ -31,11 +31,11 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern - **Video:** RTSP playback, hardware H.264/H.265 decoding via MediaCodec - **Audio:** AAC (RFC 3640 `mpeg4-generic`, ADTS, MP4A-LATM), Opus, G.711 μ-law/A-law, L16 PCM (mono/stereo) -- **Display:** single camera, or Quad mode with 4 cameras at once +- **Display:** single camera, or Quad mode with up to 8 cameras at once (2×2 or 3×3 grid) - **Controls:** pinch-to-zoom, pan, double-tap to reset, Android TV remote (Leanback launcher) - **Network:** Basic and Digest authentication, RTSP keepalive, automatic reconnection with exponential back-off, connection-quality indicator - **WebUI:** built-in browser for the camera's web interface -- **More:** long-press screenshot, 4 camera slots, URLs can be shared from other apps +- **More:** long-press screenshot, 8 camera slots, URLs can be shared from other apps ## Requirements @@ -60,7 +60,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern | Item | Action | |--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **Q** | Turn Quad mode on or off | -| **1**–**4** | 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. | +| **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. | | **Settings** | Edit the RTSP URL 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) | | **Exit** | Close the app. The small text next to it is the app version and build hash. | @@ -81,7 +81,7 @@ The app accepts plain-text shares (`ACTION_SEND`, `text/plain`). Sharing an `rts ### Quad mode -Shows all configured slots in a 2×2 grid. Each cell has its own connection and decoder. 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. Each cell has its own connection and decoder, so the device's video decoder limits how many high-resolution streams play smoothly: with five or more cameras, their substreams are a good choice. 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. ## Known limitations @@ -89,7 +89,6 @@ Shows all configured slots in a 2×2 grid. Each cell has its own connection and > These are known gaps that we plan to address. Each one has an issue — comments, test results and pull requests are welcome. > > - **RTSP over TCP only.** No UDP or multicast — [#7](https://github.com/OpenIPC/decoder/issues/7) -> - **Only 4 camera slots** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **G.726 audio is not supported** — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **Credentials are sent in clear text** over plain RTSP/HTTP (Basic auth unless the camera asks for Digest); RTSPS is not supported and not planned — [#16](https://github.com/OpenIPC/decoder/issues/16) > diff --git a/README.ru.md b/README.ru.md index c71b7a46..96a2adcd 100644 --- a/README.ru.md +++ b/README.ru.md @@ -12,7 +12,7 @@ Компактное приложение для Android: показывает RTSP-потоки с IP-камер и декодирует H.264/H.265 аппаратно. Создано для камер с прошивкой [OpenIPC](https://openipc.org). Работает на телефонах, планшетах, телевизорах, ТВ-приставках и автомобильных устройствах.

- Режим Quad: три камеры и меню + Мультивид: восемь камер и меню

## Скачать @@ -31,11 +31,11 @@ - **Видео:** воспроизведение RTSP, аппаратное декодирование H.264/H.265 через MediaCodec - **Звук:** AAC (RFC 3640 `mpeg4-generic`, ADTS, MP4A-LATM), Opus, G.711 μ-law/A-law, L16 PCM (моно и стерео) -- **Режимы:** одна камера или Quad — 4 камеры на одном экране +- **Режимы:** одна камера или Quad — до 8 камер на одном экране (сетка 2×2 или 3×3) - **Управление:** масштаб двумя пальцами, перемещение, сброс двойным касанием, пульт Android TV (Leanback) - **Сеть:** авторизация Basic и Digest, RTSP keepalive, автоматическое переподключение с растущей задержкой, индикатор качества связи - **WebUI:** встроенный браузер для веб-интерфейса камеры -- **Прочее:** скриншот долгим нажатием, 4 слота для камер, приём ссылок из других приложений +- **Прочее:** скриншот долгим нажатием, 8 слотов для камер, приём ссылок из других приложений ## Требования @@ -60,7 +60,7 @@ | Пункт | Действие | |--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **Q** | Включить или выключить режим Quad | -| **1**–**4** | Переключиться на слот камеры. Цвет номера активного слота показывает качество связи: зелёный — джиттер меньше 100 мс, жёлтый — 100–300 мс, красный — больше 300 мс. Серый номер — пустой слот. | +| **1**–**8** | Переключиться на слот камеры. Цвет номера активного слота показывает качество связи: зелёный — джиттер меньше 100 мс, жёлтый — 100–300 мс, красный — больше 300 мс. Серый номер — пустой слот. | | **Settings** | Изменить RTSP-адрес активного слота (только в режиме одной камеры) | | **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) | | **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. | @@ -81,7 +81,7 @@ ### Режим Quad -Показывает все настроенные слоты сеткой 2×2. У каждой ячейки своё подключение и свой декодер. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню. +Показывает настроенные слоты сеткой 2×2, а если занят хотя бы один из слотов 5–8 — сеткой 3×3. У каждой ячейки своё подключение и свой декодер, поэтому число плавно идущих потоков высокого разрешения ограничено видеодекодером устройства: для пяти и более камер лучше брать их дополнительные потоки (substream). Некоторые камеры допускают только одно RTSP-подключение одновременно. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню. ## Известные ограничения @@ -89,7 +89,6 @@ > Это известные пробелы, которые мы планируем закрыть. На каждый заведён issue — комментарии, результаты проверок и pull request приветствуются. > > - **Только RTSP поверх TCP.** UDP и multicast не поддерживаются — [#7](https://github.com/OpenIPC/decoder/issues/7) -> - **Только 4 слота для камер** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **Не поддерживается звук G.726** — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **Логин и пароль передаются в открытом виде** по RTSP/HTTP без шифрования (Basic, если камера не запросила Digest); RTSPS не поддерживается и не планируется — [#16](https://github.com/OpenIPC/decoder/issues/16) > diff --git a/README.zh-CN.md b/README.zh-CN.md index c28f5b60..454fed5e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ 一款小巧的 Android 应用,用于观看 IP 摄像机的 RTSP 视频流,支持 H.264/H.265 硬件解码。专为运行 [OpenIPC](https://openipc.org) 固件的摄像机设计,可在手机、平板、电视、电视盒子和车载设备上使用。

- 四分屏模式:三路摄像机与菜单 + 多画面模式:八路摄像机与菜单

## 下载 @@ -31,11 +31,11 @@ - **视频:** RTSP 播放,通过 MediaCodec 进行 H.264/H.265 硬件解码 - **音频:** AAC(RFC 3640 `mpeg4-generic`、ADTS、MP4A-LATM)、Opus、G.711 μ-law/A-law、L16 PCM(单声道/立体声) -- **显示:** 单路画面,或 Quad 四分屏(同时显示 4 路摄像机) +- **显示:** 单路画面,或 Quad 多画面(最多同时显示 8 路摄像机,2×2 或 3×3 网格) - **操作:** 双指缩放、拖动平移、双击复位,支持 Android TV 遥控器(Leanback 启动器) - **网络:** 支持 Basic 和 Digest 认证、RTSP 保活,断线后自动重连(指数退避),连接质量指示 - **WebUI:** 内置浏览器,可打开摄像机的网页管理界面 -- **其他:** 长按截图,4 个摄像机槽位,可接收其他应用分享的地址 +- **其他:** 长按截图,8 个摄像机槽位,可接收其他应用分享的地址 ## 系统要求 @@ -60,7 +60,7 @@ | 菜单项 | 功能 | |--------------|-----------------------------------------------------------------------------------------------------------------------------------------------| | **Q** | 开启或关闭 Quad 四分屏模式 | -| **1**–**4** | 切换摄像机槽位。当前槽位编号的颜色表示连接质量:绿色为抖动低于 100 毫秒,黄色为 100–300 毫秒,红色为高于 300 毫秒。灰色编号表示该槽位未配置。 | +| **1**–**8** | 切换摄像机槽位。当前槽位编号的颜色表示连接质量:绿色为抖动低于 100 毫秒,黄色为 100–300 毫秒,红色为高于 300 毫秒。灰色编号表示该槽位未配置。 | | **Settings** | 编辑当前槽位的 RTSP 地址(仅单路模式) | | **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) | | **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 | @@ -81,7 +81,7 @@ ### Quad 四分屏模式 -以 2×2 网格显示所有已配置的槽位。每个画面都有独立的连接和解码器。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。 +以 2×2 网格显示已配置的槽位;只要使用了第 5–8 个槽位中的任意一个,就改为 3×3 网格。每个画面都有独立的连接和解码器,因此能流畅播放多少路高分辨率码流取决于设备的视频解码器:五路及以上时建议使用摄像机的子码流。部分摄像机同一时间只允许一个 RTSP 客户端。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。 ## 已知限制 @@ -89,7 +89,6 @@ > 以下是我们计划解决的已知不足,每一项都有对应的 issue。欢迎留言、提交测试结果和 pull request。 > > - **仅支持 RTSP over TCP**,不支持 UDP 和组播 — [#7](https://github.com/OpenIPC/decoder/issues/7) -> - **仅有 4 个摄像机槽位** — [#13](https://github.com/OpenIPC/decoder/issues/13) > - **不支持 G.726 音频** — [#14](https://github.com/OpenIPC/decoder/issues/14) > - **用户名和密码以明文传输**(未加密的 RTSP/HTTP;摄像机未要求 Digest 时发送 Basic),不支持 RTSPS,也不计划支持 — [#16](https://github.com/OpenIPC/decoder/issues/16) > 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 3ccf88ee..828f18f1 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -45,6 +45,7 @@ import java.io.OutputStream; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Date; import java.util.Locale; import java.util.concurrent.ArrayBlockingQueue; @@ -83,15 +84,16 @@ public class Decoder extends Activity private boolean quadEnabled; private QuadCell[] quadCells; private LinearLayout quadContainer; - private final SurfaceView[] quadViews = new SurfaceView[4]; + private final SurfaceView[] quadViews = new SurfaceView[CAM_COUNT]; // Quad audio (#12): the cell whose sound plays, -1 for none private int quadAudio = -1; - private final TextView[] quadAudioBadges = new TextView[4]; + private final TextView[] quadAudioBadges = new TextView[CAM_COUNT]; private volatile int quadAudioGen; private ExecutorService quadAudioExecutor; // Camera settings - private static final int CAM_COUNT = 4; + /** Camera slots (#13). Multi-view shows 2x2, or 3x3 once a slot above 4 is in use. */ + static final int CAM_COUNT = 8; private static final String DEFAULT_URL = "rtsp://root:12345@192.168.1.10:554/stream=0"; private final String[] mHosts = new String[CAM_COUNT]; private int mActive; @@ -239,44 +241,6 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { quadContainer = new LinearLayout(this); quadContainer.setOrientation(LinearLayout.VERTICAL); quadContainer.setVisibility(View.GONE); - for (int row = 0; row < 2; row++) { - LinearLayout lr = new LinearLayout(this); - lr.setOrientation(LinearLayout.HORIZONTAL); - quadContainer.addView(lr, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, 0, 1)); - for (int col = 0; col < 2; col++) { - int idx = row * 2 + col; - quadViews[idx] = new SurfaceView(this); - quadViews[idx].setOnClickListener(cv -> - menuManager.showMenu(menuAnchor, quadEnabled, mActive, mVersion, - BuildConfig.GIT_HASH, mHosts)); - // Each cell is a FrameLayout so overlays can be stacked on the video - FrameLayout cell = new FrameLayout(this); - cell.addView(quadViews[idx], new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT)); - // Long press selects the cell whose audio plays (#12) - quadViews[idx].setOnLongClickListener(v -> { - toggleQuadAudio(idx); - return true; - }); - TextView badge = new TextView(this); - badge.setText(R.string.quad_audio_badge); - badge.setTextColor(Color.WHITE); - badge.setTextSize(11); - badge.setBackgroundColor(0x99000000); - badge.setPadding(dp(6), dp(2), dp(6), dp(2)); - badge.setVisibility(View.GONE); - FrameLayout.LayoutParams bp = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.END); - bp.setMargins(dp(4), dp(4), dp(4), dp(4)); - cell.addView(badge, bp); - quadAudioBadges[idx] = badge; - lr.addView(cell, new LinearLayout.LayoutParams( - 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); - } - } FrameLayout root = (FrameLayout) menuAnchor; root.addView(quadContainer, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, @@ -532,6 +496,7 @@ private void startQuad() { stopExecutor(); mSurface.setVisibility(View.GONE); + buildQuadGrid(quadGridSize(mHosts, DEFAULT_URL)); quadContainer.setVisibility(View.VISIBLE); ensureQuadLaidOut(); removeClearOverlay(); @@ -580,9 +545,70 @@ private void ensureQuadLaidOut() { }, 500); } + /** Grid side for multi-view: 2 while only slots 1-4 are used, else 3 (#13). */ + static int quadGridSize(String[] hosts, String defaultUrl) { + for (int i = 4; i < hosts.length; i++) { + String url = hosts[i]; + if (url != null && !url.isEmpty() && !url.equals(defaultUrl)) return 3; + } + return 2; + } + + /** (Re)build the multi-view grid; cells beyond it have no view. */ + private void buildQuadGrid(int size) { + quadContainer.removeAllViews(); + Arrays.fill(quadViews, null); + Arrays.fill(quadAudioBadges, null); + for (int row = 0; row < size; row++) { + LinearLayout lr = new LinearLayout(this); + lr.setOrientation(LinearLayout.HORIZONTAL); + quadContainer.addView(lr, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1)); + for (int col = 0; col < size; col++) { + int idx = row * size + col; + if (idx >= CAM_COUNT) { + // The last cell of a 3x3 grid: no slot, stays black + lr.addView(new View(this), new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); + continue; + } + quadViews[idx] = new SurfaceView(this); + quadViews[idx].setOnClickListener(cv -> + menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, mVersion, + BuildConfig.GIT_HASH, mHosts)); + // Each cell is a FrameLayout so overlays can be stacked on the video + FrameLayout cell = new FrameLayout(this); + cell.addView(quadViews[idx], new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + // Long press selects the cell whose audio plays (#12) + quadViews[idx].setOnLongClickListener(v -> { + toggleQuadAudio(idx); + return true; + }); + TextView badge = new TextView(this); + badge.setText(R.string.quad_audio_badge); + badge.setTextColor(Color.WHITE); + badge.setTextSize(11); + badge.setBackgroundColor(0x99000000); + badge.setPadding(dp(6), dp(2), dp(6), dp(2)); + badge.setVisibility(View.GONE); + FrameLayout.LayoutParams bp = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.END); + bp.setMargins(dp(4), dp(4), dp(4), dp(4)); + cell.addView(badge, bp); + quadAudioBadges[idx] = badge; + lr.addView(cell, new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); + } + } + } + private void createQuadCells() { - quadCells = new QuadCell[4]; - for (int i = 0; i < 4; i++) { + quadCells = new QuadCell[CAM_COUNT]; + for (int i = 0; i < CAM_COUNT; i++) { + if (quadViews[i] == null) continue; String url = mHosts[i]; if (url == null || url.isEmpty() || url.equals(DEFAULT_URL)) continue; quadCells[i] = new QuadCell(i, url, quadViews[i], framePool, mUserAgent, @@ -609,7 +635,7 @@ private void applyQuadAudio() { // A new route: audio of a previously selected cell can no longer reach playback int route = audioManager.newRoute(); QuadCell[] cells = quadCells; - for (int i = 0; i < 4; i++) { + for (int i = 0; i < CAM_COUNT; i++) { QuadCell cell = cells != null ? cells[i] : null; boolean on = i == quadAudio && cell != null; if (cell != null) cell.setAudioSink(on ? audioManager : null, route); @@ -675,7 +701,7 @@ private void removeClearOverlay() { } private void clearQuadViews() { - for (int i = 0; i < 4; i++) { + for (int i = 0; i < CAM_COUNT; i++) { SurfaceView t = quadViews[i]; if (t == null) continue; FrameLayout cell = (FrameLayout) t.getParent(); @@ -993,14 +1019,16 @@ private void startStreams() { audioManager.setEnabled(true); if (quadEnabled) { mSurface.setVisibility(View.GONE); - quadContainer.setVisibility(View.VISIBLE); - ensureQuadLaidOut(); if (quadCells != null) { for (QuadCell cell : quadCells) { if (cell != null) cell.stop(); } quadCells = null; } + // The slots may have changed while paused, and with them the grid size (#13) + buildQuadGrid(quadGridSize(mHosts, DEFAULT_URL)); + quadContainer.setVisibility(View.VISIBLE); + ensureQuadLaidOut(); clearQuadViews(); createQuadCells(); getSharedPreferences("settings", MODE_PRIVATE).edit() 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 742f1013..c878d263 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -29,13 +29,14 @@ 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.TextView; class MenuManager { private static final String TAG = "OpenIPCDecoder"; - private static final int CAM_COUNT = 4; + private static final int CAM_COUNT = Decoder.CAM_COUNT; private static final String DEFAULT_URL = "rtsp://root:12345@192.168.1.10:554/stream=0"; private final Decoder activity; @@ -139,7 +140,14 @@ public boolean dispatchKeyEvent(KeyEvent event) { LinearLayout camRow = new LinearLayout(activity); camRow.setOrientation(LinearLayout.HORIZONTAL); - layout.addView(camRow, new LinearLayout.LayoutParams( + // Q + 8 slots (#13) 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)); diff --git a/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java b/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java index 75b16ffa..a45467e8 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java @@ -69,9 +69,10 @@ public void disabledManagerNeverAsksForADecoder() { @Test public void savedSlotIsClamped() { - // #22: a stored slot outside 0..3 falls back to the first camera + // #22: a stored slot outside 0..CAM_COUNT-1 falls back to the first camera (#13: 8 slots) assertEquals(2, Decoder.clampSlot(2)); + assertEquals(7, Decoder.clampSlot(7)); assertEquals(0, Decoder.clampSlot(-1)); - assertEquals(0, Decoder.clampSlot(4)); + assertEquals(0, Decoder.clampSlot(Decoder.CAM_COUNT)); } } diff --git a/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java new file mode 100644 index 00000000..073e7bf0 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/QuadGridTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * QuadGridTest.java — multi-view grid size for 8 camera slots (#13) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class QuadGridTest { + private static final String DEF = "rtsp://default"; + + private static String[] hosts(String... set) { + String[] h = new String[Decoder.CAM_COUNT]; + java.util.Arrays.fill(h, DEF); + for (int i = 0; i < set.length; i++) if (set[i] != null) h[i] = set[i]; + return h; + } + + @Test + public void eightSlots() { + assertEquals(8, Decoder.CAM_COUNT); + } + + @Test + public void twoByTwoWhileOnlyTheFirstFourAreUsed() { + assertEquals(2, Decoder.quadGridSize(hosts(), DEF)); + assertEquals(2, Decoder.quadGridSize(hosts("rtsp://a", "rtsp://b", "rtsp://c", "rtsp://d"), DEF)); + } + + @Test + public void threeByThreeOnceAnUpperSlotIsUsed() { + assertEquals(3, Decoder.quadGridSize(hosts(null, null, null, null, "rtsp://e"), DEF)); + assertEquals(3, Decoder.quadGridSize(hosts(null, null, null, null, null, null, null, "rtsp://h"), DEF)); + } + + @Test + public void emptyOrMissingUpperSlotsDoNotCount() { + String[] h = hosts(); + h[5] = ""; + h[6] = null; + assertEquals(2, Decoder.quadGridSize(h, DEF)); + } +} diff --git a/screenshots/quad_menu.jpg b/screenshots/quad_menu.jpg index 677b8139..f1c99d0e 100644 Binary files a/screenshots/quad_menu.jpg and b/screenshots/quad_menu.jpg differ