feat: player-aware companion remote with syncState, track cycling
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,6 +256,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Center(child: _DPad(onCommand: _sendCommand)),
|
||||
if (!isPlayerActive) ...[
|
||||
const SizedBox(height: 32),
|
||||
Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
@@ -289,6 +293,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user