fix: exoplayer pip

This commit is contained in:
edde746
2026-01-25 03:49:22 +01:00
parent 93cd7369a3
commit 328b978ade
9 changed files with 164 additions and 52 deletions
@@ -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)
}
}
}
+11
View File
@@ -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;
}
-1
View File
@@ -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;
+67 -49
View File
@@ -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
View File
@@ -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
+31 -2
View File
@@ -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