diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ff6ca2a1..6b929a9a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; + 92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -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 = ""; @@ -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 */ }; diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4edb0558..887f4d26 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a" + "revision" : "b2179eec26c6ceda339e6c6b512e457341768761" } } ], diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4edb0558..887f4d26 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a" + "revision" : "b2179eec26c6ceda339e6c6b512e457341768761" } } ], diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index dfe4135b..c17fa6c7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,6 +51,7 @@ Main UIBackgroundModes + audio fetch UISupportedInterfaceOrientations diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift new file mode 100644 index 00000000..3c0f8466 --- /dev/null +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -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() + } +} diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index abd9c7c0..9519307e 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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 diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index a6483348..510dd1aa 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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 } +} diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 41c65712..18b7b6e4 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index f45aa35c..83456b4f 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 65e21fab..4640d559 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 8607cad1..bd7a99db 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index e7ec180e..f65e85e1 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 4fd541bc..00af8536 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '챕터', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 1d03c4e3..f8f37593 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 41325bf6..94755239 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 260fd35f..1949dce0 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '章节', diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 09313a71..716dbabc 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -184,6 +184,12 @@ class VideoPlayerScreenState extends State 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 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 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 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 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 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); } } diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart index 8855f2bc..e1f73777 100644 --- a/lib/services/pip_service.dart +++ b/lib/services/pip_service.dart @@ -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'), }; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ce504354..5d9855b5 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 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 } diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index 918d3b19..f2aabc69 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -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); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 8bab6f36..897ee0f1 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -836,7 +836,7 @@ class _PlexVideoControlsState extends State with WindowListen /// Check whether PiP is supported on this device Future _checkPipSupport() async { - if (!Platform.isAndroid) { + if (!Platform.isAndroid && !Platform.isIOS) { return; } @@ -952,7 +952,7 @@ class _PlexVideoControlsState extends State 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 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( valueListenable: _pipService.isPipActive, builder: (context, isInPip, _) { @@ -1946,7 +1946,7 @@ class _PlexVideoControlsState extends State 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(); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index e277f9ea..e4bb018f 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 */ }; diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4edb0558..887f4d26 100644 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a" + "revision" : "b2179eec26c6ceda339e6c6b512e457341768761" } } ], diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4edb0558..887f4d26 100644 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "86035ac4a2544e7a9b3cc0ffde5bef4549d4869a" + "revision" : "b2179eec26c6ceda339e6c6b512e457341768761" } } ],