From d1af24e0a7751f272d08ae493e6ce9a8ec98137e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:32:00 +0100 Subject: [PATCH] feat: player-aware companion remote with syncState, track cycling --- ios/Flutter/AppFrameworkInfo.plist | 2 +- ios/Podfile | 2 +- ios/Podfile.lock | 77 +++++++++- ios/Runner.xcodeproj/project.pbxproj | 30 +++- .../companion_remote/remote_command_type.dart | 1 + lib/providers/companion_remote_provider.dart | 13 ++ .../mobile_remote_screen.dart | 131 ++++++++++-------- lib/screens/video_player_screen.dart | 126 ++++++++++++++--- .../companion_remote_receiver.dart | 13 +- .../video_controls/video_controls.dart | 8 +- 10 files changed, 316 insertions(+), 87 deletions(-) diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..41bb3f8b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 13.0 + 15.5 diff --git a/ios/Podfile b/ios/Podfile index e72b0638..a154672b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '14.0' +platform :ios, '15.5' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ebe46af4..4804c94b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,8 +6,56 @@ PODS: - file_picker (0.0.1): - Flutter - Flutter (1.0.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (7.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 6.0.0) + - GoogleMLKit/MLKitCore (7.0.0): + - MLKitCommon (~> 12.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) - in_app_review (2.0.0): - Flutter + - MLImage (1.0.0-beta6) + - MLKitBarcodeScanning (6.0.0): + - MLKitCommon (~> 12.0) + - MLKitVision (~> 8.0) + - MLKitCommon (12.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (8.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta6) + - MLKitCommon (~> 12.0) + - mobile_scanner (6.0.2): + - Flutter + - GoogleMLKit/BarcodeScanning (~> 7.0.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -15,6 +63,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - PromisesObjC (2.4.0) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -61,6 +110,7 @@ DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -74,6 +124,17 @@ DEPENDENCIES: SPEC REPOS: trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC - sqlite3 EXTERNAL SOURCES: @@ -87,6 +148,8 @@ EXTERNAL SOURCES: :path: Flutter in_app_review: :path: ".symlinks/plugins/in_app_review/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/ios" os_media_controls: :path: ".symlinks/plugins/os_media_controls/ios" package_info_plus: @@ -113,10 +176,22 @@ SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 + MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56 + MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2 + MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d + MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e + mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b @@ -126,6 +201,6 @@ SPEC CHECKSUMS: wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: faeab82d8b6e3c9d039ddf62b7666988a3dea540 +PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65cff4e7..37b7b3cc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -214,6 +214,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */, + BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -380,6 +381,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -475,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -496,7 +514,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -609,7 +627,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -660,7 +678,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -683,7 +701,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -710,7 +728,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/lib/models/companion_remote/remote_command_type.dart b/lib/models/companion_remote/remote_command_type.dart index dcf0f3d5..d7285ca1 100644 --- a/lib/models/companion_remote/remote_command_type.dart +++ b/lib/models/companion_remote/remote_command_type.dart @@ -49,4 +49,5 @@ enum RemoteCommandType { deviceInfo, disconnect, ack, + syncState, } diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 1879874b..8d2f093e 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -26,6 +26,7 @@ class CompanionRemoteProvider with ChangeNotifier { String _platform = 'unknown'; final List _trustedDevices = []; final List _recentSessions = []; + bool _isPlayerActive = false; static const String _storageKey = 'companion_remote_trusted_devices'; static const String _lastDeviceKey = 'companion_remote_last_device'; @@ -61,6 +62,7 @@ class CompanionRemoteProvider with ChangeNotifier { RemoteDevice? get connectedDevice => _session?.connectedDevice; List get trustedDevices => List.unmodifiable(_trustedDevices); List get recentSessions => List.unmodifiable(_recentSessions); + bool get isPlayerActive => _isPlayerActive; CompanionRemoteProvider() { _initializeDeviceInfo(); @@ -108,6 +110,8 @@ class CompanionRemoteProvider with ChangeNotifier { if (command.type == RemoteCommandType.deviceInfo) { _handleDeviceInfo(command); + } else if (command.type == RemoteCommandType.syncState) { + _handleSyncState(command); } else if (command.type == RemoteCommandType.ping || command.type == RemoteCommandType.pong || command.type == RemoteCommandType.ack) { @@ -185,6 +189,14 @@ class CompanionRemoteProvider with ChangeNotifier { } } + void _handleSyncState(RemoteCommand command) { + final playerActive = command.data?['playerActive'] as bool? ?? false; + if (_isPlayerActive != playerActive) { + _isPlayerActive = playerActive; + notifyListeners(); + } + } + void _cleanupSubscriptions() { _commandSubscription?.cancel(); _commandSubscription = null; @@ -363,6 +375,7 @@ class CompanionRemoteProvider with ChangeNotifier { _cleanupSubscriptions(); _session = null; + _isPlayerActive = false; _intentionalDisconnect = false; notifyListeners(); } diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 4d39db5d..1dd780a2 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -211,10 +211,11 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { child: Column( children: [ SegmentedButton( + showSelectedIcon: false, segments: const [ - ButtonSegment(value: 0, label: Text('Navigate'), icon: Icon(Icons.navigation)), - ButtonSegment(value: 1, label: Text('Playback'), icon: Icon(Icons.play_arrow)), - ButtonSegment(value: 2, label: Text('Quick'), icon: Icon(Icons.flash_on)), + ButtonSegment(value: 0, label: Text('Remote'), icon: Icon(Icons.navigation)), + ButtonSegment(value: 1, label: Text('Play'), icon: Icon(Icons.play_arrow)), + ButtonSegment(value: 2, label: Text('More'), icon: Icon(Icons.flash_on)), ], selected: {_selectedTab}, onSelectionChanged: (Set selection) { @@ -236,6 +237,8 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { } Widget _buildNavigationTab() { + final isPlayerActive = context.watch().isPlayerActive; + return Column( children: [ const SizedBox(height: 16), @@ -253,41 +256,43 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { ), const SizedBox(height: 32), Center(child: _DPad(onCommand: _sendCommand)), - const SizedBox(height: 32), - Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - _RemoteChip( - icon: Icons.explore, - label: 'Discover', - onPressed: () => _sendCommand(RemoteCommandType.tabDiscover), - ), - _RemoteChip( - icon: Icons.video_library, - label: 'Libraries', - onPressed: () => _sendCommand(RemoteCommandType.tabLibraries), - ), - _RemoteChip( - icon: Icons.search, - label: 'Search', - onPressed: () => _showSearchSheet(switchToSearchTab: true), - ), - _RemoteChip( - icon: Icons.download, - label: 'Downloads', - onPressed: () => _sendCommand(RemoteCommandType.tabDownloads), - ), - _RemoteChip( - icon: Icons.settings, - label: 'Settings', - onPressed: () => _sendCommand(RemoteCommandType.tabSettings), - ), - ], - ), + if (!isPlayerActive) ...[ + const SizedBox(height: 32), + Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _RemoteChip( + icon: Icons.explore, + label: 'Discover', + onPressed: () => _sendCommand(RemoteCommandType.tabDiscover), + ), + _RemoteChip( + icon: Icons.video_library, + label: 'Libraries', + onPressed: () => _sendCommand(RemoteCommandType.tabLibraries), + ), + _RemoteChip( + icon: Icons.search, + label: 'Search', + onPressed: () => _showSearchSheet(switchToSearchTab: true), + ), + _RemoteChip( + icon: Icons.download, + label: 'Downloads', + onPressed: () => _sendCommand(RemoteCommandType.tabDownloads), + ), + _RemoteChip( + icon: Icons.settings, + label: 'Settings', + onPressed: () => _sendCommand(RemoteCommandType.tabSettings), + ), + ], + ), + ], ], ); } @@ -369,6 +374,8 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { } Widget _buildQuickActionsTab() { + final isPlayerActive = context.watch().isPlayerActive; + return Column( children: [ const SizedBox(height: 16), @@ -377,12 +384,25 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { runSpacing: 12, alignment: WrapAlignment.center, children: [ - _RemoteCard(icon: Icons.search, label: 'Search', onPressed: _showSearchSheet), - _RemoteCard( - icon: Icons.fullscreen, - label: 'Fullscreen', - onPressed: () => _sendCommand(RemoteCommandType.fullscreen), - ), + if (!isPlayerActive) + _RemoteCard(icon: Icons.search, label: 'Search', onPressed: _showSearchSheet), + if (isPlayerActive) ...[ + _RemoteCard( + icon: Icons.fullscreen, + label: 'Fullscreen', + onPressed: () => _sendCommand(RemoteCommandType.fullscreen), + ), + _RemoteCard( + icon: Icons.subtitles, + label: 'Subtitles', + onPressed: () => _sendCommand(RemoteCommandType.subtitles), + ), + _RemoteCard( + icon: Icons.audiotrack, + label: 'Audio', + onPressed: () => _sendCommand(RemoteCommandType.audioTracks), + ), + ], ], ), ], @@ -397,16 +417,17 @@ class _DPad extends StatelessWidget { @override Widget build(BuildContext context) { - const size = 80.0; - const centerSize = 60.0; + const size = 72.0; + const gap = 8.0; + const total = size * 3 + gap * 2; return SizedBox( - width: size * 3, - height: size * 3, + width: total, + height: total, child: Stack( children: [ Positioned( - left: size, + left: size + gap, top: 0, child: _DPadButton( icon: Icons.arrow_drop_up, @@ -415,7 +436,7 @@ class _DPad extends StatelessWidget { ), ), Positioned( - left: size, + left: size + gap, bottom: 0, child: _DPadButton( icon: Icons.arrow_drop_down, @@ -425,7 +446,7 @@ class _DPad extends StatelessWidget { ), Positioned( left: 0, - top: size, + top: size + gap, child: _DPadButton( icon: Icons.arrow_left, onPressed: () => onCommand(RemoteCommandType.dpadLeft), @@ -434,7 +455,7 @@ class _DPad extends StatelessWidget { ), Positioned( right: 0, - top: size, + top: size + gap, child: _DPadButton( icon: Icons.arrow_right, onPressed: () => onCommand(RemoteCommandType.dpadRight), @@ -442,13 +463,13 @@ class _DPad extends StatelessWidget { ), ), Positioned( - left: (size * 3 - centerSize) / 2, - top: (size * 3 - centerSize) / 2, + left: size + gap, + top: size + gap, child: _DPadButton( icon: Icons.check, label: 'OK', onPressed: () => onCommand(RemoteCommandType.select), - size: centerSize, + size: size, isPrimary: true, ), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 52454e3e..9aef9df5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -22,7 +22,10 @@ import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../models/companion_remote/remote_command_type.dart'; +import '../providers/companion_remote_provider.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; +import '../services/macos_window_service.dart'; import '../services/discord_rpc_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -45,6 +48,7 @@ import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/track_label_builder.dart' as tlb; import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; @@ -134,6 +138,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Watch Together provider reference (stored early to use in dispose) WatchTogetherProvider? _watchTogetherProvider; + // Companion remote state (stored early for use in dispose) + CompanionRemoteProvider? _companionRemoteProvider; + VoidCallback? _savedOnHome; + /// Get the correct PlexClient for this metadata's server PlexClient _getClientForMetadata(BuildContext context) { return context.getClientForServer(widget.metadata.serverId!); @@ -1174,6 +1182,24 @@ class VideoPlayerScreenState extends State with WidgetsBindin player!.setVolume(newVolume); settings.setVolume(newVolume); }; + receiver.onSubtitles = _cycleSubtitleTrack; + receiver.onAudioTracks = _cycleAudioTrack; + receiver.onFullscreen = _toggleFullscreen; + + // Override home to exit the player first (main screen handler runs after pop) + _savedOnHome = receiver.onHome; + receiver.onHome = () { + if (mounted) _handleBackButton(); + }; + + // Store provider reference for use in dispose and notify remote + try { + _companionRemoteProvider = context.read(); + _companionRemoteProvider!.sendCommand( + RemoteCommandType.syncState, + data: {'playerActive': true}, + ); + } catch (_) {} } void _cleanupCompanionRemoteCallbacks() { @@ -1186,6 +1212,84 @@ class VideoPlayerScreenState extends State with WidgetsBindin receiver.onVolumeUp = null; receiver.onVolumeDown = null; receiver.onVolumeMute = null; + receiver.onSubtitles = null; + receiver.onAudioTracks = null; + receiver.onFullscreen = null; + receiver.onHome = _savedOnHome; + _savedOnHome = null; + + // Notify remote that player is no longer active + _companionRemoteProvider?.sendCommand( + RemoteCommandType.syncState, + data: {'playerActive': false}, + ); + _companionRemoteProvider = null; + } + + void _cycleSubtitleTrack() { + if (player == null) return; + final tracks = player!.state.tracks.subtitle.where((t) => t.id != 'auto').toList(); + if (tracks.isEmpty) return; + + final current = player!.state.track.subtitle; + // tracks includes 'no' (off). Find current index and advance. + final currentIndex = tracks.indexWhere((t) => t.id == current?.id); + final nextIndex = (currentIndex + 1) % tracks.length; + final next = tracks[nextIndex]; + player!.selectSubtitleTrack(next); + _onSubtitleTrackChanged(next); + + if (mounted) { + final label = next.id == 'no' + ? 'Subtitles: Off' + : 'Subtitles: ${tlb.TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; + showAppSnackBar(context, label, duration: const Duration(seconds: 1)); + } + } + + void _cycleAudioTrack() { + if (player == null) return; + final tracks = player!.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); + if (tracks.length <= 1) return; + + final current = player!.state.track.audio; + final currentIndex = tracks.indexWhere((t) => t.id == current?.id); + final nextIndex = (currentIndex + 1) % tracks.length; + final next = tracks[nextIndex]; + player!.selectAudioTrack(next); + _onAudioTrackChanged(next); + + if (mounted) { + final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + showAppSnackBar(context, label, duration: const Duration(seconds: 1)); + } + } + + Future _toggleFullscreen() async { + if (PlatformDetector.isMobile(context)) return; + final isCurrentlyFullscreen = await windowManager.isFullScreen(); + if (Platform.isMacOS) { + if (isCurrentlyFullscreen) { + await MacOSWindowService.exitFullscreen(); + } else { + await MacOSWindowService.enterFullscreen(); + } + } else { + await windowManager.setFullScreen(!isCurrentlyFullscreen); + } + } + + /// Exit fullscreen before leaving the player (Windows/Linux only). + /// macOS is excluded because we can't distinguish native fullscreen + /// from maximized state, so we leave the window state unchanged. + Future _exitFullscreenIfNeeded() async { + if (Platform.isWindows || Platform.isLinux) { + final isFullscreen = await windowManager.isFullScreen(); + if (isFullscreen) { + await windowManager.setFullScreen(false); + await Future.delayed(const Duration(milliseconds: 100)); + } + } } /// Handle back button press @@ -1207,16 +1311,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed && mounted) { await _watchTogetherProvider!.leaveSession(); if (mounted) { - // Exit fullscreen before leaving player (Windows/Linux only) - if (Platform.isWindows || Platform.isLinux) { - final isFullscreen = await windowManager.isFullScreen(); - if (isFullscreen) { - await windowManager.setFullScreen(false); - // Wait for a frame to allow window manager to process the fullscreen exit - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - } - } + await _exitFullscreenIfNeeded(); if (!mounted) return; _isExiting.value = true; Navigator.of(context).pop(true); @@ -1225,16 +1320,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } - // Exit fullscreen before leaving player (Windows/Linux only) - if (Platform.isWindows || Platform.isLinux) { - final isFullscreen = await windowManager.isFullScreen(); - if (isFullscreen) { - await windowManager.setFullScreen(false); - // Wait for a frame to allow window manager to process the fullscreen exit - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - } - } + await _exitFullscreenIfNeeded(); // Default behavior for hosts or non-session users if (!mounted) return; diff --git a/lib/services/companion_remote/companion_remote_receiver.dart b/lib/services/companion_remote/companion_remote_receiver.dart index e9d16c78..77a38097 100644 --- a/lib/services/companion_remote/companion_remote_receiver.dart +++ b/lib/services/companion_remote/companion_remote_receiver.dart @@ -37,6 +37,9 @@ class CompanionRemoteReceiver { VoidCallback? onVolumeUp; VoidCallback? onVolumeDown; VoidCallback? onVolumeMute; + VoidCallback? onSubtitles; + VoidCallback? onAudioTracks; + VoidCallback? onFullscreen; void handleCommand(RemoteCommand command, BuildContext? context) { appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}'); @@ -109,17 +112,23 @@ class CompanionRemoteReceiver { onPreviousTrack?.call(); case RemoteCommandType.subtitles: + onSubtitles?.call(); case RemoteCommandType.audioTracks: - break; // No-op: track cycling not yet implemented + onAudioTracks?.call(); case RemoteCommandType.fullscreen: - simulateKeyPress(LogicalKeyboardKey.keyF); + if (onFullscreen != null) { + onFullscreen!.call(); + } else { + simulateKeyPress(LogicalKeyboardKey.keyF); + } case RemoteCommandType.ping: case RemoteCommandType.pong: case RemoteCommandType.ack: case RemoteCommandType.deviceInfo: case RemoteCommandType.disconnect: + case RemoteCommandType.syncState: break; default: diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 81ff4678..ab6b483f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -258,6 +258,7 @@ class _PlexVideoControlsState extends State with WindowListen _initAlwaysOnTopState(); } + // Focus play/pause button on first frame if in keyboard mode WidgetsBinding.instance.addPostFrameCallback((_) { _focusPlayPauseIfKeyboardMode(); @@ -733,7 +734,12 @@ class _PlexVideoControlsState extends State with WindowListen } void _updateTrafficLightVisibility() async { - await MacOSWindowService.setTrafficLightsVisible(_showControls); + // When maximized or fullscreen, always keep traffic lights visible so the + // user can reach them without the controls-hide-on-mouse-leave race. + // In normal windowed mode, toggle with controls as before. + final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen(); + final visible = isMaximizedOrFullscreen ? true : _showControls; + await MacOSWindowService.setTrafficLightsVisible(visible); } /// Check whether PiP is supported on this device