From c26af35ae18c7633c17b0b0675788b13d2f33bb0 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 17:53:16 +0000 Subject: [PATCH 1/3] 2.20.0: the touch-only actions on a D-pad remote (#263) With the menu closed the remote now does what the gestures do: - a held OK, or Menu, is the long press: a screenshot in the single view, the selected cell's sound on or off in multi-view; OK opens the menu on release, so that it can be held; - single view: hold Up / Down to zoom in and out (down to 1x resets it), a short arrow pans the zoomed picture; zoom, channel and page keys and + / - zoom in steps of 1.25x, about the centre of the screen; - multi-view: the arrows move a yellow selection frame over the cells (the first arrow shows it on the cell whose sound plays, or the first); it goes away after 4 s. RemoteKeys holds the key mapping and the zoom, pan and grid arithmetic, with unit tests. docs/usage.md and the three READMEs describe the keys. Checked with D-pad input only on an Android TV 16 emulator (single view and a 2x2 grid) and the key handling on Android 5.0. --- CHANGELOG.md | 9 + README.md | 16 +- README.ru.md | 16 +- README.zh-CN.md | 16 +- android/app/build.gradle | 4 +- .../java/com/openipc/decoder/Decoder.java | 193 +++++++++++++++++- .../java/com/openipc/decoder/RemoteKeys.java | 100 +++++++++ .../com/openipc/decoder/RemoteKeysTest.java | 111 ++++++++++ docs/usage.md | 26 ++- 9 files changed, 452 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/RemoteKeys.java create mode 100644 android/app/src/test/java/com/openipc/decoder/RemoteKeysTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bca78be..b15c87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18. `docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they were released. +## 2.20.0 — 2026-11-02 + +- A remote with only a D-pad now reaches the actions that were touch-only (#263). With the + menu closed: in the single view hold **Up** / **Down** to zoom in and out (down to 1× resets + it) and the arrows pan; zoom, channel and page keys and **+** / **−** zoom in steps. In + multi-view the arrows move a yellow frame over the cells. A held **OK**, or **Menu**, does + what a long press does: a screenshot in the single view, the selected cell's sound on or + off in multi-view. A remote's **OK** now opens the menu on release. + ## 2.19.1 — 2026-11-02 - The OpenIPC brand launcher icon and Android TV banner, shared by the OpenIPC Android apps: diff --git a/README.md b/README.md index b9b0587..21b1e5e 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern [ Exit | Reset | Debug | UDP ] ``` -| Input | Action | -|-----------------------|------------------------------------------------------------------------| -| Tap / **OK** | Open or close the menu | -| **1**–**8** | Camera slot; the number's colour shows the connection quality | -| **Grid** / **Slides** | Multi-view or one camera / every frame or key frames only | -| **Debug** / **UDP** | Text in the multi-view cells / the camera that pushes RTP to port 5600 | -| Pinch, double tap | Zoom and pan, reset the zoom (single view) | -| Long press | Screenshot (single view) / play this cell's sound (multi-view) | +| Input | Action | +|-------------------------------------------|--------------------------------------------------------------------------------------------| +| Tap / **OK** | Open or close the menu | +| **1**–**8** | Camera slot; the number's colour shows the connection quality | +| **Grid** / **Slides** | Multi-view or one camera / every frame or key frames only | +| **Debug** / **UDP** | Text in the multi-view cells / the camera that pushes RTP to port 5600 | +| Pinch, double tap / hold **Up**, **Down** | Zoom and pan, reset the zoom (single view); the arrows pan | +| Long press / hold **OK** | Screenshot (single view) / play this cell's sound (multi-view, the arrows choose the cell) | Every item, the remote, sharing a URL from another app and **Reset**: [Usage](docs/usage.md). diff --git a/README.ru.md b/README.ru.md index 96d73d0..5e25d63 100644 --- a/README.ru.md +++ b/README.ru.md @@ -76,14 +76,14 @@ [ Exit | Reset | Debug | UDP ] ``` -| Действие | Результат | -|-----------------------------|--------------------------------------------------------------------| -| Касание / **OK** | Открыть или закрыть меню | -| **1**–**8** | Слот камеры; цвет номера показывает качество связи | -| **Grid** / **Slides** | Мультивид или одна камера / все кадры или только ключевые | -| **Debug** / **UDP** | Текст в ячейках мультивида / камера, которая шлёт RTP на порт 5600 | -| Два пальца, двойное касание | Масштаб и перемещение, сброс масштаба (одиночный режим) | -| Долгое нажатие | Скриншот (одиночный режим) / звук этой ячейки (мультивид) | +| Действие | Результат | +|-------------------------------------------------------------|------------------------------------------------------------------------------------| +| Касание / **OK** | Открыть или закрыть меню | +| **1**–**8** | Слот камеры; цвет номера показывает качество связи | +| **Grid** / **Slides** | Мультивид или одна камера / все кадры или только ключевые | +| **Debug** / **UDP** | Текст в ячейках мультивида / камера, которая шлёт RTP на порт 5600 | +| Два пальца, двойное касание / удержание **Вверх**, **Вниз** | Масштаб и перемещение, сброс масштаба (одиночный режим); стрелки двигают картинку | +| Долгое нажатие / удержание **OK** | Скриншот (одиночный режим) / звук этой ячейки (мультивид, ячейку выбирают стрелки) | Все пункты меню, пульт, передача адреса из другого приложения и **Reset** — в [Usage](docs/usage.md) (на английском). diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b8ff2e..2bde3ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,14 +76,14 @@ [ Exit | Reset | Debug | UDP ] ``` -| 输入 | 功能 | -|-----------------------|--------------------------------------------------------| -| 点击 / **OK** | 打开或关闭菜单 | -| **1**–**8** | 摄像机槽位;编号的颜色表示连接质量 | -| **Grid** / **Slides** | 多画面或单路画面 / 解码全部帧或仅关键帧 | -| **Debug** / **UDP** | 多画面中每个画面的文字 / 向端口 5600 推送 RTP 的摄像机 | -| 双指捏合、双击 | 缩放和平移、恢复原始大小(单路画面) | -| 长按 | 截图(单路画面)/ 播放该画面的声音(多画面) | +| 输入 | 功能 | +|--------------------------------------|----------------------------------------------------------------| +| 点击 / **OK** | 打开或关闭菜单 | +| **1**–**8** | 摄像机槽位;编号的颜色表示连接质量 | +| **Grid** / **Slides** | 多画面或单路画面 / 解码全部帧或仅关键帧 | +| **Debug** / **UDP** | 多画面中每个画面的文字 / 向端口 5600 推送 RTP 的摄像机 | +| 双指捏合、双击 / 按住 **上**、**下** | 缩放和平移、恢复原始大小(单路画面);方向键平移 | +| 长按 / 按住 **OK** | 截图(单路画面)/ 播放该画面的声音(多画面,用方向键选择画面) | 菜单各项、遥控器、从其他应用分享地址以及 **Reset** 的说明见 [Usage](docs/usage.md)(英文)。 diff --git a/android/app/build.gradle b/android/app/build.gradle index e27b49d..8c141c0 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 49 - versionName "2.19.1" + versionCode 50 + versionName "2.20.0" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } 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 efecde1..20de2f9 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -20,6 +20,7 @@ import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Insets; +import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -172,6 +173,19 @@ public class Decoder extends Activity private static final float ZOOM_MIN = 1.0f; private static final float ZOOM_MAX = 5.0f; + // Remote control with the menu closed (#263) + /** An OK went down with no menu shown: its release opens the menu, unless it was held. */ + private boolean okDown; + /** That OK was held: the long action ran, its release does nothing more. */ + private boolean okHeld; + /** Up or Down repeated while held: it zoomed, its release does not pan as well. */ + private boolean arrowRepeated; + /** The multi-view cell a long OK or Menu acts on, -1 before the first key. */ + private int quadSelected = -1; + /** A frame around each cell, shown on the selected one for a while after a key. */ + private final View[] quadSelectFrames = new View[CELL_COUNT]; + private final Runnable hideQuadSelection = () -> showQuadSelection(false); + // Screenshot private boolean isTakingScreenshot; @@ -935,6 +949,8 @@ private void buildQuadGrid(int size) { Arrays.fill(quadViews, null); Arrays.fill(quadAudioBadges, null); Arrays.fill(quadStats, null); + Arrays.fill(quadSelectFrames, null); + quadContainer.removeCallbacks(hideQuadSelection); for (int row = 0; row < size; row++) { LinearLayout lr = new LinearLayout(this); lr.setOrientation(LinearLayout.HORIZONTAL); @@ -985,6 +1001,17 @@ private void buildQuadGrid(int size) { sp.setMargins(dp(2), dp(2), dp(2), dp(2)); cell.addView(stats, sp); quadStats[idx] = stats; + // The cell a remote's long OK acts on (#263): a frame, no fill, above the video + View frame = new View(this); + GradientDrawable border = new GradientDrawable(); + border.setColor(Color.TRANSPARENT); + border.setStroke(dp(3), 0xFFFFD000); + frame.setBackground(border); + frame.setVisibility(View.GONE); + cell.addView(frame, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + quadSelectFrames[idx] = frame; lr.addView(cell, new LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); } @@ -2727,12 +2754,29 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { // still down, its repeats must not open it again (#207, Qodo on #208) if (event.getRepeatCount() > 0) return true; if (menuManager.isPopupShowing()) { + okDown = false; menuManager.dismissPopup(); - } else { - menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, - mVersion, BuildConfig.GIT_HASH, mHosts); + return true; } + // The menu opens on release, so that a held OK can be the long press of a + // touch screen instead (#263): onKeyLongPress needs the key tracked + okDown = true; + okHeld = false; + event.startTracking(); return true; + // Menu does what a held OK does, for remotes whose OK has no long press (as on + // Debug in the menu, #207); with the menu shown the menu gets the key + case KeyEvent.KEYCODE_MENU: + if (menuManager.isPopupShowing()) return super.onKeyDown(keyCode, event); + if (event.getRepeatCount() == 0) remoteLongAction(); + return true; + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (menuManager.isPopupShowing()) return super.onKeyDown(keyCode, event); + if (onArrowDown(keyCode, event)) return true; + return super.onKeyDown(keyCode, event); // Back is not intercepted here (#30): the menu is a focusable PopupWindow and the // dialogs are Dialogs, which handle back themselves, also as predictive back // gestures (OnBackInvokedDispatcher, Android 13+). Without a popup, back keeps its @@ -2744,10 +2788,153 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { } return super.onKeyDown(keyCode, event); default: + int zoom = RemoteKeys.zoomDirection(keyCode); + if (zoom != 0 && isSingleView() && !menuManager.isPopupShowing()) { + zoomBy(zoom > 0 ? RemoteKeys.ZOOM_STEP : 1f / RemoteKeys.ZOOM_STEP); + return true; + } return super.onKeyDown(keyCode, event); } } + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) + && okDown) { + okHeld = true; + remoteLongAction(); + return true; + } + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + // Only an OK whose press reached this activity: the release of an OK that + // closed the menu, or was pressed in it, opens nothing (#207) + if (!okDown) return true; + okDown = false; + if (!okHeld && !event.isCanceled() && !menuManager.isPopupShowing()) { + menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, + mVersion, BuildConfig.GIT_HASH, mHosts); + } + okHeld = false; + return true; + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + // A short Up or Down pans; a held one zoomed already + if (isSingleView() && !menuManager.isPopupShowing()) { + if (!arrowRepeated) panBy(keyCode); + arrowRepeated = false; + return true; + } + return super.onKeyUp(keyCode, event); + default: + return super.onKeyUp(keyCode, event); + } + } + + /** The single view is on screen: neither the grid nor the U view. */ + private boolean isSingleView() { + return !quadEnabled && !udpViewShown; + } + + /** + * An arrow with the menu closed (#263). Single view: Left and Right pan the zoomed picture, + * also while held; Up and Down zoom in and out while held and pan on a short press (on + * release). Multi-view: the selection frame moves between the cells. + */ + private boolean onArrowDown(int keyCode, KeyEvent event) { + if (isSingleView()) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { + if (event.getRepeatCount() == 0) { + arrowRepeated = false; + } else { + arrowRepeated = true; + zoomBy(keyCode == KeyEvent.KEYCODE_DPAD_UP ? RemoteKeys.ZOOM_HOLD_STEP + : 1f / RemoteKeys.ZOOM_HOLD_STEP); + } + } else { + panBy(keyCode); + } + return true; + } + if (quadEnabled && !udpViewShown && quadContainer.getChildCount() > 1) { + int size = quadContainer.getChildCount(); + boolean shown = quadSelected >= 0 && quadSelectFrames[quadSelected] != null + && quadSelectFrames[quadSelected].getVisibility() == View.VISIBLE; + // The first arrow only shows where the selection is; the next ones move it + if (shown) quadSelected = RemoteKeys.moveCell(quadSelected, size, keyCode); + else ensureQuadSelected(); + showQuadSelection(true); + return true; + } + return false; + } + + /** A held OK or Menu with the menu closed: the long press of a touch screen (#263). */ + private void remoteLongAction() { + if (isSingleView()) { + takeScreenshot(); + } else if (quadEnabled && !udpViewShown) { + ensureQuadSelected(); + showQuadSelection(true); + if (quadSelected == U_CELL) { + Toast.makeText(this, "The U cell has video only", Toast.LENGTH_SHORT).show(); + } else { + toggleQuadAudio(quadSelected); + } + } + } + + /** A valid selected cell: the one whose sound plays, else the first. */ + private void ensureQuadSelected() { + if (quadSelected >= 0 && quadSelected < CELL_COUNT && quadViews[quadSelected] != null) { + return; + } + quadSelected = quadAudio >= 0 && quadAudio < CELL_COUNT && quadViews[quadAudio] != null + ? quadAudio : 0; + } + + /** The frame on the selected cell for a while, or on none. */ + private void showQuadSelection(boolean show) { + quadContainer.removeCallbacks(hideQuadSelection); + for (int i = 0; i < CELL_COUNT; i++) { + View f = quadSelectFrames[i]; + if (f != null) f.setVisibility(show && i == quadSelected ? View.VISIBLE : View.GONE); + } + if (show) quadContainer.postDelayed(hideQuadSelection, RemoteKeys.SELECTION_SHOWN_MS); + } + + /** Zoom of the single view by a factor, about the centre of the screen (#263). */ + private void zoomBy(float factor) { + if (mFitWidth <= 0 || mFitHeight <= 0) return; + float next = RemoteKeys.zoom(mZoomScale, factor, ZOOM_MIN, ZOOM_MAX); + if (next == ZOOM_MIN) { + resetZoom(); + return; + } + mPanX = RemoteKeys.panAfterZoom(mPanX, mZoomScale, next); + mPanY = RemoteKeys.panAfterZoom(mPanY, mZoomScale, next); + mZoomScale = next; + clampPan(); + applyZoomTransform(); + } + + /** One step of pan of the zoomed single view for an arrow (#263). */ + private void panBy(int keyCode) { + if (mZoomScale <= ZOOM_MIN || mFitWidth <= 0) return; + float[] d = RemoteKeys.panForArrow(keyCode, + RemoteKeys.PAN_STEP * Math.min(mFitWidth, mFitHeight)); + mPanX += d[0]; + mPanY += d[1]; + clampPan(); + applyZoomTransform(); + } + // QuadCell class — extracted to QuadCell.java } diff --git a/android/app/src/main/java/com/openipc/decoder/RemoteKeys.java b/android/app/src/main/java/com/openipc/decoder/RemoteKeys.java new file mode 100644 index 0000000..71097b8 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/RemoteKeys.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * RemoteKeys.java — what the remote's keys do while the menu is closed (#263) + * + */ + +package com.openipc.decoder; + +import android.view.KeyEvent; + +/** + * The touch-only actions on a D-pad remote (#263): zoom and pan of the single view, the cell + * a long OK acts on in multi-view. Pure functions, so they are tested without a device. + */ +final class RemoteKeys { + private RemoteKeys() {} + + /** One press of a zoom key (ZOOM, CH, PAGE, + and -). */ + static final float ZOOM_STEP = 1.25f; + /** Each key repeat while Up or Down is held: about 5× in two seconds at 20 repeats/s. */ + static final float ZOOM_HOLD_STEP = 1.04f; + /** One press of an arrow moves the zoomed picture by this share of the fitted view. */ + static final float PAN_STEP = 0.1f; + /** The selection frame in multi-view goes away after this long without a key. */ + static final long SELECTION_SHOWN_MS = 4000; + + /** +1 for a key that zooms in, -1 for one that zooms out, 0 for any other. */ + static int zoomDirection(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_ZOOM_IN: + case KeyEvent.KEYCODE_CHANNEL_UP: + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PLUS: + case KeyEvent.KEYCODE_NUMPAD_ADD: + case KeyEvent.KEYCODE_EQUALS: + return 1; + case KeyEvent.KEYCODE_ZOOM_OUT: + case KeyEvent.KEYCODE_CHANNEL_DOWN: + case KeyEvent.KEYCODE_PAGE_DOWN: + case KeyEvent.KEYCODE_MINUS: + case KeyEvent.KEYCODE_NUMPAD_SUBTRACT: + return -1; + default: + return 0; + } + } + + static boolean isArrow(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT; + } + + /** The new zoom, kept within [min, max]; at or below min + a little it is min exactly. */ + static float zoom(float scale, float factor, float min, float max) { + float next = Math.max(min, Math.min(max, scale * factor)); + return next < min * 1.001f ? min : next; + } + + /** + * The pan after a zoom from {@code oldScale} to {@code newScale}: the point of the picture at + * the centre of the screen stays there, as a remote has no point to zoom into. + */ + static float panAfterZoom(float pan, float oldScale, float newScale) { + return oldScale > 0 ? pan * newScale / oldScale : 0f; + } + + /** + * Pan in x and y for an arrow, {@code step} pixels: Up shows more of the top of the picture, + * so the picture moves down; Left likewise moves it right. + */ + static float[] panForArrow(int keyCode, float step) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: return new float[] {0f, step}; + case KeyEvent.KEYCODE_DPAD_DOWN: return new float[] {0f, -step}; + case KeyEvent.KEYCODE_DPAD_LEFT: return new float[] {step, 0f}; + case KeyEvent.KEYCODE_DPAD_RIGHT: return new float[] {-step, 0f}; + default: return new float[] {0f, 0f}; + } + } + + /** + * The cell an arrow moves the selection to in a {@code size}×{@code size} grid, cells + * numbered by rows; at an edge it stays. A cell out of the grid starts at the first. + */ + static int moveCell(int cell, int size, int keyCode) { + if (size < 1) return cell; + if (cell < 0 || cell >= size * size) return 0; + int row = cell / size; + int col = cell % size; + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: row = Math.max(0, row - 1); break; + case KeyEvent.KEYCODE_DPAD_DOWN: row = Math.min(size - 1, row + 1); break; + case KeyEvent.KEYCODE_DPAD_LEFT: col = Math.max(0, col - 1); break; + case KeyEvent.KEYCODE_DPAD_RIGHT: col = Math.min(size - 1, col + 1); break; + default: break; + } + return row * size + col; + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/RemoteKeysTest.java b/android/app/src/test/java/com/openipc/decoder/RemoteKeysTest.java new file mode 100644 index 0000000..35854b6 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/RemoteKeysTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * RemoteKeysTest.java — zoom, pan and the selected cell on a D-pad remote (#263) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.view.KeyEvent; + +import org.junit.Test; + +public class RemoteKeysTest { + private static final int UP = KeyEvent.KEYCODE_DPAD_UP; + private static final int DOWN = KeyEvent.KEYCODE_DPAD_DOWN; + private static final int LEFT = KeyEvent.KEYCODE_DPAD_LEFT; + private static final int RIGHT = KeyEvent.KEYCODE_DPAD_RIGHT; + + @Test + public void zoomKeys() { + assertEquals(1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_ZOOM_IN)); + assertEquals(1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_CHANNEL_UP)); + assertEquals(1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_PLUS)); + assertEquals(-1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_ZOOM_OUT)); + assertEquals(-1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_PAGE_DOWN)); + assertEquals(-1, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_MINUS)); + assertEquals(0, RemoteKeys.zoomDirection(UP)); + assertEquals(0, RemoteKeys.zoomDirection(KeyEvent.KEYCODE_DPAD_CENTER)); + } + + @Test + public void arrows() { + assertTrue(RemoteKeys.isArrow(UP)); + assertTrue(RemoteKeys.isArrow(RIGHT)); + assertFalse(RemoteKeys.isArrow(KeyEvent.KEYCODE_ENTER)); + } + + @Test + public void zoomStaysWithinLimits() { + assertEquals(1.25f, RemoteKeys.zoom(1f, 1.25f, 1f, 5f), 1e-6); + assertEquals(5f, RemoteKeys.zoom(4.5f, 1.25f, 1f, 5f), 1e-6); + assertEquals(1f, RemoteKeys.zoom(1.1f, 0.8f, 1f, 5f), 1e-6); + // Rounding of repeated steps lands on 1× exactly, which resets the pan + assertEquals(1f, RemoteKeys.zoom(1.0005f, 1f, 1f, 5f), 0f); + } + + @Test + public void heldUpReachesMaxInAboutTwoSeconds() { + float z = 1f; + int repeats = 0; + while (z < 5f && repeats < 100) { + z = RemoteKeys.zoom(z, RemoteKeys.ZOOM_HOLD_STEP, 1f, 5f); + repeats++; + } + assertEquals(5f, z, 1e-6); + // At 20 repeats a second + assertTrue(repeats >= 30 && repeats <= 50); + } + + @Test + public void zoomKeepsTheCentre() { + assertEquals(200f, RemoteKeys.panAfterZoom(100f, 2f, 4f), 1e-6); + assertEquals(-50f, RemoteKeys.panAfterZoom(-100f, 4f, 2f), 1e-6); + assertEquals(0f, RemoteKeys.panAfterZoom(100f, 0f, 2f), 0f); + } + + @Test + public void arrowsShowThatSideOfThePicture() { + assertArrayEquals(new float[] {0f, 10f}, RemoteKeys.panForArrow(UP, 10f), 0f); + assertArrayEquals(new float[] {0f, -10f}, RemoteKeys.panForArrow(DOWN, 10f), 0f); + assertArrayEquals(new float[] {10f, 0f}, RemoteKeys.panForArrow(LEFT, 10f), 0f); + assertArrayEquals(new float[] {-10f, 0f}, RemoteKeys.panForArrow(RIGHT, 10f), 0f); + assertArrayEquals(new float[] {0f, 0f}, + RemoteKeys.panForArrow(KeyEvent.KEYCODE_ENTER, 10f), 0f); + } + + @Test + public void selectionMovesInTheGrid() { + // 2×2: 0 1 / 2 3 + assertEquals(1, RemoteKeys.moveCell(0, 2, RIGHT)); + assertEquals(2, RemoteKeys.moveCell(0, 2, DOWN)); + assertEquals(3, RemoteKeys.moveCell(1, 2, DOWN)); + assertEquals(2, RemoteKeys.moveCell(3, 2, LEFT)); + // 3×3: the ninth cell (8, the U cell) is reachable + assertEquals(8, RemoteKeys.moveCell(5, 3, DOWN)); + assertEquals(8, RemoteKeys.moveCell(7, 3, RIGHT)); + assertEquals(4, RemoteKeys.moveCell(1, 3, DOWN)); + } + + @Test + public void selectionStopsAtTheEdges() { + assertEquals(0, RemoteKeys.moveCell(0, 2, UP)); + assertEquals(0, RemoteKeys.moveCell(0, 2, LEFT)); + assertEquals(3, RemoteKeys.moveCell(3, 2, RIGHT)); + assertEquals(8, RemoteKeys.moveCell(8, 3, DOWN)); + assertEquals(2, RemoteKeys.moveCell(2, 3, RIGHT)); + } + + @Test + public void aCellOutOfTheGridStartsAtTheFirst() { + assertEquals(0, RemoteKeys.moveCell(-1, 2, RIGHT)); + // 3×3 grid shrunk to 2×2: cell 6 is gone + assertEquals(0, RemoteKeys.moveCell(6, 2, UP)); + } +} diff --git a/docs/usage.md b/docs/usage.md index 63577bb..ea47251 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -55,19 +55,25 @@ Items that are on (Grid, Slides, Debug, UDP and the active slot) have an orange ## Gestures and keys -| Input | Action | -|-----------------------------------------|-----------------------------------------------------------------------| -| Tap / **OK** / **Enter** (D-pad centre) | Open or close the menu | -| **Back** / **Esc** | Close the menu or a dialog; without one, Back keeps its system action | -| Pinch, then drag | Zoom (1× to 5×) and pan, single view | -| Double tap | Reset the zoom | -| Long press, single view | Save a screenshot, see [Screenshots](#screenshots) | -| Long press, multi-view | Play this cell's sound; again to mute | +| Input | Action | +|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| Tap / **OK** / **Enter** (D-pad centre) | Open or close the menu (a remote's **OK** on release) | +| **Back** / **Esc** | Close the menu or a dialog; without one, Back keeps its system action | +| Pinch, then drag; remote: hold **Up** / **Down**, then the arrows | Zoom (1× to 5×) and pan, single view | +| Double tap; remote: hold **Down** down to 1× | Reset the zoom | +| Long press, single view; remote: hold **OK**, or **Menu** | Save a screenshot, see [Screenshots](#screenshots) | +| Long press, multi-view; remote: an arrow to the cell, then hold **OK**, or **Menu** | Play this cell's sound; again to mute | ## Remote control The app is in the Leanback launcher of Android TV devices and does not require a touchscreen. **OK** opens the menu; the D-pad moves through the grid; the first key after a menu opened by touch moves the focus to the active item. In the reset dialog **Cancel** has the focus, so a stray **OK** deletes nothing. +With the menu closed the remote does what the gestures do ([#263](https://github.com/OpenIPC/decoder/issues/263)): + +- **Single view:** hold **Up** to zoom in and **Down** to zoom out; down to 1× is the reset. A short press of an arrow moves the zoomed picture. Zoom, channel and page keys, and **+** / **−** on a keyboard, zoom in and out in steps. +- **Multi-view:** an arrow shows a yellow frame on the selected cell (the one whose sound plays, or the first); the next arrows move it. The frame goes away after 4 s. +- **Hold OK:** a screenshot in the single view; in multi-view the selected cell's sound on or off. **Menu** does the same, for remotes whose **OK** has no long press. So that **OK** can be held, the menu opens when it is released. + ## Multi-view **Grid** 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 is [the U cell](#the-u-cell). Tap any cell to open the menu. @@ -88,7 +94,7 @@ Each cell has its own connection and decoder. The device's video decoder has a f Substreams of the cameras, where available, let more cells play at full rate. Some cameras accept only one RTSP client at a time. -**Audio:** long-press a cell to play its sound, long-press it again to mute. The selected cell shows an **AUDIO** badge and is remembered. +**Audio:** long-press a cell to play its sound, long-press it again to mute (remote: an arrow to the cell, then hold **OK**, or **Menu**). The selected cell shows an **AUDIO** badge and is remembered. ## Slides @@ -118,7 +124,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ ## Screenshots -A long press in the single view saves the picture to `Pictures/OpenIPC` (at most 3840×2160; a larger stream is scaled down). Screenshots need Android 7.0 or newer; Android 7–9 ask for the storage permission first. For a picture of the whole grid from another computer use `/snapshot.jpg` of the [debug page](debug.md#snapshot). +A long press in the single view (remote: hold **OK**, or **Menu**) saves the picture to `Pictures/OpenIPC` (at most 3840×2160; a larger stream is scaled down). Screenshots need Android 7.0 or newer; Android 7–9 ask for the storage permission first. For a picture of the whole grid from another computer use `/snapshot.jpg` of the [debug page](debug.md#snapshot). ## Start on boot From 2d57eadd55715383961938056bc46e24a6dbc012 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 18:01:09 +0000 Subject: [PATCH 2/3] fix: the selection frame shows above a cell's hint; a cancelled Up or Down release does not pan (Qodo on #264) --- .../main/java/com/openipc/decoder/Decoder.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 20de2f9..0b3e256 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -178,6 +178,8 @@ public class Decoder extends Activity private boolean okDown; /** That OK was held: the long action ran, its release does nothing more. */ private boolean okHeld; + /** Up or Down went down in the single view: only its own release may pan. */ + private boolean arrowDown; /** Up or Down repeated while held: it zoomed, its release does not pan as well. */ private boolean arrowRepeated; /** The multi-view cell a long OK or Menu acts on, -1 before the first key. */ @@ -2827,7 +2829,10 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { case KeyEvent.KEYCODE_DPAD_DOWN: // A short Up or Down pans; a held one zoomed already if (isSingleView() && !menuManager.isPopupShowing()) { - if (!arrowRepeated) panBy(keyCode); + // Only a press that reached this view, neither cancelled nor held (Qodo on + // #264) + if (arrowDown && !arrowRepeated && !event.isCanceled()) panBy(keyCode); + arrowDown = false; arrowRepeated = false; return true; } @@ -2851,6 +2856,7 @@ private boolean onArrowDown(int keyCode, KeyEvent event) { if (isSingleView()) { if (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { if (event.getRepeatCount() == 0) { + arrowDown = true; arrowRepeated = false; } else { arrowRepeated = true; @@ -2904,7 +2910,12 @@ private void showQuadSelection(boolean show) { quadContainer.removeCallbacks(hideQuadSelection); for (int i = 0; i < CELL_COUNT; i++) { View f = quadSelectFrames[i]; - if (f != null) f.setVisibility(show && i == quadSelected ? View.VISIBLE : View.GONE); + if (f == null) continue; + boolean on = show && i == quadSelected; + f.setVisibility(on ? View.VISIBLE : View.GONE); + // Above the cell's hint, which an empty slot or a waiting U cell keeps for good; + // the frame has no fill, so the hint, badge and stats stay readable (Qodo on #264) + if (on) f.bringToFront(); } if (show) quadContainer.postDelayed(hideQuadSelection, RemoteKeys.SELECTION_SHOWN_MS); } From c6719a0bc30748f035e28481ba7f87ab32d57a82 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 18:06:12 +0000 Subject: [PATCH 3/3] fix: a long-pressed cell becomes the remote's selection; an empty slot gets no sound from the remote (Qodo on #264) --- .../app/src/main/java/com/openipc/decoder/Decoder.java | 9 +++++++++ docs/usage.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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 0b3e256..8deb273 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -974,6 +974,8 @@ private void buildQuadGrid(int size) { // Long press selects the cell whose audio plays (#12); the U cell has video only if (idx != U_CELL) { quadViews[idx].setOnLongClickListener(v -> { + // The remote's next arrow starts from the cell touched last (Qodo on #264) + quadSelected = idx; toggleQuadAudio(idx); return true; }); @@ -2888,8 +2890,15 @@ private void remoteLongAction() { } else if (quadEnabled && !udpViewShown) { ensureQuadSelected(); showQuadSelection(true); + QuadCell[] cells = quadCells; + boolean camera = cells != null && cells[quadSelected] != null; if (quadSelected == U_CELL) { Toast.makeText(this, "The U cell has video only", Toast.LENGTH_SHORT).show(); + } else if (!camera && quadAudio != quadSelected) { + // An empty slot has no sound to play; one stored from before may still go off + // (Qodo on #264) + Toast.makeText(this, "Camera " + (quadSelected + 1) + " is not configured", + Toast.LENGTH_SHORT).show(); } else { toggleQuadAudio(quadSelected); } diff --git a/docs/usage.md b/docs/usage.md index ea47251..bb88f6c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -71,7 +71,7 @@ The app is in the Leanback launcher of Android TV devices and does not require a With the menu closed the remote does what the gestures do ([#263](https://github.com/OpenIPC/decoder/issues/263)): - **Single view:** hold **Up** to zoom in and **Down** to zoom out; down to 1× is the reset. A short press of an arrow moves the zoomed picture. Zoom, channel and page keys, and **+** / **−** on a keyboard, zoom in and out in steps. -- **Multi-view:** an arrow shows a yellow frame on the selected cell (the one whose sound plays, or the first); the next arrows move it. The frame goes away after 4 s. +- **Multi-view:** an arrow shows a yellow frame on the selected cell (the one chosen or long-pressed last; at first the one whose sound plays, or the first); the next arrows move it. The frame goes away after 4 s. An empty slot can be selected but has no sound. - **Hold OK:** a screenshot in the single view; in multi-view the selected cell's sound on or off. **Menu** does the same, for remotes whose **OK** has no long press. So that **OK** can be held, the menu opens when it is released. ## Multi-view