@@ -13,6 +13,7 @@ import AVKit
1313import Combine
1414import SwiftUI
1515import ConfUIFoundation
16+ import ObjectiveC
1617
1718public final class PUIPlayerView : NSView {
1819
@@ -88,6 +89,8 @@ public final class PUIPlayerView: NSView {
8889 }
8990
9091 public init ( player: AVPlayer ) {
92+ PIPFixUp . installPIPReturnRectInterposer ( )
93+
9194 self . player = player
9295 if AVPictureInPictureController . isPictureInPictureSupported ( ) {
9396 self . pipController = AVPictureInPictureController ( contentSource: . init( playerLayer: playerLayer) )
@@ -180,8 +183,6 @@ public final class PUIPlayerView: NSView {
180183 return player? . currentItem? . asset
181184 }
182185
183- private let playerLayer = PUIBoringPlayerLayer ( )
184-
185186 private func setupPlayer( _ player: AVPlayer ) {
186187 /// User settings are applied before setting up player observations, avoiding accidental overrides when initial values come in.
187188 applyUserSettings ( to: player)
@@ -505,6 +506,16 @@ public final class PUIPlayerView: NSView {
505506 private var timeRemainingPlaceholder = " −00:00 "
506507 private var durationPlaceholder = " 00:00 "
507508
509+ private let playerLayer = PUIBoringPlayerLayer ( )
510+ private lazy var playerView : NSView = {
511+ let playerView = NSView ( )
512+ playerView. translatesAutoresizingMaskIntoConstraints = false
513+ playerView. wantsLayer = true
514+ playerView. layer = playerLayer
515+ playerLayer. backgroundColor = . clear
516+ return playerView
517+ } ( )
518+
508519 /// Displays the elapsed time.
509520 /// This is a button for consistency with `trailingTimeButton`, but it doesn't have an action.
510521 private lazy var leadingTimeButton : NSButton = {
@@ -671,11 +682,6 @@ public final class PUIPlayerView: NSView {
671682 private func setupControls( ) {
672683 addLayoutGuide ( videoLayoutGuide)
673684
674- let playerView = NSView ( )
675- playerView. translatesAutoresizingMaskIntoConstraints = false
676- playerView. wantsLayer = true
677- playerView. layer = playerLayer
678- playerLayer. backgroundColor = . clear
679685 addSubview ( playerView)
680686 playerView. leadingAnchor. constraint ( equalTo: leadingAnchor) . isActive = true
681687 playerView. trailingAnchor. constraint ( equalTo: trailingAnchor) . isActive = true
@@ -741,7 +747,7 @@ public final class PUIPlayerView: NSView {
741747 controlsContainerView = NSStackView ( views: [
742748 timelineContainerView,
743749 centerButtonsContainerView
744- ] )
750+ ] )
745751
746752 controlsContainerView. orientation = . vertical
747753 controlsContainerView. spacing = 12
@@ -802,7 +808,7 @@ public final class PUIPlayerView: NSView {
802808
803809 speedButton. $isEditingCustomSpeed. sink { [ weak self] isEditing in
804810 guard let self else { return }
805-
811+
806812 showControls ( animated: false )
807813 resetMouseIdleTimer ( )
808814 }
@@ -1382,6 +1388,9 @@ public final class PUIPlayerView: NSView {
13821388 appearanceDelegate? . presentDetachedStatus ( . fullScreen. snapshot ( using: snapshotClosure) , for: self )
13831389
13841390 fullScreenButton. isHidden = true
1391+ // when the player is in full screen, PiP doesn't make any sense
1392+ // this does not prevent the scenario of the app being full screen and entering PiP
1393+ pipButton. isHidden = true
13851394 updateTopTrailingMenuPosition ( )
13861395 }
13871396
@@ -1394,7 +1403,9 @@ public final class PUIPlayerView: NSView {
13941403 /// The transition looks nicer if there's no background color, otherwise the player looks like it attaches
13951404 /// to the whole shelf area with black bars depending on the aspect ratio.
13961405 backgroundColor = . clear
1397-
1406+
1407+ pipButton. isHidden = false
1408+
13981409 if let d = appearanceDelegate {
13991410 fullScreenButton. isHidden = !d. playerViewShouldShowFullScreenButton ( self )
14001411 }
@@ -1641,7 +1652,16 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate {
16411652
16421653 fullScreenButton. isHidden = false
16431654
1644- completionHandler ( true )
1655+ let videoRectInWindow = playerView. convert ( playerLayer. videoRect, to: nil )
1656+ let videoRectInScreen = playerView. window? . convertToScreen ( videoRectInWindow)
1657+
1658+ if let videoRectInScreen {
1659+ PIPFixUp . withReturnRect ( videoRectInScreen) {
1660+ completionHandler ( true )
1661+ }
1662+ } else {
1663+ completionHandler ( false )
1664+ }
16451665 }
16461666
16471667 // Called Last
@@ -1652,6 +1672,81 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate {
16521672 }
16531673}
16541674
1675+ /// The PIPFixUp swizzles ``NSWindow.convertToScreen`` so that we can inject a corrected return value to work around an
1676+ /// issue introduced in macOS 26.
1677+ ///
1678+ /// The issue is that the pip return animation always animates the PiP window to the window's origin (0,0) which is bottom left
1679+ /// instead of to the position of the PiP's source AVPlayerLayer.
1680+ ///
1681+ /// The fix up is applied as narrowly as possible to avoid unintended side effects.
1682+ ///
1683+ /// We only override the return value while the `completionHandler(true)` in
1684+ /// `restoreUserInterfaceForPictureInPictureStopWithCompletionHandler` is being invoked. (Interestingly, this precludes us using the
1685+ /// the `async` overlay)
1686+ ///
1687+ /// And we only install the swizzled method if we're on a platform version that is affected by the issue (26+ currently, with a feedback filed)
1688+ ///
1689+ /// FB20159253
1690+ ///
1691+ @available ( macOS, deprecated: 26 , message: " Check to make sure this still works, then raise the deprecation " )
1692+ private final class PIPFixUp {
1693+ static private let lock = NSRecursiveLock ( )
1694+ static nonisolated ( unsafe) private var _pipReturnDestination : NSRect ?
1695+ static var pipReturnDestination : NSRect ? {
1696+ lock. withLock {
1697+ _pipReturnDestination
1698+ }
1699+ }
1700+
1701+ static func withReturnRect( _ rect: NSRect , _ whileLocked: ( ) -> Void ) {
1702+ lock. withLock {
1703+ _pipReturnDestination = rect
1704+
1705+ whileLocked ( )
1706+
1707+ _pipReturnDestination = nil
1708+ }
1709+ }
1710+
1711+ @MainActor
1712+ static func installPIPReturnRectInterposer( ) {
1713+ // ensure one-time
1714+ struct Did {
1715+ @MainActor
1716+ static var once = false
1717+ }
1718+ guard !Did. once else { return }
1719+ Did . once = true
1720+
1721+ guard #available( macOS 26 . 0 , * ) else { return }
1722+
1723+ let targetClass : AnyClass = NSWindow . self
1724+ let originalSelector = #selector( NSWindow . convertToScreen)
1725+ let swizzledSelector = #selector( NSWindow . swz_convertToScreen)
1726+
1727+ guard
1728+ let original = class_getInstanceMethod ( targetClass, originalSelector) ,
1729+ let swizzled = class_getInstanceMethod ( targetClass, swizzledSelector)
1730+ else {
1731+ assertionFailure ( " convertRectToScreen: not found on NSWindow " )
1732+ return
1733+ }
1734+
1735+ method_exchangeImplementations ( original, swizzled)
1736+ }
1737+ }
1738+
1739+ extension NSWindow {
1740+ @objc
1741+ func swz_convertToScreen( _ rect: NSRect ) -> NSRect {
1742+ guard let pipReturnRect = PIPFixUp . pipReturnDestination else {
1743+ return swz_convertToScreen ( rect)
1744+ }
1745+
1746+ return pipReturnRect
1747+ }
1748+ }
1749+
16551750#if DEBUG
16561751struct PUIPlayerView_Previews : PreviewProvider {
16571752 static var previews : some View {
0 commit comments