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.
-
+
## 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). Работает на телефонах, планшетах, телевизорах, ТВ-приставках и автомобильных устройствах.
-
+
## Скачать
@@ -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