From 1e6bd01987a024cadeaa7838d1e4d15f3419c892 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 8 May 2026 07:40:41 +0200 Subject: [PATCH] fix(player): harden Apple PiP handling --- ios/Runner/MpvPlayer/MpvPipController.swift | 10 --- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 1 + lib/screens/video_player/parts/pip.dart | 67 +++++++++++++++++++ .../video_player/parts/playback_start.dart | 2 +- .../parts/{pip_shader.dart => shader.dart} | 51 +------------- lib/screens/video_player_screen.dart | 4 +- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 1 + .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 30 +++++++-- 8 files changed, 97 insertions(+), 69 deletions(-) create mode 100644 lib/screens/video_player/parts/pip.dart rename lib/screens/video_player/parts/{pip_shader.dart => shader.dart} (70%) diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index e4655ba2..7a6c9763 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -27,13 +27,11 @@ import UIKit func setup(with layer: CALayer, containerView: UIView) {} func setAutoStart(_ enabled: Bool) {} func warmLayer(currentTime: Double, isPlaying: Bool) {} - func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {} func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { completion(false) } func stopPip() {} func invalidatePlaybackState() {} - func flushLayer() {} func syncTimebase(currentTime: Double, isPlaying: Bool) {} func teardown() {} } @@ -112,11 +110,6 @@ import UIKit 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. /// This makes the PiP progress bar show the correct time. func syncTimebase(currentTime: Double, isPlaying: Bool) { @@ -208,9 +201,6 @@ import UIKit delegateHelper = nil } - /// PiP shares the inline display layer, so cleanup must not flush it. - func flushLayer() { - } } // MARK: - PiP Delegate Helper (iOS 15+) diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 177472c6..a0291091 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -162,6 +162,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { override func updateEDRMode(sigPeak: Double) { guard let videoLayer else { return } + let hdrEnabled = self.hdrEnabled var edrHeadroom: CGFloat = 1.0 #if os(iOS) if #available(iOS 17.0, *) { diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart new file mode 100644 index 00000000..6aa8a64f --- /dev/null +++ b/lib/screens/video_player/parts/pip.dart @@ -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 _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 _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(); + } + } +} diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 47924f04..3e5d989b 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -335,7 +335,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { if (_autoPipEnabled) { PipService.onAutoPipEntering = () { _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); - _videoFilterManager?.enterPipMode(); + _preparePipFiltersForEntry(); }; if (player!.state.playing) { unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true)); diff --git a/lib/screens/video_player/parts/pip_shader.dart b/lib/screens/video_player/parts/shader.dart similarity index 70% rename from lib/screens/video_player/parts/pip_shader.dart rename to lib/screens/video_player/parts/shader.dart index 228eb471..f79dfbaf 100644 --- a/lib/screens/video_player/parts/pip_shader.dart +++ b/lib/screens/video_player/parts/shader.dart @@ -1,55 +1,6 @@ part of '../../video_player_screen.dart'; -extension _VideoPlayerPipShaderMethods on VideoPlayerScreenState { - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. - /// Called from both live TV and VOD playback paths. - Future _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 _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(); - } - } - } - +extension _VideoPlayerShaderMethods on VideoPlayerScreenState { /// Apply the saved shader preset on playback start. /// Reads directly from SettingsService (synchronous SharedPreferences) to /// avoid a race with ShaderProvider's async initialization. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 326a5329..42ff80d8 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -85,7 +85,8 @@ part 'video_player/parts/errors.dart'; part 'video_player/parts/lifecycle.dart'; part 'video_player/parts/live_tv.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_services.dart'; part 'video_player/parts/playback_start.dart'; @@ -340,6 +341,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _hiddenForBackground = false; bool _autoPipEnabled = false; bool _androidAutoPipTransitionInFlight = false; + bool _pipFiltersPrepared = false; bool _resumeLiveTimelineOnResume = false; int _rewindOnResume = 0; Future _lifecycleTransition = Future.value(); diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 49bb70bd..c5227569 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -150,6 +150,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { override func updateEDRMode(sigPeak: Double) { guard let videoLayer else { return } + let hdrEnabled = self.hdrEnabled var currentHeadroom: CGFloat = 1.0 var potentialHeadroom: CGFloat = 1.0 if let screen = window?.screen ?? NSScreen.main { diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index cd0ae924..29f96f09 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -42,8 +42,18 @@ class MpvPlayerCoreBase: NSObject { var isDisposing = false var isPipActive = false var isBackgrounded = false - var hdrEnabled = true - var lastSigPeak = 0.0 + private var cachedHDREnabled = true + 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). private static let criticalProperties: Set = [ @@ -149,7 +159,10 @@ class MpvPlayerCoreBase: NSObject { ) { #if targetEnvironment(simulator) 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 } #endif @@ -195,8 +208,8 @@ class MpvPlayerCoreBase: NSObject { func setHDREnabled(_ enabled: Bool, completion: ((Result) -> Void)? = nil) { cacheLock.lock() - hdrEnabled = enabled - let sigPeak = lastSigPeak + cachedHDREnabled = enabled + let sigPeak = cachedLastSigPeak cacheLock.unlock() print("[MpvPlayerCore] HDR enabled: \(enabled)") @@ -361,16 +374,18 @@ class MpvPlayerCoreBase: NSObject { } private func updateVideoGravityIfNeeded(name: String, value: String) { + let gravity: AVLayerVideoGravity + cacheLock.lock() switch name { case "panscan": currentPanscan = Double(value) ?? 0 case "video-aspect-override": aspectOverrideActive = value != "no" && value != "-1" && value != "0" default: + cacheLock.unlock() return } - let gravity: AVLayerVideoGravity if aspectOverrideActive { gravity = .resize } else if currentPanscan > 0 { @@ -378,6 +393,7 @@ class MpvPlayerCoreBase: NSObject { } else { gravity = .resizeAspect } + cacheLock.unlock() DispatchQueue.main.async { [weak self] in self?.videoLayer?.videoGravity = gravity @@ -599,7 +615,7 @@ class MpvPlayerCoreBase: NSObject { if name == "video-params/sig-peak", let sigPeak = value as? Double { cacheLock.lock() - lastSigPeak = sigPeak + cachedLastSigPeak = sigPeak cacheLock.unlock() DispatchQueue.main.async { self.updateEDRMode(sigPeak: sigPeak)