fix: exoplayer pip
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -794,6 +794,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<bool>(
|
||||
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<bool>(
|
||||
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<VideoPlayerScreen> 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<VideoPlayerScreen> 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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool> 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
|
||||
|
||||
Reference in New Issue
Block a user