Skip to content

Commit 6bbec8b

Browse files
Fix PiP return animation via swizzling!
1 parent d184234 commit 6bbec8b

File tree

2 files changed

+140
-12
lines changed

2 files changed

+140
-12
lines changed

PlayerUI/Views/PUIPlayerView.swift

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import AVKit
1313
import Combine
1414
import SwiftUI
1515
import ConfUIFoundation
16+
import ObjectiveC
1617

1718
public 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
16561751
struct PUIPlayerView_Previews: PreviewProvider {
16571752
static var previews: some View {

WWDC/Controllers/Sessions/Playback/VideoPlayerWindowController.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,29 @@ final class VideoPlayerWindowController: NSWindowController, NSWindowDelegate {
3838

3939
originalContainer?.layer?.backgroundColor = .black
4040

41-
let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
41+
var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
42+
43+
if fullscreenOnly {
44+
// fullscreen_transition: Remove these so we can make the window appear without any noticeable transition
45+
styleMask.remove(.titled)
46+
styleMask.insert(.borderless)
47+
}
4248

4349
var rect = PUIPlayerWindow.bestScreenRectFromDetachingContainer(playerViewController.view, layoutGuide: playerViewController.playerView.videoLayoutGuide)
4450
if rect == NSRect.zero { rect = PUIPlayerWindow.centerRectForProposedContentRect(playerViewController.view.bounds) }
4551

4652
let window = PUIPlayerWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false)
4753
window.isReleasedWhenClosed = true
4854

55+
if fullscreenOnly {
56+
// We must insert this behavior because `styleMask.remove(.titled)` above disables it
57+
window.collectionBehavior.insert(.fullScreenPrimary)
58+
// fullscreen_transition: So we can make the window appear without any noticeable transition
59+
// calling showWindow() has a default animation that makes it scale slightly
60+
window.animationBehavior = .none
61+
window.hasShadow = false
62+
}
63+
4964
super.init(window: window)
5065

5166
window.delegate = self
@@ -62,16 +77,34 @@ final class VideoPlayerWindowController: NSWindowController, NSWindowDelegate {
6277
fatalError("VideoPlayerWindowController can't be initialized with a coder")
6378
}
6479

80+
/// `fullscreen_transition`: This exists because `showWindow(_:)` returns well before the window is actually visible on screen.
81+
/// Which means when we want to enter fullscreen right away, the animation starts at screen (0,0) and looks bad.
82+
///
83+
/// So we use ``windowDidChangeOcclusionState`` to detect when the window is actually visible and only then enter fullscreen.
84+
///
85+
/// This API behavior may be OS version dependent. Currently confirmed on 26.0
86+
weak var deferredEnterFullScreenSender: AnyObject?
6587
override func showWindow(_ sender: Any?) {
6688
super.showWindow(sender)
6789

6890
if !fullscreenOnly {
6991
playerWindow?.applySizePreset(.half)
92+
} else if let sender = sender as? AnyObject {
93+
deferredEnterFullScreenSender = sender
7094
} else {
7195
window?.toggleFullScreen(sender)
7296
}
7397
}
7498

99+
func windowDidChangeOcclusionState(_ notification: Notification) {
100+
guard let window else { return }
101+
102+
if let deferredEnterFullScreenSender, fullscreenOnly && window.occlusionState.contains(.visible) {
103+
self.deferredEnterFullScreenSender = nil
104+
window.toggleFullScreen(deferredEnterFullScreenSender)
105+
}
106+
}
107+
75108
// MARK: - Reattachment and fullscreen support
76109

77110
var windowWasAskedToClose = false

0 commit comments

Comments
 (0)