diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5be6d5cd..33b7057f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -825,6 +825,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } + fun onPipModeChanged(isInPipMode: Boolean) { + activity.runOnUiThread { + // Force recalculation of surface size based on new container dimensions + // Use a slight delay to allow the window to resize first + handler.postDelayed({ + val videoSize = exoPlayer?.videoSize + if (videoSize != null && videoSize.width > 0 && videoSize.height > 0) { + updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) + } + }, 100) + } + } + // Audio Focus fun requestAudioFocus(): Boolean { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 88fc0802..87232c8b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -469,6 +469,18 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ) } + // PiP Mode handling + + fun onPipModeChanged(isInPipMode: Boolean) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.onPipModeChanged(isInPipMode) + } else { + playerCore?.onPipModeChanged(isInPipMode) + } + } + } + // ExoPlayerDelegate override fun onPropertyChange(name: String, value: Any?) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 96d1bb11..3361b0c0 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -506,6 +506,10 @@ class MpvPlayerCore(private val activity: Activity) : } } + fun onPipModeChanged(isInPipMode: Boolean) { + // MPV handles aspect ratio internally via its own surface management + } + // Frame Rate Matching private fun getDisplayManager(): DisplayManager { diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt index a585e8af..c80f016d 100644 --- a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -117,5 +117,10 @@ class MainActivity : FlutterActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) MethodChannel( flutterEngine!!.dartExecutor.binaryMessenger, PIP_CHANNEL ).invokeMethod( "onPipChanged" , isInPictureInPictureMode) + + // Notify ExoPlayer plugin to resize video surface for PiP + flutterEngine?.plugins?.get(ExoPlayerPlugin::class.java)?.let { plugin -> + (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) + } } } diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index b4e9f7fd..3ed99c57 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -186,6 +186,17 @@ class PlayerAndroid extends PlayerBase { return (state.position.inMilliseconds / 1000.0).toString(); case 'duration': return (state.duration.inMilliseconds / 1000.0).toString(); + // Video dimensions - query from ExoPlayer stats + case 'width': + case 'dwidth': + final stats = await getStats(); + final width = stats['videoWidth']; + return width?.toString(); + case 'height': + case 'dheight': + final stats = await getStats(); + final height = stats['videoHeight']; + return height?.toString(); default: return null; } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index eda6ab56..6d961af1 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -26,7 +26,6 @@ import '../mixins/watch_state_aware.dart'; import '../utils/watch_state_notifier.dart'; import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; -import '../utils/platform_detector.dart'; class SeasonDetailScreen extends StatefulWidget { final PlexMetadata season; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 803bfd1f..b8bc9e1e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -794,6 +794,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin // PIP Manager _videoPIPManager = VideoPIPManager(player: player!); + _videoPIPManager!.onBeforeEnterPip = () { + _videoFilterManager?.enterPipMode(); + }; + _videoPIPManager!.isPipActive.addListener(_onPipStateChanged); } // Add external subtitles while paused, then start playback @@ -874,6 +878,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + /// Handle PiP state changes to restore video scaling when exiting PiP + void _onPipStateChanged() { + if (_videoPIPManager == null || _videoFilterManager == null) return; + + final isInPip = _videoPIPManager!.isPipActive.value; + // Only handle exit - entry is handled by onBeforeEnterPip callback + if (!isInPip) { + _videoFilterManager!.exitPipMode(); + } + } + /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void _cycleBoxFitMode() { setState(() { @@ -986,60 +1001,60 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (_isHandlingBack) return; _isHandlingBack = true; try { - // For non-host participants, show leave session confirmation - if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Leave Session?'), - content: const Text('You will be removed from the session.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - style: FilledButton.styleFrom(backgroundColor: Theme.of(dialogContext).colorScheme.error), - child: const Text('Leave'), - ), - ], - ), - ); + // For non-host participants, show leave session confirmation + if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Leave Session?'), + content: const Text('You will be removed from the session.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: FilledButton.styleFrom(backgroundColor: Theme.of(dialogContext).colorScheme.error), + child: const Text('Leave'), + ), + ], + ), + ); - if (confirmed == true && 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; + if (confirmed == true && 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; + } } + if (!mounted) return; + _isExiting.value = true; + Navigator.of(context).pop(true); } + } + 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; - _isExiting.value = true; - Navigator.of(context).pop(true); } } - 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; - } - } - - // Default behavior for hosts or non-session users - if (!mounted) return; - _isExiting.value = true; - Navigator.of(context).pop(true); + // Default behavior for hosts or non-session users + if (!mounted) return; + _isExiting.value = true; + Navigator.of(context).pop(true); } finally { _isHandlingBack = false; } @@ -1073,7 +1088,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.stopTracking(); _progressTracker?.dispose(); - // Dispose video filter manager + // Remove PiP state listener, clear callback, and dispose video filter manager + _videoPIPManager?.isPipActive.removeListener(_onPipStateChanged); + _videoPIPManager?.onBeforeEnterPip = null; _videoFilterManager?.dispose(); // Cancel stream subscriptions @@ -1638,10 +1655,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Update player size when layout changes final newSize = Size(constraints.maxWidth, constraints.maxHeight); - // Update player size in video filter manager and native layer + // Update player size in video filter manager, PiP manager, and native layer WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && player != null) { _videoFilterManager?.updatePlayerSize(newSize); + _videoPIPManager?.updatePlayerSize(newSize); // Update Metal layer frame on iOS/macOS for rotation player!.updateFrame(); } diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index dd0dcb1c..424694e5 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -21,6 +21,9 @@ class VideoFilterManager { /// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode = 0; + /// Store the boxFitMode before entering PiP so it can be restored + int? _prePipBoxFitMode; + /// Track if a pinch gesture is occurring (public for gesture tracking) bool isPinching = false; @@ -57,6 +60,24 @@ class VideoFilterManager { updateVideoFilter(); } + /// Force contain mode for PiP (no cropping/stretching) + void enterPipMode() { + if (_boxFitMode != 0) { + _prePipBoxFitMode = _boxFitMode; + _boxFitMode = 0; // Contain mode + updateVideoFilter(); + } + } + + /// Restore previous mode when exiting PiP + void exitPipMode() { + if (_prePipBoxFitMode != null) { + _boxFitMode = _prePipBoxFitMode!; + _prePipBoxFitMode = null; + updateVideoFilter(); + } + } + /// Update player size when layout changes void updatePlayerSize(Size size) { // Check if size actually changed to avoid unnecessary updates diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index 8aa75782..6ce3f3b2 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -11,6 +11,14 @@ class VideoPIPManager { Size? _playerSize; Size? get playerSize => _playerSize; + /// Callback to prepare video filter before entering PiP + VoidCallback? onBeforeEnterPip; + + /// Update player size for PiP aspect ratio calculation + void updatePlayerSize(Size size) { + _playerSize = size; + } + /// Access PiP state from the service ValueNotifier get isPipActive => PipService().isPipActive; @@ -20,7 +28,14 @@ class VideoPIPManager { final supported = await PipService.isSupported(); if (!supported) return (false, 'PiP not supported on this device'); - // Try to get actual video dimensions from MPV + // Reset video filter to contain mode BEFORE entering PiP + // This prevents the zoomed/cropped view from being shown in PiP + onBeforeEnterPip?.call(); + + // Wait a frame for the filter change to take effect + await Future.delayed(const Duration(milliseconds: 50)); + + // Get display dimensions for correct aspect ratio (accounts for pixel aspect ratio) int? width; int? height; @@ -32,7 +47,21 @@ class VideoPIPManager { height = int.tryParse(dheight); } } catch (_) { - // Fall through to use viewport size + // Fall through to storage dimensions + } + + // Fallback to storage dimensions (less accurate for anamorphic content) + if (width == null || height == null) { + try { + final videoWidth = await player.getProperty('width'); + final videoHeight = await player.getProperty('height'); + if (videoWidth != null && videoHeight != null) { + width = int.tryParse(videoWidth); + height = int.tryParse(videoHeight); + } + } catch (_) { + // Fall through to viewport size + } } // Fall back to viewport size if video dimensions unavailable