diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index 8bb4cc9e..d77c6802 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -117,8 +117,10 @@ class _InputModeTrackerState extends State { Widget build(BuildContext context) { // On Android TV, don't switch to pointer mode from pointer events // as D-pad can generate synthetic pointer events that would incorrectly - // trigger pointer mode and show a cursor instead of D-pad focus navigation - if (TvDetectionService.isTVSync()) { + // trigger pointer mode and show a cursor instead of D-pad focus navigation. + // Desktop is exempt even in force-TV mode: its pointer events come from a + // real mouse, which should keep flipping modes (and the cursor) as usual. + if (TvDetectionService.isTVSync() && !PlatformDetector.isDesktopOS()) { return _InputModeProvider(mode: _mode, child: widget.child); } diff --git a/lib/main.dart b/lib/main.dart index 4d8dd7cc..b6204c5f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -179,10 +179,10 @@ Future _bootstrapApp() async { } } - // Initialize TV detection (Android leanback or Apple TV) and PiP on Android. - if (Platform.isAndroid || Platform.isIOS) { - futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode))); - } + // Initialize TV detection on every platform: auto-detect covers Android + // leanback and Apple TV; the force-TV setting applies anywhere, including + // desktop home-theater setups. + futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode))); // Visual-effects tier (auto-detects low-end Android; full elsewhere). futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects))); if (Platform.isAndroid) { diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 4709988d..3387b025 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -102,7 +102,7 @@ class AppearanceSettingsScreen extends StatelessWidget { title: t.settings.navigation, children: [ _startupSectionSelector(), - if (Platform.isAndroid) + if (Platform.isAndroid || PlatformDetector.isDesktopOS()) SettingSwitchTile( pref: SettingsService.forceTvMode, icon: Symbols.tv_rounded, diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index c6f15812..ab826a69 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -164,7 +164,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], - if (!PlatformDetector.isTV()) _buildBackupSection(), + // Hidden on Android TV / tvOS (no document picker); desktop in + // force-TV mode keeps it — FilePickerService works there. + if (!PlatformDetector.isTV() || PlatformDetector.isDesktopOS()) _buildBackupSection(), const SizedBox(height: 24), SettingsGroup( diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 747d9226..bcb7ff60 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -202,7 +202,16 @@ class PlatformDetector { /// BuildContext. Use for OS-level capability checks (window state, native /// keyboard, etc.); use [isDesktop] for layout decisions. static bool isDesktopOS() { - return Platform.isWindows || Platform.isMacOS || Platform.isLinux; + return _debugIsDesktopOSOverride ?? (Platform.isWindows || Platform.isMacOS || Platform.isLinux); + } + + static bool? _debugIsDesktopOSOverride; + + /// Test-only: override [isDesktopOS] so device simulations (Android TV / + /// Apple TV) don't inherit the test host's real platform. + @visibleForTesting + static void debugSetIsDesktopOSOverride(bool? value) { + _debugIsDesktopOSOverride = value; } static bool supportsExternalPlayers() { diff --git a/lib/widgets/expressive_button_group.dart b/lib/widgets/expressive_button_group.dart index fc3a1a45..29876db8 100644 --- a/lib/widgets/expressive_button_group.dart +++ b/lib/widgets/expressive_button_group.dart @@ -210,8 +210,9 @@ class _ExpressiveButtonGroupState extends State> { onTap: () => _commit(segment), child: child, ); - // Hover tracking + click cursor; skipped on TV like ClickableCursor. - if (!PlatformDetector.isTV()) { + // Hover tracking + click cursor; skipped on TV like ClickableCursor, + // except on desktop (force-TV mode there still has a real mouse). + if (!PlatformDetector.isTV() || PlatformDetector.isDesktopOS()) { child = MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hoveredIndex = i), diff --git a/test/screens/profile/add_local_profile_screen_test.dart b/test/screens/profile/add_local_profile_screen_test.dart index 3d388192..3eabbb2d 100644 --- a/test/screens/profile/add_local_profile_screen_test.dart +++ b/test/screens/profile/add_local_profile_screen_test.dart @@ -14,12 +14,16 @@ void main() { setUp(() { resetSharedPreferencesForTest(); TvDetectionService.debugSetAppleTVOverride(true); + // Simulated TV device: the test host's desktop OS must not leak into + // InputModeTracker's desktop force-TV exemption. + PlatformDetector.debugSetIsDesktopOSOverride(false); LocaleSettings.setLocaleSync(AppLocale.en); }); tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.setForceTVSync(false); + PlatformDetector.debugSetIsDesktopOSOverride(null); }); testWidgets('D-pad leaves profile name input and reaches actions', (tester) async { diff --git a/test/screens/profile/profile_detail_screen_test.dart b/test/screens/profile/profile_detail_screen_test.dart index 20f31524..ff3f4f0b 100644 --- a/test/screens/profile/profile_detail_screen_test.dart +++ b/test/screens/profile/profile_detail_screen_test.dart @@ -30,10 +30,13 @@ void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); }); testWidgets('remote back pops the manage profile page', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); + // Simulated TV device, not desktop force-TV: keep locked keyboard mode. + PlatformDetector.debugSetIsDesktopOSOverride(false); final db = AppDatabase.forTesting(NativeDatabase.memory()); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); final profiles = _FakeProfileRegistry(db, [profile]); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 427591d3..4f791a76 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -85,6 +85,7 @@ void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.setForceTVSync(false); + PlatformDetector.debugSetIsDesktopOSOverride(null); }); testWidgets('autofocuses the server URL field', (tester) async { @@ -269,6 +270,8 @@ void main() { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); + // Simulated TV device, not desktop force-TV: keep locked keyboard mode. + PlatformDetector.debugSetIsDesktopOSOverride(false); await tester.pumpWidget( InputModeTracker(