fix(player): harden Apple PiP handling

This commit is contained in:
edde746
2026-05-08 07:40:41 +02:00
parent e10cc7d273
commit 1e6bd01987
8 changed files with 97 additions and 69 deletions
@@ -27,13 +27,11 @@ import UIKit
func setup(with layer: CALayer, containerView: UIView) {} func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {} func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {} func warmLayer(currentTime: Double, isPlaying: Bool) {}
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {}
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
completion(false) completion(false)
} }
func stopPip() {} func stopPip() {}
func invalidatePlaybackState() {} func invalidatePlaybackState() {}
func flushLayer() {}
func syncTimebase(currentTime: Double, isPlaying: Bool) {} func syncTimebase(currentTime: Double, isPlaying: Bool) {}
func teardown() {} func teardown() {}
} }
@@ -112,11 +110,6 @@ import UIKit
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
} }
/// vo_avfoundation renders into the inline display layer, so PiP reuses
/// the current video frame instead of pushing synthetic buffers here.
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
}
/// Sync the layer's controlTimebase with the actual playback position. /// Sync the layer's controlTimebase with the actual playback position.
/// This makes the PiP progress bar show the correct time. /// This makes the PiP progress bar show the correct time.
func syncTimebase(currentTime: Double, isPlaying: Bool) { func syncTimebase(currentTime: Double, isPlaying: Bool) {
@@ -208,9 +201,6 @@ import UIKit
delegateHelper = nil delegateHelper = nil
} }
/// PiP shares the inline display layer, so cleanup must not flush it.
func flushLayer() {
}
} }
// MARK: - PiP Delegate Helper (iOS 15+) // MARK: - PiP Delegate Helper (iOS 15+)
+1
View File
@@ -162,6 +162,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
override func updateEDRMode(sigPeak: Double) { override func updateEDRMode(sigPeak: Double) {
guard let videoLayer else { return } guard let videoLayer else { return }
let hdrEnabled = self.hdrEnabled
var edrHeadroom: CGFloat = 1.0 var edrHeadroom: CGFloat = 1.0
#if os(iOS) #if os(iOS)
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
+67
View File
@@ -0,0 +1,67 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerPipMethods on VideoPlayerScreenState {
/// Initialize VideoFilterManager and VideoPIPManager if not already set up.
/// Called from both live TV and VOD playback paths.
Future<void> _initVideoFilterAndPip() async {
if (player == null || _videoFilterManager != null) return;
final settings = await SettingsService.getInstance();
_videoFilterManager = VideoFilterManager(
player: player!,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
);
_videoFilterManager!.updateVideoFilter();
_videoPIPManager = VideoPIPManager(player: player!);
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
_videoPIPManager!.isPipActive.addListener(_onPipStateChanged);
}
Future<void> _togglePIPMode() async {
final result = await _videoPIPManager?.togglePIP();
if (result != null && !result.$1 && mounted) {
_restorePipFiltersAfterExit();
showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed);
}
}
void _preparePipFiltersForEntry() {
if (_pipFiltersPrepared) return;
_pipFiltersPrepared = true;
_videoFilterManager?.enterPipMode();
}
void _restorePipFiltersAfterExit() {
final filterManager = _videoFilterManager;
if (filterManager == null) {
_pipFiltersPrepared = false;
return;
}
final restoreAmbient = filterManager.hadAmbientLightingBeforePip;
filterManager.exitPipMode();
if (restoreAmbient) {
filterManager.clearPipAmbientLightingFlag();
_restoreAmbientLighting();
}
_pipFiltersPrepared = false;
}
/// Handle PiP state changes to restore video scaling when exiting PiP
void _onPipStateChanged() {
final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value;
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
if (_videoPIPManager == null || _videoFilterManager == null) return;
if (isInPip) {
_preparePipFiltersForEntry();
} else {
_restorePipFiltersAfterExit();
}
}
}
@@ -335,7 +335,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (_autoPipEnabled) { if (_autoPipEnabled) {
PipService.onAutoPipEntering = () { PipService.onAutoPipEntering = () {
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
_videoFilterManager?.enterPipMode(); _preparePipFiltersForEntry();
}; };
if (player!.state.playing) { if (player!.state.playing) {
unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true)); unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true));
@@ -1,55 +1,6 @@
part of '../../video_player_screen.dart'; part of '../../video_player_screen.dart';
extension _VideoPlayerPipShaderMethods on VideoPlayerScreenState { extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
/// Initialize VideoFilterManager and VideoPIPManager if not already set up.
/// Called from both live TV and VOD playback paths.
Future<void> _initVideoFilterAndPip() async {
if (player == null || _videoFilterManager != null) return;
final settings = await SettingsService.getInstance();
_videoFilterManager = VideoFilterManager(
player: player!,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
);
_videoFilterManager!.updateVideoFilter();
_videoPIPManager = VideoPIPManager(player: player!);
_videoPIPManager!.onBeforeEnterPip = () {
_videoFilterManager?.enterPipMode();
};
_videoPIPManager!.isPipActive.addListener(_onPipStateChanged);
}
Future<void> _togglePIPMode() async {
final result = await _videoPIPManager?.togglePIP();
if (result != null && !result.$1 && mounted) {
showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed);
}
}
/// Handle PiP state changes to restore video scaling when exiting PiP
void _onPipStateChanged() {
final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value;
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
if (_videoPIPManager == null || _videoFilterManager == null) return;
if (isInPip) {
_videoFilterManager!.enterPipMode();
} else {
final restoreAmbient = _videoFilterManager!.hadAmbientLightingBeforePip;
_videoFilterManager!.exitPipMode();
// Restore ambient lighting if it was active before PiP
if (restoreAmbient) {
_videoFilterManager!.clearPipAmbientLightingFlag();
_restoreAmbientLighting();
}
}
}
/// Apply the saved shader preset on playback start. /// Apply the saved shader preset on playback start.
/// Reads directly from SettingsService (synchronous SharedPreferences) to /// Reads directly from SettingsService (synchronous SharedPreferences) to
/// avoid a race with ShaderProvider's async initialization. /// avoid a race with ShaderProvider's async initialization.
+3 -1
View File
@@ -85,7 +85,8 @@ part 'video_player/parts/errors.dart';
part 'video_player/parts/lifecycle.dart'; part 'video_player/parts/lifecycle.dart';
part 'video_player/parts/live_tv.dart'; part 'video_player/parts/live_tv.dart';
part 'video_player/parts/media_controls.dart'; part 'video_player/parts/media_controls.dart';
part 'video_player/parts/pip_shader.dart'; part 'video_player/parts/pip.dart';
part 'video_player/parts/shader.dart';
part 'video_player/parts/playback_prompts.dart'; part 'video_player/parts/playback_prompts.dart';
part 'video_player/parts/playback_services.dart'; part 'video_player/parts/playback_services.dart';
part 'video_player/parts/playback_start.dart'; part 'video_player/parts/playback_start.dart';
@@ -340,6 +341,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _hiddenForBackground = false; bool _hiddenForBackground = false;
bool _autoPipEnabled = false; bool _autoPipEnabled = false;
bool _androidAutoPipTransitionInFlight = false; bool _androidAutoPipTransitionInFlight = false;
bool _pipFiltersPrepared = false;
bool _resumeLiveTimelineOnResume = false; bool _resumeLiveTimelineOnResume = false;
int _rewindOnResume = 0; int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value(); Future<void> _lifecycleTransition = Future<void>.value();
@@ -150,6 +150,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
override func updateEDRMode(sigPeak: Double) { override func updateEDRMode(sigPeak: Double) {
guard let videoLayer else { return } guard let videoLayer else { return }
let hdrEnabled = self.hdrEnabled
var currentHeadroom: CGFloat = 1.0 var currentHeadroom: CGFloat = 1.0
var potentialHeadroom: CGFloat = 1.0 var potentialHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main { if let screen = window?.screen ?? NSScreen.main {
+23 -7
View File
@@ -42,8 +42,18 @@ class MpvPlayerCoreBase: NSObject {
var isDisposing = false var isDisposing = false
var isPipActive = false var isPipActive = false
var isBackgrounded = false var isBackgrounded = false
var hdrEnabled = true private var cachedHDREnabled = true
var lastSigPeak = 0.0 private var cachedLastSigPeak = 0.0
var hdrEnabled: Bool {
cacheLock.lock()
defer { cacheLock.unlock() }
return cachedHDREnabled
}
var lastSigPeak: Double {
cacheLock.lock()
defer { cacheLock.unlock() }
return cachedLastSigPeak
}
/// Properties that must still flow to Dart while backgrounded (state-critical). /// Properties that must still flow to Dart while backgrounded (state-critical).
private static let criticalProperties: Set<String> = [ private static let criticalProperties: Set<String> = [
@@ -149,7 +159,10 @@ class MpvPlayerCoreBase: NSObject {
) { ) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
if name == "hwdec" { if name == "hwdec" {
completion(.success(())) if value != "no" {
print("[MpvPlayerCore] Simulator does not support hardware decoding; forcing hwdec=no")
}
setRawStringPropertyAsync(name, value: "no", completion: completion)
return return
} }
#endif #endif
@@ -195,8 +208,8 @@ class MpvPlayerCoreBase: NSObject {
func setHDREnabled(_ enabled: Bool, completion: ((Result<Void, Error>) -> Void)? = nil) { func setHDREnabled(_ enabled: Bool, completion: ((Result<Void, Error>) -> Void)? = nil) {
cacheLock.lock() cacheLock.lock()
hdrEnabled = enabled cachedHDREnabled = enabled
let sigPeak = lastSigPeak let sigPeak = cachedLastSigPeak
cacheLock.unlock() cacheLock.unlock()
print("[MpvPlayerCore] HDR enabled: \(enabled)") print("[MpvPlayerCore] HDR enabled: \(enabled)")
@@ -361,16 +374,18 @@ class MpvPlayerCoreBase: NSObject {
} }
private func updateVideoGravityIfNeeded(name: String, value: String) { private func updateVideoGravityIfNeeded(name: String, value: String) {
let gravity: AVLayerVideoGravity
cacheLock.lock()
switch name { switch name {
case "panscan": case "panscan":
currentPanscan = Double(value) ?? 0 currentPanscan = Double(value) ?? 0
case "video-aspect-override": case "video-aspect-override":
aspectOverrideActive = value != "no" && value != "-1" && value != "0" aspectOverrideActive = value != "no" && value != "-1" && value != "0"
default: default:
cacheLock.unlock()
return return
} }
let gravity: AVLayerVideoGravity
if aspectOverrideActive { if aspectOverrideActive {
gravity = .resize gravity = .resize
} else if currentPanscan > 0 { } else if currentPanscan > 0 {
@@ -378,6 +393,7 @@ class MpvPlayerCoreBase: NSObject {
} else { } else {
gravity = .resizeAspect gravity = .resizeAspect
} }
cacheLock.unlock()
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.videoLayer?.videoGravity = gravity self?.videoLayer?.videoGravity = gravity
@@ -599,7 +615,7 @@ class MpvPlayerCoreBase: NSObject {
if name == "video-params/sig-peak", let sigPeak = value as? Double { if name == "video-params/sig-peak", let sigPeak = value as? Double {
cacheLock.lock() cacheLock.lock()
lastSigPeak = sigPeak cachedLastSigPeak = sigPeak
cacheLock.unlock() cacheLock.unlock()
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateEDRMode(sigPeak: sigPeak) self.updateEDRMode(sigPeak: sigPeak)