Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consistent PiP / full screen state, improved full screen transition #710

Merged
merged 7 commits into from
May 30, 2024
93 changes: 55 additions & 38 deletions PlayerUI/Controllers/PUIDetachedPlaybackStatusViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,54 @@

import Cocoa

final class PUIDetachedPlaybackStatusViewController: NSViewController {
public typealias PUISnapshotClosure = (@escaping (CGImage?) -> Void) -> Void

public struct DetachedPlaybackStatus: Identifiable {
public internal(set) var id: String
var icon: NSImage
var title: String
var subtitle: String
var snapshot: PUISnapshotClosure?
}

public extension DetachedPlaybackStatus {
static let pictureInPicture = DetachedPlaybackStatus(
id: "pictureInPicture",
icon: .PUIPictureInPictureLarge.withPlayerMetrics(.large),
title: "Picture in Picture",
subtitle: "Playing in Picture in Picture"
)

static let fullScreen = DetachedPlaybackStatus(
id: "fullScreen",
icon: .PUIFullScreen.withPlayerMetrics(.large),
title: "Full Screen",
subtitle: "Playing in Full Screen"
)

func snapshot(using closure: @escaping PUISnapshotClosure) -> Self {
var mSelf = self
mSelf.snapshot = closure
return mSelf
}
}

public final class PUIDetachedPlaybackStatusViewController: NSViewController {

private lazy var context = CIContext(options: [.useSoftwareRenderer: true])

var snapshot: CGImage? {
public var status: DetachedPlaybackStatus? {
didSet {
updateSnapshot(with: snapshot)
}
}
var providerIcon: NSImage? {
didSet {
iconImageView.image = providerIcon
}
}
var providerName: String = "" {
didSet {
titleLabel.stringValue = providerName
}
}
var providerDescription: String = "" {
didSet {
descriptionLabel.stringValue = providerDescription
guard let status else { return }
update(with: status)
}
}

init() {
public init() {
super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

Expand All @@ -61,7 +79,7 @@ final class PUIDetachedPlaybackStatusViewController: NSViewController {
return f
}()

private lazy var descriptionLabel: NSTextField = {
private lazy var subtitleLabel: NSTextField = {
let f = NSTextField(labelWithString: "")

f.font = .systemFont(ofSize: 16)
Expand All @@ -72,7 +90,7 @@ final class PUIDetachedPlaybackStatusViewController: NSViewController {
}()

private lazy var stackView: NSStackView = {
let v = NSStackView(views: [self.iconImageView, self.titleLabel, self.descriptionLabel])
let v = NSStackView(views: [self.iconImageView, self.titleLabel, self.subtitleLabel])

v.translatesAutoresizingMaskIntoConstraints = false
v.orientation = .vertical
Expand Down Expand Up @@ -100,18 +118,7 @@ final class PUIDetachedPlaybackStatusViewController: NSViewController {
return l
}()

private lazy var blackoutLayer: CALayer = {
let l = CALayer()

l.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
l.backgroundColor = NSColor.black.cgColor
l.opacity = 0
l.zPosition = 10

return l
}()

override func loadView() {
public override func loadView() {
view = NSView()
view.wantsLayer = true

Expand All @@ -130,15 +137,25 @@ final class PUIDetachedPlaybackStatusViewController: NSViewController {
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true

blackoutLayer.frame = view.bounds
container.addSublayer(blackoutLayer)
hide()
}

private func update(with status: DetachedPlaybackStatus) {
status.snapshot? { [weak self] image in
guard let self else { return }
updateSnapshot(with: image)
}

iconImageView.image = status.icon
titleLabel.stringValue = status.title
subtitleLabel.stringValue = status.subtitle
}

func show() {
public func show() {
view.isHidden = false
}

func hide() {
public func hide() {
view.isHidden = true
}

Expand Down
9 changes: 8 additions & 1 deletion PlayerUI/Protocols/PUIPlayerViewDelegates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ public protocol PUIPlayerViewDelegate: AnyObject {

}

public protocol PUIPlayerViewAppearanceDelegate: AnyObject {
public protocol PUIPlayerViewDetachedStatusPresenter: AnyObject {

func presentDetachedStatus(_ status: DetachedPlaybackStatus, for playerView: PUIPlayerView)
func dismissDetachedStatus(_ status: DetachedPlaybackStatus, for playerView: PUIPlayerView)

}

public protocol PUIPlayerViewAppearanceDelegate: AnyObject, PUIPlayerViewDetachedStatusPresenter {

func playerViewShouldShowTimelineView(_ playerView: PUIPlayerView) -> Bool
func playerViewShouldShowSubtitlesControl(_ playerView: PUIPlayerView) -> Bool
Expand Down
37 changes: 37 additions & 0 deletions PlayerUI/Util/AVPlayer+Layout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Cocoa
import AVFoundation
import ConfUIFoundation

@MainActor
public extension AVPlayer {
static let fallbackNaturalSize = CGSize(width: 1920, height: 1080)

func fittingRect(with bounds: CGRect) -> CGRect {
let videoSize = currentItem?.tracks.first(where: { $0.assetTrack?.mediaType == .video })?.assetTrack?.naturalSize ?? Self.fallbackNaturalSize

let fittingRect = AVMakeRect(aspectRatio: videoSize, insideRect: bounds)

UILog("📐 Video size: \(videoSize), fitting size: \(fittingRect.size)")

return fittingRect
}

func updateLayout(guide: NSLayoutGuide, container: NSView, constraints: inout [NSLayoutConstraint]) {
let videoRect = fittingRect(with: container.bounds)

if guide.owningView == nil {
container.addLayoutGuide(guide)
}

NSLayoutConstraint.deactivate(constraints)

constraints = [
guide.widthAnchor.constraint(equalToConstant: videoRect.width),
guide.heightAnchor.constraint(equalToConstant: videoRect.height),
guide.centerYAnchor.constraint(equalTo: container.centerYAnchor),
guide.centerXAnchor.constraint(equalTo: container.centerXAnchor)
]

NSLayoutConstraint.activate(constraints)
}
}
79 changes: 41 additions & 38 deletions PlayerUI/Views/PUIPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public final class PUIPlayerView: NSView {
public var mediaTitle: String?
public var mediaIsLiveStream: Bool = false

private var backgroundColor: NSColor? {
get { layer?.backgroundColor.flatMap { NSColor(cgColor: $0) } }
set { layer?.backgroundColor = newValue?.cgColor }
}

public init(player: AVPlayer) {
self.player = player
if AVPictureInPictureController.isPictureInPictureSupported() {
Expand All @@ -90,7 +95,7 @@ public final class PUIPlayerView: NSView {

wantsLayer = true
layer = PUIBoringLayer()
layer?.backgroundColor = NSColor.black.cgColor
backgroundColor = .black

setupPlayer(player)
setupControls()
Expand Down Expand Up @@ -406,28 +411,16 @@ public final class PUIPlayerView: NSView {

private lazy var videoLayoutGuideConstraints = [NSLayoutConstraint]()

private var currentBounds = CGRect.zero
private var currentBounds: CGRect?

private func updateVideoLayoutGuide() {
guard let videoTrack = player?.currentItem?.tracks.first(where: { $0.assetTrack?.mediaType == .video })?.assetTrack else { return }
guard let player else { return }

guard bounds != currentBounds else { return }
currentBounds = bounds

let videoRect = AVMakeRect(aspectRatio: videoTrack.naturalSize, insideRect: bounds)

guard videoRect.width.isFinite, videoRect.height.isFinite else { return }

NSLayoutConstraint.deactivate(videoLayoutGuideConstraints)

videoLayoutGuideConstraints = [
videoLayoutGuide.widthAnchor.constraint(equalToConstant: videoRect.width),
videoLayoutGuide.heightAnchor.constraint(equalToConstant: videoRect.height),
videoLayoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
videoLayoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor)
]
player.updateLayout(guide: videoLayoutGuide, container: self, constraints: &videoLayoutGuideConstraints)

NSLayoutConstraint.activate(videoLayoutGuideConstraints)
currentBounds = bounds
}

deinit {
Expand Down Expand Up @@ -667,16 +660,13 @@ public final class PUIPlayerView: NSView {
return b
}()

private lazy var videoLayoutGuide = NSLayoutGuide()
public private(set) lazy var videoLayoutGuide = NSLayoutGuide()

private var topTrailingMenuTopConstraint: NSLayoutConstraint!

private lazy var detachedStatusController = PUIDetachedPlaybackStatusViewController()

private func setupControls() {
addLayoutGuide(videoLayoutGuide)

detachedStatusController.view.translatesAutoresizingMaskIntoConstraints = false
let playerView = NSView()
playerView.translatesAutoresizingMaskIntoConstraints = false
playerView.wantsLayer = true
Expand All @@ -688,14 +678,6 @@ public final class PUIPlayerView: NSView {
playerView.topAnchor.constraint(equalTo: topAnchor).isActive = true
playerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true

detachedStatusController.hide()

addSubview(detachedStatusController.view)
detachedStatusController.view.leadingAnchor.constraint(equalTo: videoLayoutGuide.leadingAnchor).isActive = true
detachedStatusController.view.trailingAnchor.constraint(equalTo: videoLayoutGuide.trailingAnchor).isActive = true
detachedStatusController.view.topAnchor.constraint(equalTo: videoLayoutGuide.topAnchor).isActive = true
detachedStatusController.view.bottomAnchor.constraint(equalTo: videoLayoutGuide.bottomAnchor).isActive = true

// Volume controls
volumeControlsContainerView = NSStackView(views: [volumeButton, volumeSlider])

Expand Down Expand Up @@ -1366,6 +1348,7 @@ public final class PUIPlayerView: NSView {

NotificationCenter.default.addObserver(self, selector: #selector(windowWillEnterFullScreen), name: NSWindow.willEnterFullScreenNotification, object: newWindow)
NotificationCenter.default.addObserver(self, selector: #selector(windowWillExitFullScreen), name: NSWindow.willExitFullScreenNotification, object: newWindow)
NotificationCenter.default.addObserver(self, selector: #selector(windowDidExitFullScreen), name: NSWindow.didExitFullScreenNotification, object: newWindow)
NotificationCenter.default.addObserver(self, selector: #selector(windowDidResignMain), name: NSWindow.didResignMainNotification, object: newWindow)
NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeMain), name: NSWindow.didBecomeMainNotification, object: newWindow)
}
Expand All @@ -1390,18 +1373,32 @@ public final class PUIPlayerView: NSView {
}

@objc private func windowWillEnterFullScreen() {
appearanceDelegate?.presentDetachedStatus(.fullScreen.snapshot(using: snapshotClosure), for: self)

fullScreenButton.isHidden = true
updateTopTrailingMenuPosition()
}

@objc private func windowWillExitFullScreen() {
/// The transition looks nicer if there's no background color, otherwise the player looks like it attaches
/// to the whole shelf area with black bars depending on the aspect ratio.
backgroundColor = .clear

if let d = appearanceDelegate {
fullScreenButton.isHidden = !d.playerViewShouldShowFullScreenButton(self)
}

updateTopTrailingMenuPosition()
}

@objc private func windowDidExitFullScreen() {
/// Restore solid black background after finishing exit full screen transition.
backgroundColor = .black

/// The detached status presentation takes care of leaving a black background before we finish the full screen transition.
appearanceDelegate?.dismissDetachedStatus(.fullScreen, for: self)
}

@objc private func windowDidBecomeMain() {

// becoming main in full screen means we're entering the space
Expand Down Expand Up @@ -1568,22 +1565,26 @@ extension PUIPlayerView: PUITimelineViewDelegate {

extension PUIPlayerView: AVPictureInPictureControllerDelegate {

private var snapshotClosure: PUISnapshotClosure {
{ [weak self] completion in
guard let self else {
completion(nil)
return
}
snapshotPlayer(completion: completion)
}
}

// Start

public func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
delegate?.playerViewWillEnterPictureInPictureMode(self)

snapshotPlayer { [weak self] image in
self?.detachedStatusController.snapshot = image
}

detachedStatusController.providerIcon = .PUIPictureInPictureLarge.withPlayerMetrics(.large)
detachedStatusController.providerName = "Picture in Picture"
detachedStatusController.providerDescription = "Playing in Picture in Picture"
detachedStatusController.show()
appearanceDelegate?.presentDetachedStatus(.pictureInPicture.snapshot(using: snapshotClosure), for: self)
}

public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
fullScreenButton.isHidden = true
pipButton.state = .on

invalidateTouchBar()
Expand Down Expand Up @@ -1623,13 +1624,15 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate {
}
}

fullScreenButton.isHidden = false

completionHandler(true)
}

// Called Last
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
appearanceDelegate?.dismissDetachedStatus(.pictureInPicture, for: self)
pipButton.state = .off
detachedStatusController.hide()
invalidateTouchBar()
}
}
Expand Down
5 changes: 4 additions & 1 deletion PlayerUI/Views/PUIPlayerWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ open class PUIPlayerWindow: NSWindow {
super.init(contentRect: contentRect, styleMask: effectiveStyle, backing: bufferingType, defer: flag)

applyCustomizations()

backgroundColor = .clear
isOpaque = false
}

open override func awakeFromNib() {
Expand Down Expand Up @@ -236,7 +239,7 @@ private class PUIPlayerWindowContentView: NSView {
}

fileprivate override func draw(_ dirtyRect: NSRect) {
NSColor.black.setFill()
NSColor.clear.setFill()
dirtyRect.fill()
}

Expand Down
Loading