feat: extend Force TV mode to desktop for home-theater setups

Initialize TvDetectionService on every platform so the existing
force_tv_mode setting drives the 10-foot TV interface on Windows,
macOS, and Linux, and surface the toggle in appearance settings there.

Desktop keeps real-mouse behavior in TV mode: InputModeTracker still
flips between pointer and keyboard modes (cursor hidden while
keyboard-driven), segmented controls keep their hover affordance, and
the settings backup section stays available (only Android TV lacks a
document picker).

Adds PlatformDetector.debugSetIsDesktopOSOverride so TV-device
simulations in widget tests don't inherit the desktop test host's
platform.

close #1409
This commit is contained in:
edde746
2026-07-05 07:08:40 +02:00
parent 3d5a1ec966
commit aee48f6956
9 changed files with 35 additions and 11 deletions
+4 -2
View File
@@ -117,8 +117,10 @@ class _InputModeTrackerState extends State<InputModeTracker> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// On Android TV, don't switch to pointer mode from pointer events // On Android TV, don't switch to pointer mode from pointer events
// as D-pad can generate synthetic pointer events that would incorrectly // as D-pad can generate synthetic pointer events that would incorrectly
// trigger pointer mode and show a cursor instead of D-pad focus navigation // trigger pointer mode and show a cursor instead of D-pad focus navigation.
if (TvDetectionService.isTVSync()) { // 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); return _InputModeProvider(mode: _mode, child: widget.child);
} }
+4 -4
View File
@@ -179,10 +179,10 @@ Future<void> _bootstrapApp() async {
} }
} }
// Initialize TV detection (Android leanback or Apple TV) and PiP on Android. // Initialize TV detection on every platform: auto-detect covers Android
if (Platform.isAndroid || Platform.isIOS) { // leanback and Apple TV; the force-TV setting applies anywhere, including
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode))); // desktop home-theater setups.
} futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
// Visual-effects tier (auto-detects low-end Android; full elsewhere). // Visual-effects tier (auto-detects low-end Android; full elsewhere).
futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects))); futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -102,7 +102,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
title: t.settings.navigation, title: t.settings.navigation,
children: [ children: [
_startupSectionSelector(), _startupSectionSelector(),
if (Platform.isAndroid) if (Platform.isAndroid || PlatformDetector.isDesktopOS())
SettingSwitchTile( SettingSwitchTile(
pref: SettingsService.forceTvMode, pref: SettingsService.forceTvMode,
icon: Symbols.tv_rounded, icon: Symbols.tv_rounded,
+3 -1
View File
@@ -164,7 +164,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], 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), const SizedBox(height: 24),
SettingsGroup( SettingsGroup(
+10 -1
View File
@@ -202,7 +202,16 @@ class PlatformDetector {
/// BuildContext. Use for OS-level capability checks (window state, native /// BuildContext. Use for OS-level capability checks (window state, native
/// keyboard, etc.); use [isDesktop] for layout decisions. /// keyboard, etc.); use [isDesktop] for layout decisions.
static bool isDesktopOS() { 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() { static bool supportsExternalPlayers() {
+3 -2
View File
@@ -210,8 +210,9 @@ class _ExpressiveButtonGroupState<T> extends State<ExpressiveButtonGroup<T>> {
onTap: () => _commit(segment), onTap: () => _commit(segment),
child: child, child: child,
); );
// Hover tracking + click cursor; skipped on TV like ClickableCursor. // Hover tracking + click cursor; skipped on TV like ClickableCursor,
if (!PlatformDetector.isTV()) { // except on desktop (force-TV mode there still has a real mouse).
if (!PlatformDetector.isTV() || PlatformDetector.isDesktopOS()) {
child = MouseRegion( child = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoveredIndex = i), onEnter: (_) => setState(() => _hoveredIndex = i),
@@ -14,12 +14,16 @@ void main() {
setUp(() { setUp(() {
resetSharedPreferencesForTest(); resetSharedPreferencesForTest();
TvDetectionService.debugSetAppleTVOverride(true); 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); LocaleSettings.setLocaleSync(AppLocale.en);
}); });
tearDown(() { tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false); TvDetectionService.setForceTVSync(false);
PlatformDetector.debugSetIsDesktopOSOverride(null);
}); });
testWidgets('D-pad leaves profile name input and reaches actions', (tester) async { testWidgets('D-pad leaves profile name input and reaches actions', (tester) async {
@@ -30,10 +30,13 @@ void main() {
tearDown(() { tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
}); });
testWidgets('remote back pops the manage profile page', (tester) async { testWidgets('remote back pops the manage profile page', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true); TvDetectionService.debugSetAppleTVOverride(true);
// Simulated TV device, not desktop force-TV: keep locked keyboard mode.
PlatformDetector.debugSetIsDesktopOSOverride(false);
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final profiles = _FakeProfileRegistry(db, [profile]); final profiles = _FakeProfileRegistry(db, [profile]);
@@ -85,6 +85,7 @@ void main() {
tearDown(() { tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false); TvDetectionService.setForceTVSync(false);
PlatformDetector.debugSetIsDesktopOSOverride(null);
}); });
testWidgets('autofocuses the server URL field', (tester) async { testWidgets('autofocuses the server URL field', (tester) async {
@@ -269,6 +270,8 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true); TvDetectionService.setForceTVSync(true);
// Simulated TV device, not desktop force-TV: keep locked keyboard mode.
PlatformDetector.debugSetIsDesktopOSOverride(false);
await tester.pumpWidget( await tester.pumpWidget(
InputModeTracker( InputModeTracker(