Files
yattee/Yattee/Services/Player/MPV/MPVPiPBridge.swift
Arkadiusz Fal 32b1dac249 Fix macOS PiP window not resizing when switching videos
Switching videos during active PiP left the window at the old video's
size with white space around the new content, for two reasons:

- MPV reports the new video's width and height as separate property
  events while the old values are kept across switches, so a transient
  mixed size (new width with old height) could reach the PiP capture
  path and AVKit sized the window from that frame. Coalesce the PiP
  capture-size/aspect update with a short debounce so only settled
  dimensions are used.

- On macOS nothing told AVKit about the new aspect: unlike iOS, the
  bridge never flushed the sample buffer renderer on an aspect change
  during active PiP, and AVKit doesn't resize the macOS PiP window on
  its own. Flush the renderer, reset the format description, and
  resize the PiP window to the new aspect ratio explicitly.
2026-06-16 23:59:57 +02:00

1029 lines
42 KiB
Swift

//
// 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<NSView>.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 }
#if os(macOS)
let previousAspectRatio = videoAspectRatio
#endif
videoAspectRatio = aspectRatio
#if os(macOS)
if isPiPActive {
// Video changed while PiP is active. AVKit sizes the PiP window from
// the format of the initially enqueued frames and doesn't resize it
// when frames with new dimensions arrive, leaving empty space around
// the new video. Flush stale frames, force a fresh format description
// so AVKit re-reads the new dimensions, and resize the PiP window to
// the new aspect ratio ourselves.
let ratioChange = abs(aspectRatio - previousAspectRatio) / previousAspectRatio
if ratioChange > 0.01 {
sampleBufferLayer.sampleBufferRenderer.flush()
currentFormatDescription = nil
resizePiPWindow(toAspectRatio: aspectRatio)
LoggingService.shared.debug("MPVPiPBridge: Aspect ratio changed during PiP \(previousAspectRatio) -> \(aspectRatio), flushed buffer and resized PiP window", category: .mpv)
}
} else {
// 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
}
/// Resize the PiP window to match a new video aspect ratio, keeping the
/// current width and top edge. AVKit doesn't resize the window itself when
/// the enqueued frame dimensions change (e.g. switching videos during PiP).
func resizePiPWindow(toAspectRatio aspectRatio: CGFloat) {
guard aspectRatio > 0, let pipWindow = findPiPWindow() else {
LoggingService.shared.debug("MPVPiPBridge: resizePiPWindow - PiP window not found", category: .mpv)
return
}
let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame)
guard contentRect.width > 0 else { return }
let newContentHeight = contentRect.width / aspectRatio
let heightDelta = newContentHeight - contentRect.height
guard abs(heightDelta) >= 1 else { return }
var frame = pipWindow.frame
frame.size.height += heightDelta
frame.origin.y -= heightDelta // keep the top edge in place
pipWindow.setFrame(frame, display: true, animate: false)
LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv)
}
/// 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