fix(player): harden Apple PiP handling
This commit is contained in:
@@ -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+)
|
||||||
|
|||||||
@@ -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, *) {
|
||||||
|
|||||||
@@ -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
-50
@@ -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.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user