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