From 5a468570352917b92be56ce35b4536ff3a33ffe6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 14 May 2026 02:22:25 +0200 Subject: [PATCH] fix(companion): respect remote server setting close #1038 --- lib/screens/main_screen.dart | 33 +--- .../settings/playback_settings_screen.dart | 2 + .../companion_remote_host_controller.dart | 55 +++++++ .../remote_session_dialog.dart | 146 ++++++++++-------- test/widgets/remote_session_dialog_test.dart | 141 +++++++++++++++++ 5 files changed, 285 insertions(+), 92 deletions(-) create mode 100644 lib/services/companion_remote/companion_remote_host_controller.dart create mode 100644 test/widgets/remote_session_dialog_test.dart diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 57896f6e..7d79dec5 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -23,12 +23,9 @@ import '../mixins/refreshable.dart'; import '../widgets/overlay_sheet.dart'; import '../mixins/tab_visibility_aware.dart'; import '../navigation/navigation_tabs.dart'; -import '../connection/connection_registry.dart'; -import '../profiles/active_plex_identity.dart'; import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; -import '../profiles/profile_connection_registry.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; @@ -40,6 +37,7 @@ import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../providers/offline_mode_provider.dart'; +import '../services/companion_remote/companion_remote_host_controller.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../providers/companion_remote_provider.dart'; @@ -251,7 +249,7 @@ class _MainScreenState extends State // Auto-start companion remote server once the active profile is known. if (_companionRemoteSetup && mounted) { - unawaited(_autoStartCompanionRemoteServer(context.read())); + unawaited(_autoStartCompanionRemoteServer()); } } @@ -657,35 +655,12 @@ class _MainScreenState extends State }; } - Future _autoStartCompanionRemoteServer(CompanionRemoteProvider companionRemote) async { + Future _autoStartCompanionRemoteServer() async { try { final settings = await SettingsService.getInstance(); if (!settings.read(SettingsService.enableCompanionRemoteServer)) return; if (!mounted) return; - - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - if (!mounted) return; - final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - if (!mounted) return; - final ok = await companionRemote.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); - if (ok) { - await companionRemote.startHostServer(); - } + await startCompanionRemoteHost(context); } catch (e) { appLogger.e('CompanionRemote: Failed to auto-start server', error: e); } diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 71addc7b..c8dcec01 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -7,6 +7,7 @@ import '../../i18n/strings.g.dart'; import '../../models/transcode_quality_preset.dart'; import '../../mpv/player/platform/player_android.dart'; import '../../utils/quality_preset_labels.dart'; +import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/discord_rpc_service.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; @@ -139,6 +140,7 @@ class _PlaybackSettingsScreenState extends State { icon: Symbols.phone_android_rounded, title: t.settings.companionRemoteServer, subtitle: t.settings.companionRemoteServerDescription, + onAfterWrite: (v) => applyCompanionRemoteServerSetting(context, v), ), SettingSwitchTile( pref: SettingsService.rememberTrackSelections, diff --git a/lib/services/companion_remote/companion_remote_host_controller.dart b/lib/services/companion_remote/companion_remote_host_controller.dart new file mode 100644 index 00000000..9c03f84d --- /dev/null +++ b/lib/services/companion_remote/companion_remote_host_controller.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection_registry.dart'; +import '../../profiles/active_plex_identity.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../providers/companion_remote_provider.dart'; +import '../../utils/app_logger.dart'; + +Future startCompanionRemoteHost(BuildContext context) async { + final companionRemote = context.read(); + if (companionRemote.isHostServerRunning) return true; + + try { + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (!context.mounted) return false; + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + if (!context.mounted) return false; + final ok = await companionRemote.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); + if (!context.mounted || !ok) return false; + + await companionRemote.startHostServer(); + return companionRemote.isHostServerRunning; + } catch (e) { + appLogger.e('CompanionRemote: Failed to start server', error: e); + return false; + } +} + +Future applyCompanionRemoteServerSetting(BuildContext context, bool enabled) async { + final companionRemote = context.read(); + if (!enabled) { + await companionRemote.stopHostServer(); + return; + } + + await startCompanionRemoteHost(context); +} diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index ee6a87c6..f046ead9 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -1,17 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/mounted_set_state_mixin.dart'; -import '../../profiles/active_plex_identity.dart'; -import '../../profiles/active_profile_provider.dart'; -import '../../profiles/plex_home_service.dart'; -import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; +import '../../services/companion_remote/companion_remote_host_controller.dart'; +import '../../services/settings_service.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; -import '../../utils/app_logger.dart'; class RemoteSessionDialog extends StatefulWidget { const RemoteSessionDialog({super.key}); @@ -31,78 +27,69 @@ class RemoteSessionDialog extends StatefulWidget { class _RemoteSessionDialogState extends State with MountedSetStateMixin { bool _isStarting = false; String? _errorMessage; + final _closeFocusNode = FocusNode(debugLabel: 'RemoteSessionDialog.close'); + final _toggleFocusNode = FocusNode(debugLabel: 'RemoteSessionDialog.toggle'); + final _minimizeFocusNode = FocusNode(debugLabel: 'RemoteSessionDialog.minimize'); + final _errorCloseFocusNode = FocusNode(debugLabel: 'RemoteSessionDialog.errorClose'); + final _errorRetryFocusNode = FocusNode(debugLabel: 'RemoteSessionDialog.errorRetry'); @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _ensureServerRunning()); + void dispose() { + _closeFocusNode.dispose(); + _toggleFocusNode.dispose(); + _minimizeFocusNode.dispose(); + _errorCloseFocusNode.dispose(); + _errorRetryFocusNode.dispose(); + super.dispose(); } - Future _ensureServerRunning() async { - final provider = context.read(); - + Future _startServer() async { setState(() { _isStarting = true; _errorMessage = null; }); try { - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.enableCompanionRemoteServer, true); if (!mounted) return; - final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - if (!mounted) return; - final ok = await provider.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); - if (!mounted) return; - if (!ok) { - setState(() { - _isStarting = false; - _errorMessage = t.companionRemote.pairing.cryptoInitFailed; - }); - return; - } - if (!provider.isHostServerRunning) { - await provider.startHostServer(); - } - - setStateIfMounted(() => _isStarting = false); - } catch (e) { - appLogger.e('Failed to start companion remote server', error: e); + final started = await startCompanionRemoteHost(context); if (!mounted) return; + final provider = context.read(); setState(() { + _isStarting = false; + _errorMessage = started ? null : provider.session?.errorMessage ?? t.companionRemote.pairing.cryptoInitFailed; + }); + } catch (e) { + setStateIfMounted(() { _isStarting = false; _errorMessage = e.toString(); }); } } + Future _stopServer() async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.enableCompanionRemoteServer, false); + if (!mounted) return; + await context.read().stopHostServer(); + } + Future _toggleServer() async { final provider = context.read(); if (provider.isHostServerRunning) { - await provider.stopHostServer(); + await _stopServer(); } else { - await _ensureServerRunning(); + await _startServer(); } } + void _close() => Navigator.of(context).pop(); + @override Widget build(BuildContext context) { return Focus( - autofocus: true, + canRequestFocus: false, onKeyEvent: (node, event) => handleBackKeyNavigation(context, event), child: Consumer( builder: (context, provider, child) { @@ -135,8 +122,23 @@ class _RemoteSessionDialogState extends State with MountedS ], ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(t.common.close)), - TextButton(onPressed: _ensureServerRunning, child: Text(t.common.retry)), + FocusableButton( + autofocus: true, + focusNode: _errorCloseFocusNode, + onPressed: _close, + onBack: _close, + onNavigateRight: () => _errorRetryFocusNode.requestFocus(), + useBackgroundFocus: true, + child: TextButton(onPressed: _close, child: Text(t.common.close)), + ), + FocusableButton( + focusNode: _errorRetryFocusNode, + onPressed: _startServer, + onBack: _close, + onNavigateLeft: () => _errorCloseFocusNode.requestFocus(), + useBackgroundFocus: true, + child: TextButton(onPressed: _startServer, child: Text(t.common.retry)), + ), ], ); } @@ -164,7 +166,14 @@ class _RemoteSessionDialogState extends State with MountedS ], ), ), - IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()), + FocusableButton( + focusNode: _closeFocusNode, + onPressed: _close, + onBack: _close, + onNavigateDown: () => _toggleFocusNode.requestFocus(), + useBackgroundFocus: true, + child: IconButton(icon: const Icon(Icons.close), onPressed: _close), + ), ], ), const SizedBox(height: 24), @@ -180,22 +189,33 @@ class _RemoteSessionDialogState extends State with MountedS Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon( + FocusableButton( + autofocus: true, + focusNode: _toggleFocusNode, onPressed: _toggleServer, - icon: Icon(provider.isHostServerRunning ? Icons.stop : Icons.play_arrow), - label: Text( - provider.isHostServerRunning - ? t.companionRemote.session.stopServer - : t.companionRemote.session.startServer, + onBack: _close, + onNavigateUp: () => _closeFocusNode.requestFocus(), + onNavigateRight: () => _minimizeFocusNode.requestFocus(), + useBackgroundFocus: true, + child: TextButton.icon( + onPressed: _toggleServer, + icon: Icon(provider.isHostServerRunning ? Icons.stop : Icons.play_arrow), + label: Text( + provider.isHostServerRunning + ? t.companionRemote.session.stopServer + : t.companionRemote.session.startServer, + ), ), ), const SizedBox(width: 8), FocusableButton( - onPressed: () => Navigator.of(context).pop(), - child: FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.companionRemote.session.minimize), - ), + focusNode: _minimizeFocusNode, + onPressed: _close, + onBack: _close, + onNavigateUp: () => _closeFocusNode.requestFocus(), + onNavigateLeft: () => _toggleFocusNode.requestFocus(), + useBackgroundFocus: true, + child: FilledButton(onPressed: _close, child: Text(t.companionRemote.session.minimize)), ), ], ), diff --git a/test/widgets/remote_session_dialog_test.dart b/test/widgets/remote_session_dialog_test.dart new file mode 100644 index 00000000..60ff3531 --- /dev/null +++ b/test/widgets/remote_session_dialog_test.dart @@ -0,0 +1,141 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/providers/companion_remote_provider.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/companion_remote/remote_session_dialog.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + SettingsService.resetForTesting(); + }); + + testWidgets('opening dialog does not start a stopped server', (tester) async { + final provider = _FakeCompanionRemoteProvider(); + addTearDown(provider.dispose); + + await _pumpDialogLauncher(tester, provider); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(RemoteSessionDialog), findsOneWidget); + expect(provider.startCount, 0); + expect(provider.isHostServerRunning, isFalse); + expect(find.text(t.companionRemote.session.serverStopped), findsWidgets); + }); + + testWidgets('D-pad select activates Stop server', (tester) async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.enableCompanionRemoteServer, true); + final provider = _FakeCompanionRemoteProvider(isHostServerRunning: true); + addTearDown(provider.dispose); + + await _pumpDialogLauncher(tester, provider); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text(t.companionRemote.session.stopServer), findsOneWidget); + + await _pressDpadKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(provider.stopCount, 1); + expect(provider.isHostServerRunning, isFalse); + expect(settings.read(SettingsService.enableCompanionRemoteServer), isFalse); + expect(find.text(t.companionRemote.session.startServer), findsOneWidget); + }); + + testWidgets('D-pad can move to Minimize and close dialog', (tester) async { + final provider = _FakeCompanionRemoteProvider(); + addTearDown(provider.dispose); + + await _pumpDialogLauncher(tester, provider); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await _pressDpadKey(tester, LogicalKeyboardKey.arrowRight, PhysicalKeyboardKey.arrowRight); + await _pressDpadKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(find.byType(RemoteSessionDialog), findsNothing); + }); +} + +class _FakeCompanionRemoteProvider extends CompanionRemoteProvider { + _FakeCompanionRemoteProvider({bool isHostServerRunning = false}) : _isHostServerRunning = isHostServerRunning; + + bool _isHostServerRunning; + int startCount = 0; + int stopCount = 0; + + @override + bool get isHostServerRunning => _isHostServerRunning; + + @override + Future startHostServer() async { + startCount++; + _isHostServerRunning = true; + notifyListeners(); + } + + @override + Future stopHostServer() async { + stopCount++; + _isHostServerRunning = false; + notifyListeners(); + } +} + +Future _pumpDialogLauncher(WidgetTester tester, CompanionRemoteProvider provider) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: TextButton(onPressed: () => RemoteSessionDialog.show(context), child: const Text('Open')), + ); + }, + ), + ), + ), + ); +} + +Future _pressDpadKey(WidgetTester tester, LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) async { + _dispatchKey( + KeyDownEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + await tester.pump(); +} + +KeyEventResult _dispatchKey(KeyEvent event) { + FocusNode? node = FocusManager.instance.primaryFocus; + while (node != null) { + final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + if (result == KeyEventResult.handled) return result; + node = node.parent; + } + return KeyEventResult.ignored; +}