// // MPVPiPBridge.swift // Yattee // // Native Picture-in-Picture support for MPV using AVSampleBufferDisplayLayer. // #if os(iOS) || os(macOS) import AVKit import CoreMedia import CoreVideo import os #if os(iOS) import UIKit typealias PlatformView = UIView typealias PlatformColor = UIColor #elseif os(macOS) import AppKit typealias PlatformView = NSView typealias PlatformColor = NSColor #endif /// Bridges MPV video output to AVPictureInPictureController using AVSampleBufferDisplayLayer. /// This enables native PiP for MPV-rendered content. @MainActor final class MPVPiPBridge: NSObject { // MARK: - Properties private let sampleBufferLayer = AVSampleBufferDisplayLayer() private var pipController: AVPictureInPictureController? private weak var mpvBackend: MPVBackend? /// Whether PiP is currently active. var isPiPActive: Bool { pipController?.isPictureInPictureActive ?? false } /// Whether PiP is possible (controller exists and is not nil). var isPiPPossible: Bool { pipController?.isPictureInPicturePossible ?? false } /// Callback for when user wants to restore from PiP to main app. var onRestoreUserInterface: (() async -> Void)? /// Callback for when PiP active status changes. var onPiPStatusChanged: ((Bool) -> Void)? /// Callback for when PiP will start (for early UI updates like clearing main view) var onPiPWillStart: (() -> Void)? /// Callback for when PiP will stop (to resume main view rendering before animation ends) var onPiPWillStop: (() -> Void)? /// Callback for when PiP stops without restore (user clicked close button in PiP) var onPiPDidStopWithoutRestore: (() -> Void)? /// Callback for when isPictureInPicturePossible changes var onPiPPossibleChanged: ((Bool) -> Void)? /// Callback for when PiP render size changes (for resizing capture buffers) var onPiPRenderSizeChanged: ((CMVideoDimensions) -> Void)? /// KVO observation for isPictureInPicturePossible private var pipPossibleObservation: NSKeyValueObservation? /// Current PiP render size from AVPictureInPictureController private var currentPiPRenderSize: CMVideoDimensions? /// Current video aspect ratio (width / height) private var videoAspectRatio: CGFloat = 16.0 / 9.0 /// Track whether restore was requested before PiP stopped private var restoreWasRequested = false #if os(macOS) /// Timer to periodically update layer frame to match superlayer private var layerResizeTimer: Timer? /// Track if we've logged the PiP window hierarchy already private var hasLoggedPiPHierarchy = false /// Views we've hidden that need to be restored before PiP cleanup. /// Uses weak references to avoid retaining AVKit internal views that get deallocated /// when the PiP window closes, which would cause crashes in objc_release. private var hiddenPiPViews = NSHashTable.weakObjects() #endif // MARK: - Format Descriptions private var currentFormatDescription: CMVideoFormatDescription? private var lastPresentationTime: CMTime = .zero /// Timebase for controlling sample buffer display timing private var timebase: CMTimebase? /// Cache last pixel buffer to re-enqueue during close animation private var lastPixelBuffer: CVPixelBuffer? // MARK: - Playback State (Thread-Safe for nonisolated delegate methods) /// Cached duration for PiP time range (thread-safe) private let _duration = OSAllocatedUnfairLock(initialState: 0.0) /// Cached paused state for PiP (thread-safe) private let _isPaused = OSAllocatedUnfairLock(initialState: false) /// Update cached playback state from backend (call periodically) func updatePlaybackState(duration: Double, currentTime: Double, isPaused: Bool) { _duration.withLock { $0 = duration } _isPaused.withLock { $0 = isPaused } // Update timebase with current playback position if let timebase { let time = CMTime(seconds: currentTime, preferredTimescale: 90000) CMTimebaseSetTime(timebase, time: time) } } // MARK: - Setup /// Set up PiP with the given MPV backend and container view. /// - Parameters: /// - backend: The MPV backend to connect to /// - containerView: The view to embed the sample buffer layer in func setup(backend: MPVBackend, in containerView: PlatformView) { self.mpvBackend = backend // Configure sample buffer layer sampleBufferLayer.frame = containerView.bounds #if os(macOS) // On macOS, use resize to fill the entire area (ignoring aspect ratio) // This works around AVKit's PiP window sizing that includes title bar height sampleBufferLayer.videoGravity = .resize sampleBufferLayer.contentsGravity = .resize // Enable auto-resizing to fill superlayer sampleBufferLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] #else sampleBufferLayer.videoGravity = .resizeAspect sampleBufferLayer.contentsGravity = .resizeAspect #endif sampleBufferLayer.backgroundColor = PlatformColor.clear.cgColor // Set up timebase for controlling playback timing var timebase: CMTimebase? CMTimebaseCreateWithSourceClock( allocator: kCFAllocatorDefault, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &timebase ) if let timebase { self.timebase = timebase sampleBufferLayer.controlTimebase = timebase CMTimebaseSetRate(timebase, rate: 1.0) CMTimebaseSetTime(timebase, time: .zero) } // IMPORTANT: Hide the layer during normal playback so it doesn't cover // the OpenGL rendering. It will be shown when PiP is active. sampleBufferLayer.isHidden = true // Layer must be in view hierarchy for PiP to work, but can be hidden #if os(iOS) containerView.layer.addSublayer(sampleBufferLayer) #elseif os(macOS) // On macOS, add the layer to the container view's layer. // The warning about NSHostingController is unavoidable with AVSampleBufferDisplayLayer PiP, // but it doesn't affect functionality - the PiP window works correctly. containerView.wantsLayer = true if let layer = containerView.layer { // Add on top - the layer is hidden during normal playback anyway layer.addSublayer(sampleBufferLayer) } sampleBufferLayer.frame = containerView.bounds #endif // Create content source for sample buffer playback let contentSource = AVPictureInPictureController.ContentSource( sampleBufferDisplayLayer: sampleBufferLayer, playbackDelegate: self ) // Create PiP controller pipController = AVPictureInPictureController(contentSource: contentSource) pipController?.delegate = self // Observe isPictureInPicturePossible changes via KVO // Note: Don't use .initial here - callbacks aren't set up yet when setup() is called. // Use notifyPiPPossibleState() after setting up callbacks. pipPossibleObservation = pipController?.observe(\.isPictureInPicturePossible, options: [.new]) { [weak self] _, change in let isPossible = change.newValue ?? false Task { @MainActor [weak self] in self?.onPiPPossibleChanged?(isPossible) } } #if os(iOS) // Observe app lifecycle to handle background transitions while PiP is active NotificationCenter.default.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil ) #endif LoggingService.shared.debug("MPVPiPBridge: Setup complete", category: .mpv) } /// Manually notify current isPiPPossible state. /// Call this after setting up onPiPPossibleChanged callback. func notifyPiPPossibleState() { onPiPPossibleChanged?(isPiPPossible) } /// Update the video aspect ratio for proper PiP sizing. /// Call this when video dimensions are known or change. /// - Parameter aspectRatio: Video width divided by height (e.g., 16/9 = 1.777...) func updateVideoAspectRatio(_ aspectRatio: CGFloat) { guard aspectRatio > 0 else { return } videoAspectRatio = aspectRatio #if os(macOS) // On macOS, update layer bounds to match aspect ratio // This helps AVKit size the PiP window correctly let currentBounds = sampleBufferLayer.bounds let newHeight = currentBounds.width / aspectRatio let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight) CATransaction.begin() CATransaction.setDisableActions(true) sampleBufferLayer.bounds = newBounds CATransaction.commit() LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv) #else // On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment // (negative Y offset) which breaks the system's PiP restore UI positioning. // AVKit gets the aspect ratio from the enqueued video frames. // If PiP is active and video changed, calculate and update the layer frame. // The superlayer bounds don't update during PiP (view hierarchy hidden), // so we calculate the correct frame based on screen width and aspect ratio. if isPiPActive { // Detect significant aspect ratio change - if so, flush buffer to force AVKit // to re-read video dimensions from the new format description let currentBounds = sampleBufferLayer.bounds if currentBounds.height > 0 { let previousRatio = currentBounds.width / currentBounds.height let ratioChange = abs(aspectRatio - previousRatio) / previousRatio if ratioChange > 0.05 { // >5% change indicates new video // Flush buffer and clear format description to force AVKit to re-read dimensions sampleBufferLayer.sampleBufferRenderer.flush() currentFormatDescription = nil LoggingService.shared.debug("MPVPiPBridge: Flushed buffer for aspect ratio change \(previousRatio) -> \(aspectRatio)", category: .mpv) } } let screenWidth = UIScreen.main.bounds.width // Calculate height based on aspect ratio, capped to leave room for details let maxHeight = UIScreen.main.bounds.height * 0.6 // Leave 40% for details let calculatedHeight = screenWidth / aspectRatio let height = min(calculatedHeight, maxHeight) let width = height < calculatedHeight ? height * aspectRatio : screenWidth let newFrame = CGRect(x: 0, y: 0, width: width, height: height) CATransaction.begin() CATransaction.setDisableActions(true) sampleBufferLayer.frame = newFrame sampleBufferLayer.bounds = CGRect(origin: .zero, size: newFrame.size) CATransaction.commit() pipController?.invalidatePlaybackState() LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), calculated layer frame during PiP: \(newFrame)", category: .mpv) } else { LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio) (bounds unchanged on iOS)", category: .mpv) } #endif } #if os(iOS) @objc private func appWillResignActive() { guard isPiPActive, let timebase else { return } // Sync timebase and pre-buffer frames before iOS throttles/suspends if let currentTime = mpvBackend?.currentTime { let time = CMTime(seconds: currentTime, preferredTimescale: 90000) CMTimebaseSetTime(timebase, time: time) } CMTimebaseSetRate(timebase, rate: 1.0) preBufferFramesForBackgroundTransition() } @objc private func appDidEnterBackground() { guard isPiPActive, let timebase else { return } // Ensure timebase is synced and running if let currentTime = mpvBackend?.currentTime { let time = CMTime(seconds: currentTime, preferredTimescale: 90000) CMTimebaseSetTime(timebase, time: time) } CMTimebaseSetRate(timebase, rate: 1.0) // Pre-buffer additional frames as secondary buffer preBufferFramesForBackgroundTransition() } /// Pre-buffer frames with future timestamps to bridge iOS background suspension. /// Note: iOS suspends app code for ~300-400ms during background transition. /// Pre-buffered frames show the same content but keep the layer fed. private func preBufferFramesForBackgroundTransition() { guard let pixelBuffer = lastPixelBuffer, let formatDescription = currentFormatDescription, let timebase else { return } let currentTimebaseTime = CMTimebaseGetTime(timebase) let frameInterval = CMTime(value: 1, timescale: 30) var currentPTS = currentTimebaseTime // Pre-enqueue 30 frames (~1 second) to bridge the iOS suspension gap for _ in 0..<30 { currentPTS = CMTimeAdd(currentPTS, frameInterval) var sampleTimingInfo = CMSampleTimingInfo( duration: frameInterval, presentationTimeStamp: currentPTS, decodeTimeStamp: .invalid ) var sampleBuffer: CMSampleBuffer? CMSampleBufferCreateReadyWithImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: formatDescription, sampleTiming: &sampleTimingInfo, sampleBufferOut: &sampleBuffer ) guard let sampleBuffer else { continue } if sampleBufferLayer.sampleBufferRenderer.status != .failed { sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer) } } lastPresentationTime = currentPTS } #endif /// Update the layer frame when container bounds change. /// On macOS, the frame should be relative to the window's content view. func updateLayerFrame(_ frame: CGRect) { sampleBufferLayer.frame = frame } #if os(macOS) /// Update the layer frame based on container view's bounds. /// Call this on macOS when the container view's size changes. func updateLayerFrame(for containerView: NSView) { sampleBufferLayer.frame = containerView.bounds } #endif /// Move the sample buffer layer to a new container view. /// This is needed when transitioning from fullscreen to PiP, /// as the layer must be in a visible window hierarchy. func moveLayer(to containerView: PlatformView) { sampleBufferLayer.removeFromSuperlayer() sampleBufferLayer.frame = containerView.bounds #if os(iOS) containerView.layer.addSublayer(sampleBufferLayer) #elseif os(macOS) containerView.wantsLayer = true containerView.layer?.addSublayer(sampleBufferLayer) #endif } /// Clean up and release resources. func cleanup() { NotificationCenter.default.removeObserver(self) pipPossibleObservation?.invalidate() pipPossibleObservation = nil lastPixelBuffer = nil pipController?.stopPictureInPicture() pipController = nil sampleBufferLayer.removeFromSuperlayer() sampleBufferLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) mpvBackend = nil } /// Flush the sample buffer to clear any displayed frame. /// Call this when stopping playback to prevent stale frames when reusing backend. func flushBuffer() { sampleBufferLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) frameCount = 0 } // MARK: - Frame Enqueueing /// Track frame count for logging private var frameCount = 0 /// Enqueue a video frame from MPV for display. /// This is called by MPV's render callback when a frame is ready. /// - Parameters: /// - pixelBuffer: The decoded video frame as CVPixelBuffer /// - presentationTime: The presentation timestamp for this frame func enqueueFrame(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) { frameCount += 1 // Log first few frames and then periodically if frameCount <= 3 || frameCount % 60 == 0 { let width = CVPixelBufferGetWidth(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) LoggingService.shared.debug("MPVPiPBridge: Enqueue frame #\(frameCount), size: \(width)x\(height), layer status: \(sampleBufferLayer.sampleBufferRenderer.status.rawValue)", category: .mpv) } // Create format description if needed or if dimensions changed let width = CVPixelBufferGetWidth(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) if currentFormatDescription == nil || CMVideoFormatDescriptionGetDimensions(currentFormatDescription!).width != Int32(width) || CMVideoFormatDescriptionGetDimensions(currentFormatDescription!).height != Int32(height) { var formatDescription: CMVideoFormatDescription? CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription ) currentFormatDescription = formatDescription } guard let formatDescription = currentFormatDescription else { return } // Create sample timing info var sampleTimingInfo = CMSampleTimingInfo( duration: CMTime(value: 1, timescale: 30), // Approximate frame duration presentationTimeStamp: presentationTime, decodeTimeStamp: .invalid ) // Create sample buffer var sampleBuffer: CMSampleBuffer? CMSampleBufferCreateReadyWithImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: formatDescription, sampleTiming: &sampleTimingInfo, sampleBufferOut: &sampleBuffer ) guard let sampleBuffer else { return } // Cache the pixel buffer for re-enqueuing during close animation lastPixelBuffer = pixelBuffer // Enqueue on sample buffer layer if sampleBufferLayer.sampleBufferRenderer.status != .failed { sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer) lastPresentationTime = presentationTime } else { // Flush and retry if layer is in failed state sampleBufferLayer.sampleBufferRenderer.flush() sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer) } } /// Re-enqueue the last frame to prevent placeholder from showing private func reenqueueLastFrame() { guard let pixelBuffer = lastPixelBuffer, let formatDescription = currentFormatDescription else { return } // Increment presentation time slightly to avoid duplicate timestamps let newPresentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 30)) var sampleTimingInfo = CMSampleTimingInfo( duration: CMTime(value: 1, timescale: 30), presentationTimeStamp: newPresentationTime, decodeTimeStamp: .invalid ) var sampleBuffer: CMSampleBuffer? CMSampleBufferCreateReadyWithImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: formatDescription, sampleTiming: &sampleTimingInfo, sampleBufferOut: &sampleBuffer ) guard let sampleBuffer else { return } if sampleBufferLayer.sampleBufferRenderer.status != .failed { sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer) lastPresentationTime = newPresentationTime } } // MARK: - PiP Control /// Start Picture-in-Picture. func startPiP() { guard let pipController, pipController.isPictureInPicturePossible else { LoggingService.shared.warning("MPVPiPBridge: PiP not possible", category: .mpv) return } #if os(iOS) // Update layer frame to match current superlayer bounds before starting PiP. // This ensures the frame is correct for the current video's player area. if let superlayer = sampleBufferLayer.superlayer { CATransaction.begin() CATransaction.setDisableActions(true) sampleBufferLayer.frame = superlayer.bounds sampleBufferLayer.bounds = CGRect(origin: .zero, size: superlayer.bounds.size) CATransaction.commit() LoggingService.shared.debug("MPVPiPBridge: Updated layer frame before PiP: \(superlayer.bounds)", category: .mpv) } #endif // Show the layer before starting PiP - it needs to be visible for PiP to work sampleBufferLayer.isHidden = false pipController.startPictureInPicture() LoggingService.shared.debug("MPVPiPBridge: Starting PiP", category: .mpv) } /// Stop Picture-in-Picture. func stopPiP() { pipController?.stopPictureInPicture() // Layer will be hidden in didStopPictureInPicture delegate LoggingService.shared.debug("MPVPiPBridge: Stopping PiP", category: .mpv) } /// Toggle Picture-in-Picture. func togglePiP() { if isPiPActive { stopPiP() } else { startPiP() } } /// Invalidate and update the playback state in PiP window. func invalidatePlaybackState() { pipController?.invalidatePlaybackState() } } // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate extension MPVPiPBridge: AVPictureInPictureSampleBufferPlaybackDelegate { nonisolated func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool ) { // Update cached state immediately for responsive UI _isPaused.withLock { $0 = !playing } Task { @MainActor in // Update timebase rate if let timebase { CMTimebaseSetRate(timebase, rate: playing ? 1.0 : 0.0) } if playing { mpvBackend?.play() } else { mpvBackend?.pause() } // Notify PiP system that state changed pipController?.invalidatePlaybackState() } } nonisolated func pictureInPictureControllerTimeRangeForPlayback( _ pictureInPictureController: AVPictureInPictureController ) -> CMTimeRange { let duration = _duration.withLock { $0 } // Return actual duration if known if duration > 0 { return CMTimeRange(start: .zero, duration: CMTime(seconds: duration, preferredTimescale: 90000)) } // Fallback to a reasonable default until we know the actual duration return CMTimeRange(start: .zero, duration: CMTime(seconds: 3600, preferredTimescale: 90000)) } nonisolated func pictureInPictureControllerIsPlaybackPaused( _ pictureInPictureController: AVPictureInPictureController ) -> Bool { _isPaused.withLock { $0 } } // Optional: Handle skip by interval (completion handler style to avoid compiler crash) nonisolated func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping @Sendable () -> Void ) { Task { @MainActor in let currentTime = mpvBackend?.currentTime ?? 0 let newTime = currentTime + skipInterval.seconds await mpvBackend?.seek(to: max(0, newTime)) completionHandler() } } // Optional: Whether to prohibit background audio nonisolated func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback( _ pictureInPictureController: AVPictureInPictureController ) -> Bool { false // Allow background audio } // Required: Handle render size changes nonisolated func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions ) { Task { @MainActor in currentPiPRenderSize = newRenderSize onPiPRenderSizeChanged?(newRenderSize) LoggingService.shared.debug("MPVPiPBridge: PiP render size changed to \(newRenderSize.width)x\(newRenderSize.height)", category: .mpv) } } } // MARK: - AVPictureInPictureControllerDelegate extension MPVPiPBridge: AVPictureInPictureControllerDelegate { nonisolated func pictureInPictureControllerWillStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { Task { @MainActor in // Reset PiP render size - will be updated by didTransitionToRenderSize currentPiPRenderSize = nil // Reset restore flag - will be set if user clicks restore button restoreWasRequested = false // Show the sample buffer layer when PiP starts sampleBufferLayer.isHidden = false #if os(macOS) // Ensure our layer has no background that could cause black areas sampleBufferLayer.backgroundColor = nil // Hide other sublayers (like _NSOpenGLViewBackingLayer) that would cover our video if let superlayer = sampleBufferLayer.superlayer, let sublayers = superlayer.sublayers { for layer in sublayers where layer !== sampleBufferLayer { layer.isHidden = true LoggingService.shared.debug("MPVPiPBridge: Hiding layer \(type(of: layer)) for PiP", category: .mpv) } } // Clear backgrounds on parent layers that could cause black areas // The container view's backing layer often has a black background var currentLayer: CALayer? = sampleBufferLayer.superlayer var depth = 0 while let layer = currentLayer { let layerType = String(describing: type(of: layer)) if layer.backgroundColor != nil { LoggingService.shared.debug("MPVPiPBridge: Clearing background on \(layerType) at depth \(depth)", category: .mpv) layer.backgroundColor = nil } currentLayer = layer.superlayer depth += 1 if depth > 5 { break } // Don't go too far up } #endif // Notify to clear main view immediately onPiPWillStart?() LoggingService.shared.debug("MPVPiPBridge: Will start PiP", category: .mpv) } } nonisolated func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { Task { @MainActor in onPiPStatusChanged?(true) // Debug: Log layer frame and bounds LoggingService.shared.debug("MPVPiPBridge: Did start PiP - layer frame: \(sampleBufferLayer.frame), bounds: \(sampleBufferLayer.bounds), videoGravity: \(sampleBufferLayer.videoGravity.rawValue)", category: .mpv) #if os(macOS) // Start timer to update layer frame to match PiP window startLayerResizeTimer() #endif } } nonisolated func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error ) { Task { @MainActor in // Hide the layer again since PiP failed sampleBufferLayer.isHidden = true #if os(macOS) // Unhide other sublayers that we hid when trying to start PiP if let superlayer = sampleBufferLayer.superlayer, let sublayers = superlayer.sublayers { for layer in sublayers where layer !== sampleBufferLayer { layer.isHidden = false } } #endif onPiPStatusChanged?(false) LoggingService.shared.logMPVError("MPVPiPBridge: Failed to start PiP", error: error) } } nonisolated func pictureInPictureControllerWillStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { Task { @MainActor in #if os(macOS) // Restore hidden views BEFORE cleanup to prevent crashes restoreHiddenPiPViews() #endif // Pre-enqueue multiple copies of the last frame to ensure buffer has content // throughout the entire close animation (typically ~0.3-0.5 seconds) for _ in 0..<30 { reenqueueLastFrame() } // Resume main view rendering before animation ends // Keep sampleBufferLayer visible and receiving frames during close animation // to avoid showing the "video is playing in picture in picture" placeholder onPiPWillStop?() LoggingService.shared.debug("MPVPiPBridge: Will stop PiP, pre-enqueued frames", category: .mpv) } } nonisolated func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { Task { @MainActor in #if os(macOS) // Stop layer resize timer stopLayerResizeTimer() // Unhide other sublayers that we hid when PiP started if let superlayer = sampleBufferLayer.superlayer, let sublayers = superlayer.sublayers { for layer in sublayers where layer !== sampleBufferLayer { layer.isHidden = false } } #endif // Hide the sample buffer layer when PiP stops sampleBufferLayer.isHidden = true // Clear cached pixel buffer lastPixelBuffer = nil onPiPStatusChanged?(false) // If restore wasn't requested, notify that PiP stopped without restore // (user clicked X button instead of restore button) if !restoreWasRequested { LoggingService.shared.debug("MPVPiPBridge: Did stop PiP without restore (close button)", category: .mpv) onPiPDidStopWithoutRestore?() } else { LoggingService.shared.debug("MPVPiPBridge: Did stop PiP (with restore)", category: .mpv) } } } nonisolated func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void ) { Task { @MainActor in // Mark that restore was requested - didStopPictureInPicture will check this restoreWasRequested = true LoggingService.shared.debug("MPVPiPBridge: Restore requested", category: .mpv) await onRestoreUserInterface?() completionHandler(true) } } } // MARK: - macOS Layer Resize Timer #if os(macOS) extension MPVPiPBridge { /// Start a timer to periodically resize the layer to match the PiP window. /// This is needed on macOS because AVKit doesn't automatically resize the layer. func startLayerResizeTimer() { stopLayerResizeTimer() // Check immediately updateLayerFrameToMatchPiPWindow() // Then check periodically layerResizeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.updateLayerFrameToMatchPiPWindow() } } } /// Stop the layer resize timer. func stopLayerResizeTimer() { layerResizeTimer?.invalidate() layerResizeTimer = nil } /// Find the PiP window by enumerating all windows. private func findPiPWindow() -> NSWindow? { // Get all windows in the app let allWindows = NSApplication.shared.windows for window in allWindows { let className = String(describing: type(of: window)) // PiP windows on macOS are typically named PIPPanelWindow or similar if className.contains("PIP") || className.contains("PiP") || className.contains("Picture") { LoggingService.shared.debug("MPVPiPBridge: Found PiP window: \(className), frame: \(window.frame)", category: .mpv) return window } } // If no PiP window found in our app, it might be owned by AVKit framework // Try to find it via CGWindowListCopyWindowInfo let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) if let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] { for windowInfo in windowList { if let ownerName = windowInfo[kCGWindowOwnerName as String] as? String, ownerName.contains("Picture") || ownerName.contains("PiP") { LoggingService.shared.debug("MPVPiPBridge: Found PiP via CGWindowList: \(ownerName)", category: .mpv) } if let windowName = windowInfo[kCGWindowName as String] as? String { if windowName.contains("Picture") || windowName.contains("PiP") { // Found it, but CGWindowInfo doesn't give us NSWindow if let bounds = windowInfo[kCGWindowBounds as String] as? [String: Any], let width = bounds["Width"] as? CGFloat, let height = bounds["Height"] as? CGFloat { LoggingService.shared.debug("MPVPiPBridge: PiP window bounds from CGWindowList: \(width)x\(height)", category: .mpv) } } } } } return nil } /// Recursively log view hierarchy for debugging private func logViewHierarchy(_ view: NSView, depth: Int) { let indent = String(repeating: " ", count: depth) let viewType = String(describing: type(of: view)) let layerInfo: String if let layer = view.layer { let bgColor = layer.backgroundColor != nil ? "has bg" : "no bg" let clips = layer.masksToBounds ? "clips" : "no clip" layerInfo = "layer: \(layer.frame), \(bgColor), \(clips)" } else { layerInfo = "no layer" } LoggingService.shared.debug("MPVPiPBridge: \(indent)[\(depth)] \(viewType) frame: \(view.frame), \(layerInfo)", category: .mpv) // Check sublayers if let layer = view.layer { for sublayer in layer.sublayers ?? [] { let sublayerType = String(describing: type(of: sublayer)) let subBg = sublayer.backgroundColor != nil ? "HAS BG" : "no bg" let subClips = sublayer.masksToBounds ? "clips" : "no clip" LoggingService.shared.debug("MPVPiPBridge: \(indent) -> sublayer: \(sublayerType), frame: \(sublayer.frame), \(subBg), \(subClips)", category: .mpv) } } // Recurse into subviews (limit depth to avoid spam) if depth < 6 { for subview in view.subviews { logViewHierarchy(subview, depth: depth + 1) } } } /// Fix the mispositioned AVPictureInPictureCALayerHostView that causes the black bar private func fixPiPLayerHostViewPosition(in pipWindow: NSWindow) { guard let contentView = pipWindow.contentView else { return } // Find the AVPictureInPictureCALayerHostView which is positioned incorrectly findAndFixLayerHostView(in: contentView, windowBounds: contentView.bounds) } private func findAndFixLayerHostView(in view: NSView, windowBounds: CGRect) { let viewType = String(describing: type(of: view)) // AVPictureInPictureCALayerHostView contains the SOURCE view content (our OpenGL view) // It's positioned incorrectly and shows the black background from our app // We want to HIDE this completely - we only need the AVSampleBufferDisplayLayerContentLayer if viewType.contains("AVPictureInPictureCALayerHostView") { if !view.isHidden { LoggingService.shared.debug("MPVPiPBridge: Hiding \(viewType) - it contains source view with black bg", category: .mpv) view.isHidden = true view.layer?.isHidden = true // Track this view so we can unhide it before cleanup hiddenPiPViews.add(view) } } // Disable clipping on content layers if viewType.contains("AVPictureInPictureSampleBufferDisplayLayerHostView") { // Disable clipping on this view and its sublayers view.layer?.masksToBounds = false for sublayer in view.layer?.sublayers ?? [] { sublayer.masksToBounds = false } } // Recurse for subview in view.subviews { findAndFixLayerHostView(in: subview, windowBounds: windowBounds) } } /// Restore any views we hid to prevent crashes during cleanup private func restoreHiddenPiPViews() { let views = hiddenPiPViews.allObjects let count = views.count for view in views { view.isHidden = false view.layer?.isHidden = false } hiddenPiPViews.removeAllObjects() LoggingService.shared.debug("MPVPiPBridge: Restored \(count) hidden PiP views", category: .mpv) } /// Update the sample buffer layer's frame to match the PiP window size. private func updateLayerFrameToMatchPiPWindow() { // Try to find the PiP window if let pipWindow = findPiPWindow() { // Get the content view bounds (excludes title bar) let windowSize = pipWindow.contentView?.bounds.size ?? pipWindow.frame.size // Log detailed PiP window view hierarchy once if !hasLoggedPiPHierarchy, let contentView = pipWindow.contentView { hasLoggedPiPHierarchy = true LoggingService.shared.debug("MPVPiPBridge: ===== PiP Window View Hierarchy =====", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Window frame: \(pipWindow.frame), contentView frame: \(contentView.frame)", category: .mpv) logViewHierarchy(contentView, depth: 0) } // Fix mispositioned internal AVKit views that cause the black bar fixPiPLayerHostViewPosition(in: pipWindow) let newFrame = CGRect(origin: .zero, size: windowSize) if sampleBufferLayer.frame.size != newFrame.size { LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv) CATransaction.begin() CATransaction.setDisableActions(true) sampleBufferLayer.frame = newFrame CATransaction.commit() } } else { // Fallback: try to match superlayer guard let superlayer = sampleBufferLayer.superlayer else { return } let superBounds = superlayer.bounds if sampleBufferLayer.frame != superBounds { LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv) CATransaction.begin() CATransaction.setDisableActions(true) sampleBufferLayer.frame = superBounds CATransaction.commit() } } } } #endif #endif