diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..29b38e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Project overview + +WiTV is an Android TV IPTV player (Java, Gradle). There is no backend service, database, or Node.js tooling — it is a single-module Android app built entirely with Gradle. + +### Environment + +- **JDK 17** is required (`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`). The VM ships with JDK 21 by default; the update script installs JDK 17 and configures `~/.bashrc`. +- **Android SDK** is installed at `/opt/android-sdk` with platform API 36 and build-tools 36.0.0. `ANDROID_HOME` and `ANDROID_SDK_ROOT` are set via `~/.bashrc`. +- The Gradle wrapper (`./gradlew`) auto-downloads Gradle 8.9; no manual Gradle install is needed. + +### Key commands (see README for full details) + +| Task | Command | +|------|---------| +| Unit tests | `./gradlew testDebugUnitTest --no-daemon` | +| Debug APK build | `./gradlew assembleDebug --no-daemon` | +| Release APK build | `./gradlew assembleRelease --no-daemon` | + +### Gotchas + +- `./gradlew lintDebug` currently fails due to pre-existing `NewApi` lint errors in the codebase. Lint is **not** part of CI — CI only runs `testDebugUnitTest` + `assembleDebug`. +- The app uses `compileSdk 36` which triggers a suppressible warning via `android.suppressUnsupportedCompileSdk=36` in `gradle.properties`. +- Room annotation processor produces a warning about multiple constructors on `FavoriteChannel.java` — this is harmless. +- This is an Android TV app with no web frontend build step; the files in `app/src/main/assets/web/` are plain HTML/JS served by the embedded NanoHTTPD server at runtime. +- There is no Android emulator on the cloud VM, so the APK cannot be run directly. Testing is limited to unit tests and build verification. diff --git a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java index 2c3691a..8a85f0b 100644 --- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java +++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java @@ -117,6 +117,9 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca if (channelListPanel != null) { channelListPanel.setVisibility(View.GONE); } + if (playerView != null) { + playerView.requestFocus(); + } }; private final Runnable loadSpeedRefreshRunnable = new Runnable() { @@ -683,6 +686,9 @@ private void hideOverlay() { channelListPanel.setVisibility(View.GONE); } overlayVisible = false; + if (playerView != null) { + playerView.requestFocus(); + } } private boolean isChannelListPanelVisible() { @@ -1203,6 +1209,14 @@ && isDescendantOf(focused, channelListOverlay)) { } break; case KeyEvent.KEYCODE_BACK: + if (isChannelListPanelVisible()) { + cancelChannelListIdleHide(); + channelListPanel.setVisibility(View.GONE); + if (playerView != null) { + playerView.requestFocus(); + } + return true; + } showExitDialog(); return true; case KeyEvent.KEYCODE_INFO: @@ -1241,6 +1255,21 @@ && isDescendantOf(focused, channelListOverlay)) { return super.onKeyDown(keyCode, event); } + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (isSettingsPanelVisible()) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_ENTER) { + return true; + } + } + return super.onKeyUp(keyCode, event); + } + @Override public void onBackPressed() { if (isSettingsPanelVisible()) { @@ -1252,6 +1281,14 @@ public void onBackPressed() { hideSettingsPanel(); return; } + if (isChannelListPanelVisible()) { + cancelChannelListIdleHide(); + channelListPanel.setVisibility(View.GONE); + if (playerView != null) { + playerView.requestFocus(); + } + return; + } showExitDialog(); } diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index c4ae11f..c851ba4 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -34,6 +34,9 @@ import com.whyun.witv.data.repository.EpgRepository; import com.whyun.witv.player.PlayerManager; +import android.os.Handler; +import android.os.Looper; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -75,6 +78,10 @@ public class SettingsCollapsibleFragment extends Fragment private int openCategory = 0; private int lastOpenedCategory = CAT_ADDRESS; + private final Handler focusHandler = new Handler(Looper.getMainLooper()); + private Runnable pendingSubmenuOpen; + private static final long SUBMENU_OPEN_DEBOUNCE_MS = 150; + @Override public void onAttach(@NonNull Context context) { super.onAttach(context); @@ -145,6 +152,7 @@ public void onResume() { @Override public void onDestroy() { super.onDestroy(); + cancelPendingSubmenuOpen(); executor.shutdown(); } @@ -186,6 +194,8 @@ public void dispatchDrawerKey(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { focused.dispatchKeyEvent(event); + KeyEvent upEvent = new KeyEvent(KeyEvent.ACTION_UP, keyCode); + focused.dispatchKeyEvent(upEvent); return; } @@ -194,12 +204,21 @@ public void dispatchDrawerKey(int keyCode, KeyEvent event) { && openCategory != 0 && submenuContainer != null && submenuContainer.getVisibility() == View.VISIBLE - && focused != null && isDescendant(submenuContainer, focused)) { focusMainMenuAtCategory(lastOpenedCategory); return; } + // 右侧主菜单按左键:如果子菜单已展开,显式将焦点移入子菜单首项 + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT + && openCategory != 0 + && submenuContainer != null + && submenuContainer.getVisibility() == View.VISIBLE + && isDescendant(mainMenuRecycler, focused)) { + focusSubmenuFirstItem(); + return; + } + int direction = keyCodeToFocusDirection(keyCode); if (direction == 0) { return; @@ -210,6 +229,29 @@ && isDescendant(submenuContainer, focused)) { } } + private void focusSubmenuFirstItem() { + if (submenuRecycler == null) { + return; + } + submenuRecycler.post(() -> { + if (submenuRecycler.getChildCount() > 0) { + View first = submenuRecycler.getChildAt(0); + if (first.isFocusable()) { + first.requestFocus(); + } else { + // 某些子菜单行(如 hint)不可聚焦,向下找第一个可聚焦项 + for (int i = 1; i < submenuRecycler.getChildCount(); i++) { + View child = submenuRecycler.getChildAt(i); + if (child.isFocusable()) { + child.requestFocus(); + return; + } + } + } + } + }); + } + private static int keyCodeToFocusDirection(int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: @@ -245,6 +287,7 @@ private static boolean isDescendant(View ancestor, View descendant) { /** 关闭整条设置抽屉时收起左侧子菜单,避免下次打开仍停在子层 */ public void onSettingsDrawerDismiss() { + cancelPendingSubmenuOpen(); openCategory = 0; if (submenuContainer != null && mainMenuRecycler != null && menuRow != null) { submenuContainer.setVisibility(View.GONE); @@ -529,7 +572,10 @@ private static String getDeviceIp(Context context) { @Override public void onMainMenuItemClick(int categoryId) { + // 用户明确按确认键进入子菜单,取消防抖中的待执行展开 + cancelPendingSubmenuOpen(); if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { + focusSubmenuFirstItem(); return; } openSubmenu(categoryId, true); @@ -538,10 +584,25 @@ public void onMainMenuItemClick(int categoryId) { @Override public void onMainMenuItemFocused(int categoryId) { if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { - rebuildSubmenuIfOpen(); + cancelPendingSubmenuOpen(); return; } - openSubmenu(categoryId, false); + // 防抖:快速切换焦点时不立即重建子菜单,只在焦点稳定后展开 + cancelPendingSubmenuOpen(); + pendingSubmenuOpen = () -> { + if (!isAdded()) { + return; + } + openSubmenu(categoryId, false); + }; + focusHandler.postDelayed(pendingSubmenuOpen, SUBMENU_OPEN_DEBOUNCE_MS); + } + + private void cancelPendingSubmenuOpen() { + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + pendingSubmenuOpen = null; + } } private void showHelpDialog() { diff --git a/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java b/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java new file mode 100644 index 0000000..359b6f2 --- /dev/null +++ b/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java @@ -0,0 +1,50 @@ +package com.whyun.witv.ui; + +import android.view.View; +import android.widget.FrameLayout; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@RunWith(RobolectricTestRunner.class) +public class SettingsCollapsibleFragmentTest { + + @Test + public void onMainMenuItemFocusedCancelsPendingOpenWhenReturningToOpenCategory() + throws Exception { + SettingsCollapsibleFragment fragment = new SettingsCollapsibleFragment(); + FrameLayout submenuContainer = new FrameLayout( + androidx.test.core.app.ApplicationProvider.getApplicationContext()); + submenuContainer.setVisibility(View.VISIBLE); + + setField(fragment, "submenuContainer", submenuContainer); + setField(fragment, "openCategory", SettingsCollapsibleFragment.CAT_ADDRESS); + + fragment.onMainMenuItemFocused(SettingsCollapsibleFragment.CAT_EPG); + fragment.onMainMenuItemFocused(SettingsCollapsibleFragment.CAT_ADDRESS); + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + assertNull(getField(fragment, "pendingSubmenuOpen")); + assertEquals(SettingsCollapsibleFragment.CAT_ADDRESS, getField(fragment, "openCategory")); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } +}