feat(ios): add picture-in-picture support

close #44
This commit is contained in:
edde746
2026-03-02 14:06:11 +01:00
parent 5f1c51ff6c
commit a008f86ee2
24 changed files with 765 additions and 91 deletions
+9 -5
View File
@@ -12,9 +12,10 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C12D873DE8572579684A82C /* Pods_Runner.framework */; };
6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; };
6A8A46212EDB320D0057B88C /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit-GPL */; };
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit */; };
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -57,6 +58,7 @@
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -86,7 +88,7 @@
buildActionMask = 2147483647;
files = (
50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */,
6A8A46212EDB320D0057B88C /* MPVKit-GPL in Frameworks */,
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -115,6 +117,7 @@
children = (
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */,
);
path = MpvPlayer;
sourceTree = "<group>";
@@ -415,6 +418,7 @@
files = (
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
@@ -783,16 +787,16 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 86035ac4a2544e7a9b3cc0ffde5bef4549d4869a;
revision = b2179eec26c6ceda339e6c6b512e457341768761;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
6A8A46202EDB320D0057B88C /* MPVKit-GPL */ = {
6A8A46202EDB320D0057B88C /* MPVKit */ = {
isa = XCSwiftPackageProductDependency;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = "MPVKit-GPL";
productName = "MPVKit";
};
/* End XCSwiftPackageProductDependency section */
};
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a"
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a"
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
}
}
],
+1
View File
@@ -51,6 +51,7 @@
<string>Main</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
</array>
<key>UISupportedInterfaceOrientations</key>
+363
View File
@@ -0,0 +1,363 @@
import AVKit
import UIKit
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
/// Called when PiP is about to start (system or app-initiated)
func pipWillStart()
func pipDidStart()
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
/// Get total duration in seconds
var pipDuration: Double { get }
}
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
weak var delegate: MpvPipDelegate?
/// Pointer to the sample buffer layer for passing to mpv as `wid`
var layerPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
}
// MARK: - Initialization
override init() {
super.init()
setup()
}
private func setup() {
guard #available(iOS 15.0, *) else { return }
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
let view = UIView(frame: window.bounds)
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
createPipController()
}
/// Helper that conforms to the iOS 15+ delegate protocols
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *) else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = helper
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
}
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
func setAutoStart(_ enabled: Bool) {
guard #available(iOS 14.2, *) else { return }
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
}
/// Push a black frame to the sample buffer layer so PiP has content
/// to display immediately (before vo_pip decodes the first real frame).
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault, Int(width), Int(height),
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
)
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
// Fill with black
CVPixelBufferLockBaseAddress(pb, [])
if let base = CVPixelBufferGetBaseAddress(pb) {
memset(base, 0, CVPixelBufferGetDataSize(pb))
}
CVPixelBufferUnlockBaseAddress(pb, [])
// Use current timebase time for PTS (if available) so the frame isn't stale
let pts: CMTime
if let tb = sampleBufferLayer.controlTimebase {
pts = CMTimebaseGetTime(tb)
} else {
pts = CMTime(value: 0, timescale: 30)
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
// Create format description and sample buffer
var formatDesc: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescriptionOut: &formatDesc
)
guard let fmt = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescription: fmt,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard let sb = sampleBuffer else { return }
// Set DisplayImmediately so it shows regardless of timebase timing
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true) as? [NSMutableDictionary],
let dict = attachments.first {
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
}
sampleBufferLayer.enqueue(sb)
}
/// 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) {
guard let timebase = sampleBufferLayer.controlTimebase else { return }
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
CMTimebaseSetTime(timebase, time: cmTime)
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
}
/// Ensure the layer has a timebase and blank frame so the system considers
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
func warmLayer(currentTime: Double, isPlaying: Bool) {
if sampleBufferLayer.controlTimebase == nil {
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
allocator: kCFAllocatorDefault,
sourceClock: CMClockGetHostTimeClock(),
timebaseOut: &timebase
)
if let tb = timebase {
sampleBufferLayer.controlTimebase = tb
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
}
// MARK: - Public API
static var isSupported: Bool {
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
}
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
/// readiness check since the scene is about to deactivate.
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
guard let pipController = pipController else {
completion(false)
return
}
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = sampleBufferLayer.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = sampleBufferLayer.isReadyForDisplay
} else {
hasFrame = true
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
attempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
} else {
print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))")
completion(false)
}
}
tryStart()
}
func stopPip() {
pipController?.stopPictureInPicture()
}
/// Invalidate the playback state so PiP updates its UI (play/pause button)
func invalidatePlaybackState() {
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP removes the container view from the window and
/// destroys the AVPictureInPictureController so the system can no longer
/// trigger auto-PiP after the player is disposed.
func teardown() {
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = nil
delegateHelper = nil
sampleBufferLayer.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
}
}
// MARK: - PiP Delegate Helper (iOS 15+)
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
AVPictureInPictureSampleBufferPlaybackDelegate
{
weak var controller: MpvPipController?
private var isRestoring = false
init(controller: MpvPipController) {
self.controller = controller
super.init()
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will stop")
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
}
func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
let duration = controller?.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
return !(controller?.delegate?.isPipPlaying ?? false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
) {
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
controller?.delegate?.pipSkip(byInterval: seconds)
completionHandler()
}
}
+105 -7
View File
@@ -51,6 +51,10 @@ class MpvPlayerCore: NSObject {
private(set) var isInitialized = false
private var isDisposing = false // Flag to prevent race conditions during disposal
// PiP state
var isPipActive = false
var isPipStarting = false // VO switched for PiP, waiting for first frame
// HDR settings
private var hdrEnabled = true // User preference for HDR
private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation
@@ -90,9 +94,6 @@ class MpvPlayerCore: NSObject {
// Add container view to window (behind Flutter's root view controller)
window.insertSubview(container, at: 0)
print(
"[MpvPlayerCore] Metal layer added to window via container view, frame: \(layer.frame)")
// Initialize MPV with this Metal layer
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
@@ -161,7 +162,6 @@ class MpvPlayerCore: NSObject {
// Observe video-params/sig-peak for HDR detection
mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
print("[MpvPlayerCore] MPV initialized successfully")
return true
}
@@ -183,6 +183,10 @@ class MpvPlayerCore: NSObject {
}
@objc private func enterBackground() {
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
}
// Disable video output to fix black screen when returning from background
print("[MpvPlayerCore] Entering background - disabling video")
if mpv != nil {
@@ -191,6 +195,11 @@ class MpvPlayerCore: NSObject {
}
@objc private func enterForeground() {
// Skip if PiP is active - video is already enabled
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
}
// Re-enable video output
print("[MpvPlayerCore] Entering foreground - enabling video")
if mpv != nil {
@@ -305,6 +314,98 @@ class MpvPlayerCore: NSObject {
}
}
// MARK: - PiP VO Switching
/// Switch from gpu-next to vo_pip for PiP rendering.
/// vo_pip feeds VideoToolbox CVPixelBuffers to an AVSampleBufferDisplayLayer
/// which is required by AVPictureInPictureController.
///
/// Strategy: disable the video track (`vid=no`) to tear down the active VO,
/// then set `wid` and `vo` while no VO is running, then re-enable video.
/// This avoids the crash where gpu-next calls `drawableSize` on an
/// AVSampleBufferDisplayLayer, and ensures the new VO reads `wid` at init.
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard let mpv = mpv else { return false }
print("[MpvPlayerCore] Switching to pip VO for PiP")
// Detach the Metal layer from the view hierarchy on the main thread BEFORE
// vid=no, so the 'vo' thread VO uninit has no UIKit work to do (avoids
// "layout engine from background thread" crash).
metalLayer?.removeFromSuperlayer()
// 1. Disable video track tears down the current VO completely
mpv_set_property_string(mpv, "vid", "no")
// 2. Point wid at the PiP sample-buffer layer (safe no active VO)
var ptr = Int64(Int(bitPattern: layerPtr))
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &ptr)
// 3. Set VO to pip (stored, not yet created)
mpv_set_property_string(mpv, "vo", "pip")
// 4. Re-enable video creates pip VO with our layer as wid
mpv_set_property_string(mpv, "vid", "auto")
print("[MpvPlayerCore] Switched to pip VO successfully")
return true
}
/// Switch back from vo_pip to gpu-next VO for normal rendering.
/// Same strategy: vid=no reconfigure vid=auto.
func switchToGpuNextVO() -> Bool {
guard let mpv = mpv, let metalLayer = metalLayer else { return false }
print("[MpvPlayerCore] Switching back to gpu-next VO")
// 1. Disable video track tears down pip VO
mpv_set_property_string(mpv, "vid", "no")
// 2. Point wid back at the Metal layer (safe no active VO)
var layer = metalLayer
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
// 3. Restore gpu-next + Vulkan/MoltenVK settings (read at VO creation)
mpv_set_property_string(mpv, "gpu-api", "vulkan")
mpv_set_property_string(mpv, "gpu-context", "moltenvk")
mpv_set_property_string(mpv, "vo", "gpu-next")
// 4. Re-enable video creates gpu-next VO with Metal layer as wid
mpv_set_property_string(mpv, "vid", "auto")
// Re-attach the Metal layer to the view hierarchy (was detached in switchToPipVO)
if metalLayer.superlayer == nil, let container = containerView {
container.layer.addSublayer(metalLayer)
}
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
return true
}
/// Whether mpv is currently paused
var isPaused: Bool {
guard let mpv = mpv else { return true }
var flag: Int32 = 0
mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag)
return flag != 0
}
/// Current playback duration in seconds
var duration: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value)
return value
}
/// Current playback time in seconds
var timePos: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value)
return value
}
// MARK: - Visibility
func setVisible(_ visible: Bool) {
@@ -317,7 +418,6 @@ class MpvPlayerCore: NSObject {
}
container.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))")
}
func updateFrame(_ frame: CGRect? = nil) {
@@ -337,8 +437,6 @@ class MpvPlayerCore: NSObject {
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
print("[MpvPlayerCore] updateFrame: \(container.frame)")
}
// MARK: - Private Helpers
+208 -50
View File
@@ -1,5 +1,6 @@
import UIKit
import Flutter
import AVKit
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate {
@@ -11,41 +12,47 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:]
// PiP
private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false
private var isManualPipRequest = false
private var pipTimebaseSyncTimer: Timer?
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
// Method channel for commands
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger()
)
// Event channel for state updates
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger()
)
let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip",
binaryMessenger: registrar.messenger()
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
instance.pipChannel = pipChannel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
print("[MpvPlayerPlugin] Registered with Flutter")
pipChannel.setMethodCallHandler(instance.handlePipCall)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
print("[MpvPlayerPlugin] Event stream connected")
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
print("[MpvPlayerPlugin] Event stream disconnected")
return nil
}
@@ -55,39 +62,149 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "setVisible":
handleSetVisible(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
case "updateFrame":
handleUpdateFrame(result: result)
case "setLogLevel":
handleSetLogLevel(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - PiP
private func ensurePipController() -> MpvPipController {
if let existing = pipController { return existing }
let controller = MpvPipController()
controller.delegate = self
pipController = controller
return controller
}
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
autoPipEnabled = ready
if ready {
let pip = ensurePipController()
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidActivate), name: UIScene.didActivateNotification, object: nil)
} else {
pipController?.setAutoStart(false)
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func switchToPipAndPrepare() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
let pip = ensurePipController()
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
playerCore.isPipStarting = true
pip.pushBlankFrame()
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
}
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard MpvPipController.isSupported else {
result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"])
return
}
guard playerCore != nil else {
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
return
}
guard let pip = switchToPipAndPrepare() else {
result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"])
return
}
isManualPipRequest = manual
pip.startPip(waitForFrame: manual) { [weak self] started in
if started {
result?(["success": true])
} else {
self?.cleanupPip(notify: false)
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"])
}
}
}
/// Unified cleanup for all PiP exit paths
private func cleanupPip(notify: Bool, pause: Bool = false) {
playerCore?.isPipStarting = false
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
_ = playerCore?.switchToGpuNextVO()
if pause { playerCore?.setProperty("pause", value: "yes") }
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
}
/// Scene became active re-warm the layer so auto-PiP stays possible
@objc private func sceneDidActivate() {
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
}
// MARK: - Timebase Sync
private func syncPipTimebase() {
guard let playerCore = playerCore, let pipController = pipController else { return }
pipController.syncTimebase(
currentTime: playerCore.timePos,
isPlaying: !playerCore.isPaused
)
}
private func startPipTimebaseSync() {
stopPipTimebaseSync()
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.syncPipTimebase()
}
}
private func stopPipTimebaseSync() {
pipTimebaseSyncTimer?.invalidate()
pipTimebaseSyncTimer = nil
}
// MARK: - Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
@@ -97,45 +214,40 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return
}
// Check if already initialized
if self.playerCore?.isInitialized == true {
print("[MpvPlayerPlugin] Already initialized")
result(true)
return
}
// Find the key window
guard let window = self.findKeyWindow() else {
print("[MpvPlayerPlugin] Failed to find key window")
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
return
}
// Create and initialize player core
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
print("[MpvPlayerPlugin] Failed to initialize MPV")
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
// Start hidden
core.setVisible(false)
print("[MpvPlayerPlugin] Initialized successfully")
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.dispose()
self?.playerCore = nil
print("[MpvPlayerPlugin] Disposed")
guard let self = self else { result(nil); return }
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
@@ -149,6 +261,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
pipController?.invalidatePlaybackState()
if playerCore?.isPipActive == true { syncPipTimebase() }
}
result(nil)
}
@@ -158,9 +276,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil))
return
}
let value = playerCore?.getProperty(name)
result(value)
result(playerCore?.getProperty(name))
}
private func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
@@ -184,7 +300,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return
}
// Use async command to prevent UI blocking during network operations
playerCore?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
@@ -204,12 +319,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
DispatchQueue.main.async { [weak self] in
self?.playerCore?.setVisible(visible)
// Update frame when becoming visible
if visible {
self?.playerCore?.updateFrame()
}
if visible { self?.playerCore?.updateFrame() }
result(nil)
}
}
@@ -234,21 +344,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink else { return }
if let propId = nameToId[name] {
eventSink([propId, value as Any])
}
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
eventSink([propId, value as Any])
}
func onEvent(name: String, data: [String: Any]?) {
guard let eventSink = eventSink else { return }
var event: [String: Any] = ["type": "event", "name": name]
if let data = data {
event["data"] = data
}
if let data = data { event["data"] = data }
eventSink(event)
}
@@ -262,3 +365,58 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return window
}
}
// MARK: - MpvPipDelegate
extension MpvPlayerPlugin: MpvPipDelegate {
func pipWillStart() {
// If PiP was system-initiated (not via our enterPip), switch VO now
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO")
if switchToPipAndPrepare() == nil {
print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP")
pipController?.stopPip()
}
}
func pipDidStart() {
playerCore?.isPipStarting = false
playerCore?.isPipActive = true
pipChannel?.invokeMethod("onPipChanged", arguments: true)
syncPipTimebase()
startPipTimebaseSync()
if isManualPipRequest {
isManualPipRequest = false
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
}
}
func pipDidStop(restored: Bool) {
cleanupPip(notify: true, pause: !restored)
}
func pipDidFailToStart(error: Error?) {
cleanupPip(notify: true)
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
pipController?.invalidatePlaybackState()
syncPipTimebase()
}
func pipSkip(byInterval seconds: Double) {
guard let playerCore = playerCore else { return }
let newTime = max(0, playerCore.timePos + seconds)
playerCore.command(["seek", String(newTime), "absolute"])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.syncPipTimebase()
self?.pipController?.invalidatePlaybackState()
}
}
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
var pipDuration: Double { playerCore?.duration ?? 0 }
}
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsDe implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Erfordert Android 8.0 oder neuer';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'Bild-in-Bild-Berechtigung ist deaktiviert. Aktiviere sie unter Einstellungen > Apps > Plezy > Bild-in-Bild';
@override String get notSupported => 'Dieses Gerät unterstützt den Bild-in-Bild-Modus nicht';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Bild-in-Bild konnte nicht gestartet werden';
@override String unknown({required Object error}) => 'Ein Fehler ist aufgetreten: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsDe {
'videoControls.endsAt' => ({required Object time}) => 'Endet um ${time}',
'videoControls.pipFailed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.pipErrors.androidVersion' => 'Erfordert Android 8.0 oder neuer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'Bild-in-Bild-Berechtigung ist deaktiviert. Aktiviere sie unter Einstellungen > Apps > Plezy > Bild-in-Bild',
'videoControls.pipErrors.notSupported' => 'Dieses Gerät unterstützt den Bild-in-Bild-Modus nicht',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ein Fehler ist aufgetreten: ${error}',
'videoControls.chapters' => 'Kapitel',
+8
View File
@@ -2616,12 +2616,18 @@ class TranslationsVideoControlsPipErrorsEn {
/// en: 'Requires Android 8.0 or newer'
String get androidVersion => 'Requires Android 8.0 or newer';
/// en: 'Requires iOS 15.0 or newer'
String get iosVersion => 'Requires iOS 15.0 or newer';
/// en: 'Picture-in-picture permission is disabled. Enable it in Settings > Apps > Plezy > Picture-in-picture'
String get permissionDisabled => 'Picture-in-picture permission is disabled. Enable it in Settings > Apps > Plezy > Picture-in-picture';
/// en: 'Device doesn't support picture-in-picture mode'
String get notSupported => 'Device doesn\'t support picture-in-picture mode';
/// en: 'Failed to switch video output for picture-in-picture'
String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
/// en: 'Picture-in-picture failed to start'
String get failed => 'Picture-in-picture failed to start';
@@ -3244,8 +3250,10 @@ extension on Translations {
'videoControls.endsAt' => ({required Object time}) => 'Ends at ${time}',
'videoControls.pipFailed' => 'Picture-in-picture failed to start',
'videoControls.pipErrors.androidVersion' => 'Requires Android 8.0 or newer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture permission is disabled. Enable it in Settings > Apps > Plezy > Picture-in-picture',
'videoControls.pipErrors.notSupported' => 'Device doesn\'t support picture-in-picture mode',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Picture-in-picture failed to start',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'An error occurred: ${error}',
'videoControls.chapters' => 'Chapters',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsEs implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Requiere Android 8.0 o más reciente';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'El permiso de Imagen en Imagen está desactivado. Actívalo en Ajustes > Aplicaciones > Plezy > Imagen en Imagen';
@override String get notSupported => 'El dispositivo no soporta el modo Imagen en Imagen';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Error al iniciar Imagen en Imagen';
@override String unknown({required Object error}) => 'Ocurrió un error: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsEs {
'videoControls.endsAt' => ({required Object time}) => 'Termina a las ${time}',
'videoControls.pipFailed' => 'Error al iniciar Imagen en Imagen',
'videoControls.pipErrors.androidVersion' => 'Requiere Android 8.0 o más reciente',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'El permiso de Imagen en Imagen está desactivado. Actívalo en Ajustes > Aplicaciones > Plezy > Imagen en Imagen',
'videoControls.pipErrors.notSupported' => 'El dispositivo no soporta el modo Imagen en Imagen',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Error al iniciar Imagen en Imagen',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ocurrió un error: ${error}',
'videoControls.chapters' => 'Capítulos',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsFr implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Nécessite Android 8.0 ou plus récent';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'L\'autorisation Image dans l\'image est désactivée. Activez-la dans Paramètres > Applications > Plezy > Image dans l\'image';
@override String get notSupported => 'Cet appareil ne prend pas en charge le mode image dans l\'image';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Échec du démarrage du mode image dans l\'image';
@override String unknown({required Object error}) => 'Une erreur s\'est produite : ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsFr {
'videoControls.endsAt' => ({required Object time}) => 'Fin à ${time}',
'videoControls.pipFailed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.pipErrors.androidVersion' => 'Nécessite Android 8.0 ou plus récent',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'L\'autorisation Image dans l\'image est désactivée. Activez-la dans Paramètres > Applications > Plezy > Image dans l\'image',
'videoControls.pipErrors.notSupported' => 'Cet appareil ne prend pas en charge le mode image dans l\'image',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Une erreur s\'est produite : ${error}',
'videoControls.chapters' => 'Chapitres',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsIt implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Richiede Android 8.0 o versioni successive';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'L\'autorizzazione Picture-in-Picture è disabilitata. Abilitala in Impostazioni > App > Plezy > Picture-in-Picture';
@override String get notSupported => 'Questo dispositivo non supporta la modalità Picture-in-Picture';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Impossibile avviare la modalità Picture-in-Picture';
@override String unknown({required Object error}) => 'Si è verificato un errore: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsIt {
'videoControls.endsAt' => ({required Object time}) => 'Finisce alle ${time}',
'videoControls.pipFailed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.pipErrors.androidVersion' => 'Richiede Android 8.0 o versioni successive',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'L\'autorizzazione Picture-in-Picture è disabilitata. Abilitala in Impostazioni > App > Plezy > Picture-in-Picture',
'videoControls.pipErrors.notSupported' => 'Questo dispositivo non supporta la modalità Picture-in-Picture',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Si è verificato un errore: ${error}',
'videoControls.chapters' => 'Capitoli',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsKo implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Android 8.0 이상이 필요합니다';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => '화면 속 화면 권한이 비활성화되어 있습니다. 설정 > 앱 > Plezy > 화면 속 화면에서 활성화하세요';
@override String get notSupported => '이 기기는 화면 속 화면 모드를 지원하지 않습니다';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => '화면 속 화면 모드를 시작할 수 없습니다';
@override String unknown({required Object error}) => '오류가 발생했습니다: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsKo {
'videoControls.endsAt' => ({required Object time}) => '${time}에 종료',
'videoControls.pipFailed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.pipErrors.androidVersion' => 'Android 8.0 이상이 필요합니다',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => '화면 속 화면 권한이 비활성화되어 있습니다. 설정 > 앱 > Plezy > 화면 속 화면에서 활성화하세요',
'videoControls.pipErrors.notSupported' => '이 기기는 화면 속 화면 모드를 지원하지 않습니다',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.pipErrors.unknown' => ({required Object error}) => '오류가 발생했습니다: ${error}',
'videoControls.chapters' => '챕터',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsNl implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Vereist Android 8.0 of nieuwer';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'Beeld-in-beeld toestemming is uitgeschakeld. Schakel deze in via Instellingen > Apps > Plezy > Beeld-in-beeld';
@override String get notSupported => 'Dit apparaat ondersteunt geen beeld-in-beeld modus';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Beeld-in-beeld kon niet worden gestart';
@override String unknown({required Object error}) => 'Er is een fout opgetreden: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsNl {
'videoControls.endsAt' => ({required Object time}) => 'Eindigt om ${time}',
'videoControls.pipFailed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.pipErrors.androidVersion' => 'Vereist Android 8.0 of nieuwer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'Beeld-in-beeld toestemming is uitgeschakeld. Schakel deze in via Instellingen > Apps > Plezy > Beeld-in-beeld',
'videoControls.pipErrors.notSupported' => 'Dit apparaat ondersteunt geen beeld-in-beeld modus',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Er is een fout opgetreden: ${error}',
'videoControls.chapters' => 'Hoofdstukken',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsSv implements TranslationsVideoControls
// Translations
@override String get androidVersion => 'Kräver Android 8.0 eller nyare';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => 'Bild-i-bild-behörighet är inaktiverad. Aktivera den i Inställningar > Appar > Plezy > Bild-i-bild';
@override String get notSupported => 'Denna enhet stöder inte bild-i-bild-läge';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => 'Bild-i-bild kunde inte starta';
@override String unknown({required Object error}) => 'Ett fel uppstod: ${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsSv {
'videoControls.endsAt' => ({required Object time}) => 'Slutar ${time}',
'videoControls.pipFailed' => 'Bild-i-bild kunde inte starta',
'videoControls.pipErrors.androidVersion' => 'Kräver Android 8.0 eller nyare',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'Bild-i-bild-behörighet är inaktiverad. Aktivera den i Inställningar > Appar > Plezy > Bild-i-bild',
'videoControls.pipErrors.notSupported' => 'Denna enhet stöder inte bild-i-bild-läge',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => 'Bild-i-bild kunde inte starta',
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ett fel uppstod: ${error}',
'videoControls.chapters' => 'Kapitel',
+4
View File
@@ -1164,8 +1164,10 @@ class _TranslationsVideoControlsPipErrorsZh implements TranslationsVideoControls
// Translations
@override String get androidVersion => '需要 Android 8.0 或更高版本';
@override String get iosVersion => 'Requires iOS 15.0 or newer';
@override String get permissionDisabled => '画中画权限已禁用。请在设置 > 应用 > Plezy > 画中画中启用';
@override String get notSupported => '此设备不支持画中画模式';
@override String get voSwitchFailed => 'Failed to switch video output for picture-in-picture';
@override String get failed => '画中画启动失败';
@override String unknown({required Object error}) => '发生错误:${error}';
}
@@ -1627,8 +1629,10 @@ extension on TranslationsZh {
'videoControls.endsAt' => ({required Object time}) => '${time} 结束',
'videoControls.pipFailed' => '画中画启动失败',
'videoControls.pipErrors.androidVersion' => '需要 Android 8.0 或更高版本',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => '画中画权限已禁用。请在设置 > 应用 > Plezy > 画中画中启用',
'videoControls.pipErrors.notSupported' => '此设备不支持画中画模式',
'videoControls.pipErrors.voSwitchFailed' => 'Failed to switch video output for picture-in-picture',
'videoControls.pipErrors.failed' => '画中画启动失败',
'videoControls.pipErrors.unknown' => ({required Object error}) => '发生错误:${error}',
'videoControls.chapters' => '章节',
+12 -10
View File
@@ -184,6 +184,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _wasPlayingBeforeInactive = false;
bool _autoPipEnabled = false;
/// Whether to skip lifecycle actions because PiP is active or about to start.
/// iOS auto-PiP is system-initiated during the background transition, so
/// isPipActive may not be true yet — we also check the auto-PiP setting.
bool get _shouldSkipForPip =>
PipService().isPipActive.value || (Platform.isIOS && _autoPipEnabled);
// Services
MediaControlsManager? _mediaControlsManager;
PlaybackProgressTracker? _progressTracker;
@@ -318,9 +324,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
case AppLifecycleState.hidden:
// App is being hidden (user is switching away)
// Pause video since we don't support background playback (mobile only)
// Skip if entering PiP mode - video should keep playing
if (PlatformDetector.isMobile(context)) {
if (Platform.isAndroid && PipService().isPipActive.value) break;
if (_shouldSkipForPip) break;
if (player != null && _isPlayerInitialized) {
_wasPlayingBeforeInactive = player!.state.playing;
if (_wasPlayingBeforeInactive) {
@@ -331,8 +336,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
break;
case AppLifecycleState.paused:
// Skip if in PiP mode - video should keep playing
if (Platform.isAndroid && PipService().isPipActive.value) break;
if (_shouldSkipForPip) break;
// Clear media controls when app truly goes to background
// (we don't support background playback)
OsMediaControls.clear();
@@ -401,9 +405,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Load buffer size from settings
final settingsService = await SettingsService.getInstance();
_videoPlayerNavigationEnabled = settingsService.getVideoPlayerNavigationEnabled();
if (Platform.isAndroid) {
_autoPipEnabled = settingsService.getAutoPip();
}
_autoPipEnabled = settingsService.getAutoPip();
final bufferSizeMB = settingsService.getBufferSize();
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
@@ -1115,7 +1117,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoPIPManager!.isPipActive.addListener(_onPipStateChanged);
// Auto-PiP: set up callback for API 26-30 path and initial state
if (Platform.isAndroid && _autoPipEnabled) {
if (_autoPipEnabled) {
PipService.onAutoPipEntering = () {
_videoFilterManager?.enterPipMode();
};
@@ -1822,8 +1824,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
DiscordRPCService.instance.pausePlayback();
}
// Update auto-PiP readiness on Android
if (Platform.isAndroid && _autoPipEnabled) {
// Update auto-PiP readiness
if (_autoPipEnabled) {
_videoPIPManager?.updateAutoPipState(isPlaying: isPlaying);
}
}
+2
View File
@@ -60,8 +60,10 @@ class PipService {
static String _getLocalizedError(String errorCode, String? errorMessage) {
return switch (errorCode) {
'android_version' => t.videoControls.pipErrors.androidVersion,
'ios_version' => t.videoControls.pipErrors.iosVersion,
'permission_disabled' => t.videoControls.pipErrors.permissionDisabled,
'not_supported' => t.videoControls.pipErrors.notSupported,
'vo_switch_failed' => t.videoControls.pipErrors.voSwitchFailed,
'failed' => t.videoControls.pipErrors.failed,
_ => t.videoControls.pipErrors.unknown(error: errorMessage ?? 'Unknown error'),
};
+3 -1
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import '../models/hotkey_model.dart';
import 'package:plezy/utils/app_logger.dart';
@@ -1002,12 +1003,13 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled
}
// Auto Picture-in-Picture (Android only)
// Auto Picture-in-Picture (Android & iOS)
Future<void> setAutoPip(bool enabled) async {
await prefs.setBool(_keyAutoPip, enabled);
}
bool getAutoPip() {
if (!Platform.isAndroid && !Platform.isIOS) return false;
return prefs.getBool(_keyAutoPip) ?? true; // Default enabled
}
+9 -5
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../mpv/mpv.dart';
import '../services/pip_service.dart';
@@ -59,11 +61,13 @@ class VideoPIPManager {
final supported = await PipService.isSupported();
if (!supported) return (false, 'PiP not supported on this device');
// Reset video filter to contain mode BEFORE entering PiP
onBeforeEnterPip?.call();
// Wait a frame for the filter change to take effect
await Future.delayed(const Duration(milliseconds: 50));
// Reset video filter to contain mode BEFORE entering PiP (Android only —
// iOS switches VO entirely so the filter is irrelevant)
if (!Platform.isIOS) {
onBeforeEnterPip?.call();
// Wait a frame for the filter change to take effect
await Future.delayed(const Duration(milliseconds: 50));
}
final dims = await _getVideoDimensions();
return await PipService.enter(width: dims.$1, height: dims.$2);
@@ -836,7 +836,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Check whether PiP is supported on this device
Future<void> _checkPipSupport() async {
if (!Platform.isAndroid) {
if (!Platform.isAndroid && !Platform.isIOS) {
return;
}
@@ -952,7 +952,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen,
onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onToggleRotationLock: _toggleRotationLock,
onToggleFullscreen: _toggleFullscreen,
@@ -1583,7 +1583,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Use desktop controls for desktop platforms AND Android TV
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
// Hide ALL controls when in PiP mode (Android only)
// Hide ALL controls when in PiP mode
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
@@ -1946,7 +1946,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
Widget _buildDesktopControlsListener() {
final pipMode = (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null;
final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null;
final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null;
final playbackState = context.watch<PlaybackStateProvider>();
+5 -5
View File
@@ -27,7 +27,7 @@
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 6AB6B0942EDB27C100EAC8DB /* MPVKit-GPL */; };
6AB6B0952EDB27C100EAC8DB /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6AB6B0942EDB27C100EAC8DB /* MPVKit */; };
6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; };
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
@@ -112,7 +112,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */,
6AB6B0952EDB27C100EAC8DB /* MPVKit in Frameworks */,
DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -831,16 +831,16 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 86035ac4a2544e7a9b3cc0ffde5bef4549d4869a;
revision = b2179eec26c6ceda339e6c6b512e457341768761;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
6AB6B0942EDB27C100EAC8DB /* MPVKit-GPL */ = {
6AB6B0942EDB27C100EAC8DB /* MPVKit */ = {
isa = XCSwiftPackageProductDependency;
package = 6AB6B0932EDB27C100EAC8DB /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = "MPVKit-GPL";
productName = "MPVKit";
};
/* End XCSwiftPackageProductDependency section */
};
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a"
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a"
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
}
}
],