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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user