feat: player-aware companion remote with syncState, track cycling

This commit is contained in:
edde746
2026-02-10 12:32:00 +01:00
parent 5c3fbfdff8
commit d1af24e0a7
10 changed files with 316 additions and 87 deletions
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>15.5</string>
</dict>
</plist>
+1 -1
View File
@@ -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'
+76 -1
View File
@@ -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
+24 -6
View File
@@ -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",
@@ -49,4 +49,5 @@ enum RemoteCommandType {
deviceInfo,
disconnect,
ack,
syncState,
}
@@ -26,6 +26,7 @@ class CompanionRemoteProvider with ChangeNotifier {
String _platform = 'unknown';
final List<TrustedDevice> _trustedDevices = [];
final List<RecentRemoteSession> _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<TrustedDevice> get trustedDevices => List.unmodifiable(_trustedDevices);
List<RecentRemoteSession> 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();
}
@@ -211,10 +211,11 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
child: Column(
children: [
SegmentedButton<int>(
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<int> selection) {
@@ -236,6 +237,8 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
}
Widget _buildNavigationTab() {
final isPlayerActive = context.watch<CompanionRemoteProvider>().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<CompanionRemoteProvider>().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,
),
),
+106 -20
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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>();
_companionRemoteProvider!.sendCommand(
RemoteCommandType.syncState,
data: {'playerActive': true},
);
} catch (_) {}
}
void _cleanupCompanionRemoteCallbacks() {
@@ -1186,6 +1212,84 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<void> _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<void> _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<VideoPlayerScreen> 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<VideoPlayerScreen> 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;
@@ -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:
@@ -258,6 +258,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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