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