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) {
// 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);
}
+3 -3
View File
@@ -179,10 +179,10 @@ Future<void> _bootstrapApp() async {
}
}
// Initialize TV detection (Android leanback or Apple TV) and PiP on Android.
if (Platform.isAndroid || Platform.isIOS) {
// 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) {
@@ -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,
+3 -1
View File
@@ -164,7 +164,9 @@ class _SettingsScreenState extends State<SettingsScreen> 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(
+10 -1
View File
@@ -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() {
+3 -2
View File
@@ -210,8 +210,9 @@ class _ExpressiveButtonGroupState<T> extends State<ExpressiveButtonGroup<T>> {
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),
@@ -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 {
@@ -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]);
@@ -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(