mirror of
https://github.com/tappollo/WWDC.git
synced 2026-06-19 06:36:00 +08:00
1314 lines
46 KiB
Swift
1314 lines
46 KiB
Swift
//
|
|
// PUIPlayerView.swift
|
|
// PlayerProto
|
|
//
|
|
// Created by Guilherme Rambo on 29/04/17.
|
|
// Copyright © 2017 Guilherme Rambo. All rights reserved.
|
|
//
|
|
|
|
import Cocoa
|
|
import AVFoundation
|
|
|
|
public final class PUIPlayerView: NSView {
|
|
|
|
// MARK: - Public API
|
|
|
|
public weak var delegate: PUIPlayerViewDelegate?
|
|
|
|
public internal(set) var isInPictureInPictureMode: Bool = false {
|
|
didSet {
|
|
guard isInPictureInPictureMode != oldValue else { return }
|
|
|
|
self.pipButton.state = isInPictureInPictureMode ? NSOnState : NSOffState
|
|
|
|
if isInPictureInPictureMode {
|
|
self.externalStatusController.providerIcon = .PUIPictureInPictureLarge
|
|
self.externalStatusController.providerName = "Picture in Picture"
|
|
self.externalStatusController.providerDescription = "Playing in Picture in Picture"
|
|
self.externalStatusController.view.isHidden = false
|
|
} else {
|
|
self.externalStatusController.view.isHidden = true
|
|
}
|
|
}
|
|
}
|
|
|
|
public weak var appearanceDelegate: PUIPlayerViewAppearanceDelegate? {
|
|
didSet {
|
|
invalidateAppearance()
|
|
}
|
|
}
|
|
|
|
public var timelineDelegate: PUITimelineDelegate? {
|
|
get {
|
|
return timelineView.delegate
|
|
}
|
|
set {
|
|
timelineView.delegate = newValue
|
|
}
|
|
}
|
|
|
|
public var annotations: [PUITimelineAnnotation] {
|
|
get {
|
|
return sortedAnnotations
|
|
}
|
|
set {
|
|
self.sortedAnnotations = newValue.filter({ $0.isValid }).sorted(by: { $0.timestamp < $1.timestamp })
|
|
|
|
timelineView.annotations = sortedAnnotations
|
|
}
|
|
}
|
|
|
|
public weak var player: AVPlayer? {
|
|
didSet {
|
|
guard oldValue != player else { return }
|
|
|
|
if let oldPlayer = oldValue {
|
|
teardown(player: oldPlayer)
|
|
}
|
|
|
|
guard player != nil else { return }
|
|
|
|
setupPlayer()
|
|
}
|
|
}
|
|
|
|
public var isInFullScreenPlayerWindow: Bool {
|
|
return window is PUIPlayerWindow
|
|
}
|
|
|
|
public var remoteMediaUrl: URL?
|
|
|
|
var pictureContainer: PUIPictureContainerViewController!
|
|
|
|
public init(player: AVPlayer) {
|
|
self.player = player
|
|
|
|
super.init(frame: .zero)
|
|
|
|
self.wantsLayer = true
|
|
self.layer = PUIBoringLayer()
|
|
self.layer?.backgroundColor = NSColor.black.cgColor
|
|
|
|
setupPlayer()
|
|
setupControls()
|
|
}
|
|
|
|
public required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public var isPlaying: Bool {
|
|
if let externalProvider = currentExternalPlaybackProvider {
|
|
return !externalProvider.status.rate.isZero
|
|
} else {
|
|
return isInternalPlayerPlaying
|
|
}
|
|
}
|
|
|
|
public var isInternalPlayerPlaying: Bool {
|
|
guard let player = player else { return false }
|
|
|
|
return !player.rate.isZero
|
|
}
|
|
|
|
public var currentTimestamp: Double {
|
|
guard let player = player else { return 0 }
|
|
|
|
if let externalProvider = currentExternalPlaybackProvider {
|
|
return Double(externalProvider.status.currentTime)
|
|
} else {
|
|
return Double(CMTimeGetSeconds(player.currentTime()))
|
|
}
|
|
}
|
|
|
|
public var firstAnnotationBeforeCurrentTime: PUITimelineAnnotation? {
|
|
return annotations.filter({ annotation in
|
|
guard annotation.isValid else { return false }
|
|
|
|
return annotation.timestamp + 1 < currentTimestamp
|
|
}).last
|
|
}
|
|
|
|
public var firstAnnotationAfterCurrentTime: PUITimelineAnnotation? {
|
|
return annotations.filter({ annotation in
|
|
guard annotation.isValid else { return false }
|
|
|
|
return annotation.timestamp > currentTimestamp + 1
|
|
}).first
|
|
}
|
|
|
|
public func seek(to annotation: PUITimelineAnnotation) {
|
|
guard let player = player else { return }
|
|
|
|
let time = CMTimeMakeWithSeconds(Float64(annotation.timestamp), 9000)
|
|
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.seek(to: annotation.timestamp)
|
|
} else {
|
|
player.seek(to: time)
|
|
}
|
|
}
|
|
|
|
public var playbackSpeed: PUIPlaybackSpeed = .normal {
|
|
didSet {
|
|
guard let player = player else { return }
|
|
|
|
if isPlaying && !isPlayingExternally { player.rate = playbackSpeed.rawValue }
|
|
|
|
updatePlaybackSpeedState()
|
|
}
|
|
}
|
|
|
|
public var isPlayingExternally: Bool {
|
|
return currentExternalPlaybackProvider != nil
|
|
}
|
|
|
|
public var hideAllControls: Bool = false {
|
|
didSet {
|
|
controlsContainerView.isHidden = hideAllControls
|
|
extrasMenuContainerView.isHidden = hideAllControls
|
|
}
|
|
}
|
|
|
|
// MARK: External playback
|
|
|
|
fileprivate(set) var externalPlaybackProviders: [PUIExternalPlaybackProviderRegistration] = [] {
|
|
didSet {
|
|
updateExternalPlaybackMenus()
|
|
}
|
|
}
|
|
|
|
public func registerExternalPlaybackProvider(_ provider: PUIExternalPlaybackProvider.Type) {
|
|
// prevent registering the same provider multiple times
|
|
guard !externalPlaybackProviders.contains(where: { type(of: $0.provider).name == provider.name }) else {
|
|
NSLog("PUIPlayerView WARNING: tried to register provider \(provider.name) which was already registered")
|
|
return
|
|
}
|
|
|
|
let instance = provider.init(consumer: self)
|
|
let button = self.button(for: instance)
|
|
let registration = PUIExternalPlaybackProviderRegistration(provider: instance, button: button, menu: NSMenu())
|
|
|
|
externalPlaybackProviders.append(registration)
|
|
}
|
|
|
|
public func invalidateAppearance() {
|
|
configureWithAppearanceFromDelegate()
|
|
}
|
|
|
|
// MARK: - Private API
|
|
|
|
fileprivate var lastKnownWindow: NSWindow? = nil
|
|
|
|
private var sortedAnnotations: [PUITimelineAnnotation] = [] {
|
|
didSet {
|
|
updateAnnotationsState()
|
|
}
|
|
}
|
|
|
|
private var playerTimeObserver: Any?
|
|
|
|
fileprivate var asset: AVAsset? {
|
|
return player?.currentItem?.asset
|
|
}
|
|
|
|
private var playerLayer = PUIBoringPlayerLayer()
|
|
|
|
private func setupPlayer() {
|
|
guard let player = player else { return }
|
|
|
|
playerLayer.player = player
|
|
playerLayer.videoGravity = AVLayerVideoGravityResizeAspect
|
|
|
|
if pictureContainer == nil {
|
|
pictureContainer = PUIPictureContainerViewController(playerLayer: playerLayer)
|
|
pictureContainer.delegate = self
|
|
pictureContainer.view.frame = bounds
|
|
pictureContainer.view.autoresizingMask = [.viewWidthSizable, .viewHeightSizable]
|
|
|
|
addSubview(pictureContainer.view)
|
|
}
|
|
|
|
player.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.initial, .new], context: nil)
|
|
player.addObserver(self, forKeyPath: #keyPath(AVPlayer.volume), options: [.initial, .new], context: nil)
|
|
player.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: [.initial, .new], context: nil)
|
|
player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.loadedTimeRanges), options: [.initial, .new], context: nil)
|
|
player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.duration), options: [.initial, .new], context: nil)
|
|
|
|
asset?.loadValuesAsynchronously(forKeys: ["tracks"], completionHandler: metadataBecameAvailable)
|
|
|
|
playerTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(0.5, 9000), queue: DispatchQueue.main) { [weak self] currentTime in
|
|
self?.playerTimeDidChange(time: currentTime)
|
|
}
|
|
}
|
|
|
|
private func teardown(player oldValue: AVPlayer) {
|
|
oldValue.pause()
|
|
oldValue.cancelPendingPrerolls()
|
|
|
|
if let observer = playerTimeObserver {
|
|
oldValue.removeTimeObserver(observer)
|
|
playerTimeObserver = nil
|
|
}
|
|
|
|
oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status))
|
|
oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate))
|
|
oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.volume))
|
|
oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.loadedTimeRanges))
|
|
oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.duration))
|
|
}
|
|
|
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
DispatchQueue.main.async {
|
|
if keyPath == #keyPath(AVPlayer.status) {
|
|
self.playerStatusChanged()
|
|
} else if keyPath == #keyPath(AVPlayer.currentItem.loadedTimeRanges) {
|
|
self.updateBufferedSegments()
|
|
} else if keyPath == #keyPath(AVPlayer.volume) {
|
|
self.playerVolumeChanged()
|
|
} else if keyPath == #keyPath(AVPlayer.rate) {
|
|
self.updatePlayingState()
|
|
} else if keyPath == #keyPath(AVPlayer.currentItem.duration) {
|
|
self.metadataBecameAvailable()
|
|
} else {
|
|
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func playerVolumeChanged() {
|
|
guard let player = player else { return }
|
|
|
|
if player.volume.isZero {
|
|
self.volumeButton.image = .PUIVolumeMuted
|
|
self.volumeSlider.doubleValue = 0
|
|
} else {
|
|
self.volumeButton.image = .PUIVolume
|
|
self.volumeSlider.doubleValue = Double(player.volume)
|
|
}
|
|
}
|
|
|
|
private func updateBufferedSegments() {
|
|
guard let player = player else { return }
|
|
|
|
guard let loadedRanges = player.currentItem?.loadedTimeRanges else { return }
|
|
guard let durationTime = player.currentItem?.duration else { return }
|
|
|
|
let duration = Double(CMTimeGetSeconds(durationTime))
|
|
|
|
let segments = loadedRanges.map { value -> PUIBufferSegment in
|
|
let range = value.timeRangeValue
|
|
let startTime = Double(CMTimeGetSeconds(range.start))
|
|
let segmentDuration = Double(CMTimeGetSeconds(range.duration))
|
|
|
|
return PUIBufferSegment(start: startTime / duration, duration: segmentDuration / duration)
|
|
}
|
|
|
|
timelineView.loadedSegments = Set<PUIBufferSegment>(segments)
|
|
}
|
|
|
|
private func updateAnnotationsState() {
|
|
let canGoBack = firstAnnotationBeforeCurrentTime != nil
|
|
let canGoForward = firstAnnotationAfterCurrentTime != nil
|
|
|
|
previousAnnotationButton.isEnabled = canGoBack
|
|
nextAnnotationButton.isEnabled = canGoForward
|
|
}
|
|
|
|
fileprivate func updatePlayingState() {
|
|
pipController?.setPlaying(isPlaying)
|
|
|
|
if isPlaying {
|
|
playButton.image = .PUIPause
|
|
} else {
|
|
playButton.image = .PUIPlay
|
|
}
|
|
}
|
|
|
|
fileprivate func updatePlaybackSpeedState() {
|
|
speedButton.image = playbackSpeed.icon
|
|
}
|
|
|
|
fileprivate var currentPresentationSize: NSSize? {
|
|
guard let size = player?.currentItem?.presentationSize, size != NSZeroSize else { return nil }
|
|
|
|
return size
|
|
}
|
|
|
|
private func playerStatusChanged() {
|
|
guard let player = player else { return }
|
|
|
|
switch player.status {
|
|
case .readyToPlay:
|
|
self.updateTimeLabels()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
private func metadataBecameAvailable() {
|
|
DispatchQueue.main.async {
|
|
guard let duration = self.asset?.duration else { return }
|
|
|
|
self.timelineView.mediaDuration = Double(CMTimeGetSeconds(duration))
|
|
}
|
|
}
|
|
|
|
fileprivate func playerTimeDidChange(time: CMTime) {
|
|
DispatchQueue.main.async {
|
|
guard let player = self.player else { return }
|
|
|
|
guard player.hasValidMediaDuration else { return }
|
|
guard let duration = self.asset?.duration else { return }
|
|
|
|
let progress = Double(CMTimeGetSeconds(time) / CMTimeGetSeconds(duration))
|
|
self.timelineView.playbackProgress = progress
|
|
|
|
self.updateAnnotationsState()
|
|
self.updateTimeLabels()
|
|
}
|
|
}
|
|
|
|
private func updateTimeLabels() {
|
|
guard let player = player else { return }
|
|
|
|
guard player.hasValidMediaDuration else { return }
|
|
guard let duration = self.asset?.duration else { return }
|
|
|
|
let time = player.currentTime()
|
|
|
|
self.elapsedTimeLabel.stringValue = String(time: time) ?? ""
|
|
|
|
let remainingTime = CMTimeSubtract(duration, time)
|
|
self.remainingTimeLabel.stringValue = String(time: remainingTime) ?? ""
|
|
}
|
|
|
|
deinit {
|
|
if let player = player {
|
|
teardown(player: player)
|
|
}
|
|
}
|
|
|
|
// MARK: Controls
|
|
|
|
fileprivate var wasPlayingBeforeStartingInteractiveSeek = false
|
|
|
|
private var extrasMenuContainerView: NSStackView!
|
|
|
|
// fileprivate var controlsVisualEffectView: NSVisualEffectView!
|
|
fileprivate var scrimContainerView: PUIScrimContainerView!
|
|
|
|
private var timeLabelsContainerView: NSStackView!
|
|
private var controlsContainerView: NSStackView!
|
|
private var volumeControlsContainerView: NSStackView!
|
|
private var centerButtonsContainerView: NSStackView!
|
|
|
|
fileprivate var timelineView: PUITimelineView!
|
|
|
|
private lazy var elapsedTimeLabel: NSTextField = {
|
|
let l = NSTextField(labelWithString: "00:00:00")
|
|
|
|
l.alignment = .left
|
|
l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium)
|
|
l.textColor = .timeLabel
|
|
|
|
return l
|
|
}()
|
|
|
|
private lazy var remainingTimeLabel: NSTextField = {
|
|
let l = NSTextField(labelWithString: "-00:00:00")
|
|
|
|
l.alignment = .right
|
|
l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium)
|
|
l.textColor = .timeLabel
|
|
|
|
return l
|
|
}()
|
|
|
|
private lazy var fullScreenButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIFullScreen
|
|
b.target = self
|
|
b.action = #selector(toggleFullscreen(_:))
|
|
b.toolTip = "Toggle full screen"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var volumeButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIVolume
|
|
b.target = self
|
|
b.action = #selector(toggleMute(_:))
|
|
b.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
b.toolTip = "Mute/unmute"
|
|
|
|
return b
|
|
}()
|
|
|
|
fileprivate lazy var volumeSlider: PUISlider = {
|
|
let s = PUISlider(frame: .zero)
|
|
|
|
s.widthAnchor.constraint(equalToConstant: 88).isActive = true
|
|
s.isContinuous = true
|
|
s.target = self
|
|
s.minValue = 0
|
|
s.maxValue = 1
|
|
s.action = #selector(volumeSliderAction(_:))
|
|
|
|
return s
|
|
}()
|
|
|
|
private lazy var subtitlesButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUISubtitles
|
|
b.target = self
|
|
b.action = #selector(showSubtitlesMenu(_:))
|
|
b.toolTip = "Subtitles"
|
|
|
|
return b
|
|
}()
|
|
|
|
fileprivate lazy var playButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIPlay
|
|
b.target = self
|
|
b.action = #selector(togglePlaying(_:))
|
|
b.toolTip = "Play/pause"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var previousAnnotationButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIPreviousBookmark
|
|
b.target = self
|
|
b.action = #selector(previousAnnotation(_:))
|
|
b.toolTip = "Go to previous bookmark"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var nextAnnotationButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUINextBookmark
|
|
b.target = self
|
|
b.action = #selector(nextAnnotation(_:))
|
|
b.toolTip = "Go to next bookmark"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var backButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIBack30s
|
|
b.target = self
|
|
b.action = #selector(goBackInTime(_:))
|
|
b.toolTip = "Go back 30s"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var forwardButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIForward30s
|
|
b.target = self
|
|
b.action = #selector(goForwardInTime(_:))
|
|
b.toolTip = "Go forward 30s"
|
|
|
|
return b
|
|
}()
|
|
|
|
fileprivate lazy var speedButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUISpeedOne
|
|
b.target = self
|
|
b.action = #selector(toggleSpeed(_:))
|
|
b.toolTip = "Change playback speed"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var addAnnotationButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = .PUIBookmark
|
|
b.target = self
|
|
b.action = #selector(addAnnotation(_:))
|
|
b.toolTip = "Add bookmark"
|
|
|
|
return b
|
|
}()
|
|
|
|
private lazy var pipButton: PUIButton = {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.isToggle = true
|
|
b.image = .PUIPictureInPicture
|
|
b.target = self
|
|
b.action = #selector(togglePip(_:))
|
|
b.toolTip = "Toggle picture in picture"
|
|
|
|
return b
|
|
}()
|
|
|
|
private var extrasMenuTopConstraint: NSLayoutConstraint!
|
|
|
|
private lazy var externalStatusController = PUIExternalPlaybackStatusViewController()
|
|
|
|
private func setupControls() {
|
|
externalStatusController.view.isHidden = true
|
|
externalStatusController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(externalStatusController.view)
|
|
externalStatusController.view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
|
externalStatusController.view.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
|
|
externalStatusController.view.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
|
externalStatusController.view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
|
|
|
// // VFX view
|
|
// controlsVisualEffectView = NSVisualEffectView(frame: bounds)
|
|
// controlsVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
|
// controlsVisualEffectView.material = .ultraDark
|
|
// controlsVisualEffectView.appearance = NSAppearance(named: NSAppearanceNameVibrantDark)
|
|
// controlsVisualEffectView.blendingMode = .withinWindow
|
|
// controlsVisualEffectView.wantsLayer = true
|
|
// controlsVisualEffectView.layer?.masksToBounds = false
|
|
// controlsVisualEffectView.state = .active
|
|
scrimContainerView = PUIScrimContainerView(frame: bounds)
|
|
|
|
// Time labels
|
|
timeLabelsContainerView = NSStackView(views: [elapsedTimeLabel, remainingTimeLabel])
|
|
timeLabelsContainerView.distribution = .fillEqually
|
|
timeLabelsContainerView.orientation = .horizontal
|
|
timeLabelsContainerView.alignment = .centerY
|
|
|
|
// Timeline view
|
|
timelineView = PUITimelineView(frame: .zero)
|
|
timelineView.viewDelegate = self
|
|
|
|
// Volume controls
|
|
volumeControlsContainerView = NSStackView(views: [volumeButton, volumeSlider])
|
|
|
|
volumeControlsContainerView.orientation = .horizontal
|
|
volumeControlsContainerView.spacing = 6
|
|
volumeControlsContainerView.alignment = .centerY
|
|
|
|
// Center Buttons
|
|
centerButtonsContainerView = NSStackView(frame: bounds)
|
|
|
|
// Leading controls (volume, subtitles)
|
|
centerButtonsContainerView.addView(volumeButton, in: .leading)
|
|
centerButtonsContainerView.addView(volumeSlider, in: .leading)
|
|
centerButtonsContainerView.addView(subtitlesButton, in: .leading)
|
|
|
|
centerButtonsContainerView.setCustomSpacing(6, after: volumeButton)
|
|
|
|
// Center controls (play, annotations, forward, backward)
|
|
centerButtonsContainerView.addView(backButton, in: .center)
|
|
centerButtonsContainerView.addView(previousAnnotationButton, in: .center)
|
|
centerButtonsContainerView.addView(playButton, in: .center)
|
|
centerButtonsContainerView.addView(nextAnnotationButton, in: .center)
|
|
centerButtonsContainerView.addView(forwardButton, in: .center)
|
|
|
|
// Trailing controls (speed, add annotation, pip)
|
|
centerButtonsContainerView.addView(speedButton, in: .trailing)
|
|
centerButtonsContainerView.addView(addAnnotationButton, in: .trailing)
|
|
centerButtonsContainerView.addView(pipButton, in: .trailing)
|
|
|
|
centerButtonsContainerView.orientation = .horizontal
|
|
centerButtonsContainerView.spacing = 24
|
|
centerButtonsContainerView.distribution = .gravityAreas
|
|
centerButtonsContainerView.alignment = .centerY
|
|
|
|
// Visibility priorities
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: volumeButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: volumeSlider)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: subtitlesButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: backButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: previousAnnotationButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityMustHold, for: playButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: forwardButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: nextAnnotationButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: speedButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: addAnnotationButton)
|
|
centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: pipButton)
|
|
centerButtonsContainerView.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .horizontal)
|
|
|
|
// Main stack view
|
|
controlsContainerView = NSStackView(views: [
|
|
timeLabelsContainerView,
|
|
timelineView,
|
|
centerButtonsContainerView
|
|
])
|
|
|
|
controlsContainerView.orientation = .vertical
|
|
controlsContainerView.spacing = 12
|
|
controlsContainerView.distribution = .fill
|
|
controlsContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
controlsContainerView.wantsLayer = true
|
|
controlsContainerView.layer?.masksToBounds = false
|
|
controlsContainerView.layer?.zPosition = 10
|
|
|
|
addSubview(scrimContainerView)
|
|
addSubview(controlsContainerView)
|
|
|
|
scrimContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
scrimContainerView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
|
scrimContainerView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
|
|
scrimContainerView.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
|
scrimContainerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
|
|
|
controlsContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12).isActive = true
|
|
controlsContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12).isActive = true
|
|
controlsContainerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12).isActive = true
|
|
|
|
timeLabelsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true
|
|
timeLabelsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true
|
|
|
|
centerButtonsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true
|
|
centerButtonsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true
|
|
|
|
// Extras menu (external playback, fullscreen button)
|
|
extrasMenuContainerView = NSStackView(views: [fullScreenButton])
|
|
extrasMenuContainerView.orientation = .horizontal
|
|
extrasMenuContainerView.alignment = .centerY
|
|
extrasMenuContainerView.distribution = .equalSpacing
|
|
extrasMenuContainerView.spacing = 30
|
|
|
|
addSubview(extrasMenuContainerView)
|
|
|
|
extrasMenuContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12).isActive = true
|
|
updateExtrasMenuPosition()
|
|
}
|
|
|
|
private func configureWithAppearanceFromDelegate() {
|
|
guard let d = appearanceDelegate else { return }
|
|
|
|
subtitlesButton.isHidden = !d.playerViewShouldShowSubtitlesControl(self)
|
|
pipButton.isHidden = !d.playerViewShouldShowPictureInPictureControl(self)
|
|
speedButton.isHidden = !d.playerViewShouldShowSpeedControl(self)
|
|
|
|
let disableAnnotationControls = !d.playerViewShouldShowAnnotationControls(self)
|
|
addAnnotationButton.isHidden = disableAnnotationControls
|
|
previousAnnotationButton.isHidden = disableAnnotationControls
|
|
nextAnnotationButton.isHidden = disableAnnotationControls
|
|
|
|
let disableBackAndForward = !d.playerViewShouldShowBackAndForwardControls(self)
|
|
backButton.isHidden = disableBackAndForward
|
|
forwardButton.isHidden = disableBackAndForward
|
|
|
|
updateExternalPlaybackControlsAvailability()
|
|
|
|
fullScreenButton.isHidden = !d.playerViewShouldShowFullScreenButton(self)
|
|
}
|
|
|
|
fileprivate func updateExternalPlaybackControlsAvailability() {
|
|
guard let d = appearanceDelegate else { return }
|
|
|
|
let disableExternalPlayback = !d.playerViewShouldShowExternalPlaybackControls(self)
|
|
externalPlaybackProviders.forEach({ $0.button.isHidden = disableExternalPlayback })
|
|
}
|
|
|
|
private var isDominantViewInWindow: Bool {
|
|
guard let contentView = window?.contentView else { return false }
|
|
guard contentView != self else { return true }
|
|
|
|
return bounds.height >= contentView.bounds.height
|
|
}
|
|
|
|
private func updateExtrasMenuPosition() {
|
|
let topConstant: CGFloat = isDominantViewInWindow ? 34 : 12
|
|
|
|
if extrasMenuTopConstraint == nil {
|
|
extrasMenuTopConstraint = extrasMenuContainerView.topAnchor.constraint(equalTo: topAnchor, constant: topConstant)
|
|
extrasMenuTopConstraint.isActive = true
|
|
} else {
|
|
extrasMenuTopConstraint.constant = topConstant
|
|
}
|
|
}
|
|
|
|
fileprivate func updateExternalPlaybackMenus() {
|
|
// clean menu
|
|
extrasMenuContainerView.arrangedSubviews.enumerated().forEach { idx, v in
|
|
guard idx < extrasMenuContainerView.arrangedSubviews.count - 1 else { return }
|
|
|
|
extrasMenuContainerView.removeArrangedSubview(v)
|
|
}
|
|
|
|
// repopulate
|
|
externalPlaybackProviders.filter({ $0.provider.isAvailable }).forEach { registration in
|
|
registration.button.menu = registration.menu
|
|
extrasMenuContainerView.insertArrangedSubview(registration.button, at: 0)
|
|
}
|
|
}
|
|
|
|
private func button(for provider: PUIExternalPlaybackProvider) -> PUIButton {
|
|
let b = PUIButton(frame: .zero)
|
|
|
|
b.image = provider.icon
|
|
b.toolTip = type(of: provider).name
|
|
b.showsMenuOnLeftClick = true
|
|
|
|
return b
|
|
}
|
|
|
|
// MARK: - Control actions
|
|
|
|
private var playerVolumeBeforeMuting: Float = 1.0
|
|
|
|
@IBAction public func toggleMute(_ sender: Any?) {
|
|
guard let player = player else { return }
|
|
|
|
if player.volume.isZero {
|
|
player.volume = playerVolumeBeforeMuting
|
|
} else {
|
|
playerVolumeBeforeMuting = player.volume
|
|
player.volume = 0
|
|
}
|
|
}
|
|
|
|
@IBAction func volumeSliderAction(_ sender: Any?) {
|
|
guard let player = player else { return }
|
|
|
|
let v = Float(volumeSlider.doubleValue)
|
|
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.setVolume(v)
|
|
} else {
|
|
player.volume = v
|
|
}
|
|
}
|
|
|
|
@IBAction func showSubtitlesMenu(_ sender: PUIButton) {
|
|
// TODO: show subtitles menu
|
|
}
|
|
|
|
@IBAction public func togglePlaying(_ sender: Any?) {
|
|
if isPlaying {
|
|
pause(sender)
|
|
} else {
|
|
play(sender)
|
|
}
|
|
}
|
|
|
|
@IBAction public func pause(_ sender: Any?) {
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.pause()
|
|
} else {
|
|
player?.rate = 0
|
|
}
|
|
}
|
|
|
|
@IBAction public func play(_ sender: Any?) {
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.play()
|
|
} else {
|
|
player?.rate = playbackSpeed.rawValue
|
|
}
|
|
}
|
|
|
|
@IBAction public func previousAnnotation(_ sender: Any?) {
|
|
guard let annotation = firstAnnotationBeforeCurrentTime else { return }
|
|
|
|
seek(to: annotation)
|
|
}
|
|
|
|
@IBAction public func nextAnnotation(_ sender: Any?) {
|
|
guard let annotation = firstAnnotationAfterCurrentTime else { return }
|
|
|
|
seek(to: annotation)
|
|
}
|
|
|
|
@IBAction public func goBackInTime(_ sender: Any?) {
|
|
modifyCurrentTime(with: 30, using: CMTimeSubtract)
|
|
}
|
|
|
|
@IBAction public func goForwardInTime(_ sender: Any?) {
|
|
modifyCurrentTime(with: 30, using: CMTimeAdd)
|
|
}
|
|
|
|
@IBAction public func toggleSpeed(_ sender: Any?) {
|
|
playbackSpeed = playbackSpeed.next
|
|
}
|
|
|
|
@IBAction public func addAnnotation(_ sender: NSView?) {
|
|
guard let player = player else { return }
|
|
|
|
let timestamp = Double(CMTimeGetSeconds(player.currentTime()))
|
|
|
|
delegate?.playerViewDidSelectAddAnnotation(self, at: timestamp)
|
|
}
|
|
|
|
@IBAction public func togglePip(_ sender: NSView?) {
|
|
if isInPictureInPictureMode {
|
|
exitPictureInPictureMode()
|
|
} else {
|
|
enterPictureInPictureMode()
|
|
}
|
|
}
|
|
|
|
@IBAction public func toggleFullscreen(_ sender: Any?) {
|
|
delegate?.playerViewDidSelectToggleFullScreen(self)
|
|
}
|
|
|
|
private func modifyCurrentTime(with seconds: Double, using function: (CMTime, CMTime) -> CMTime) {
|
|
guard let player = player else { return }
|
|
|
|
guard let durationTime = player.currentItem?.duration else { return }
|
|
|
|
let modifier = CMTimeMakeWithSeconds(seconds, durationTime.timescale)
|
|
let targetTime = function(player.currentTime(), modifier)
|
|
|
|
guard targetTime.isValid && targetTime.isNumeric else { return }
|
|
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.seek(to: seconds)
|
|
} else {
|
|
player.seek(to: targetTime)
|
|
}
|
|
}
|
|
|
|
// MARK: - Key commands
|
|
|
|
private var keyDownEventMonitor: Any?
|
|
|
|
private enum KeyCommands: UInt16 {
|
|
case spaceBar = 49
|
|
}
|
|
|
|
private func startMonitoringKeyEvents() {
|
|
if keyDownEventMonitor != nil {
|
|
stopMonitoringKeyEvents()
|
|
}
|
|
|
|
keyDownEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [unowned self] event in
|
|
guard let command = KeyCommands(rawValue: event.keyCode) else {
|
|
return event
|
|
}
|
|
|
|
// ignore keystrokes when editing text
|
|
guard !(self.window?.firstResponder is NSTextView) else { return event }
|
|
guard !self.timelineView.isEditingAnnotation else { return event }
|
|
|
|
switch command {
|
|
case .spaceBar:
|
|
self.togglePlaying(nil)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopMonitoringKeyEvents() {
|
|
if let keyDownEventMonitor = keyDownEventMonitor {
|
|
NSEvent.removeMonitor(keyDownEventMonitor)
|
|
}
|
|
|
|
keyDownEventMonitor = nil
|
|
}
|
|
|
|
|
|
|
|
// MARK: - PiP Support
|
|
|
|
public func snapshotPlayer(completion: @escaping (NSImage?) -> Void) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
guard let welf = self, let player = welf.player else { return }
|
|
|
|
var image: NSImage?
|
|
|
|
defer { DispatchQueue.main.async { completion(image) } }
|
|
|
|
guard let asset = player.currentItem?.asset else { return }
|
|
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.appliesPreferredTrackTransform = true
|
|
|
|
do {
|
|
let rawImage = try generator.copyCGImage(at: player.currentTime(), actualTime: nil)
|
|
image = NSImage(cgImage: rawImage, size: NSSize(width: rawImage.width, height: rawImage.height))
|
|
} catch {
|
|
NSLog("Error getting player snapshot: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate var pipController: PIPViewController?
|
|
|
|
fileprivate func enterPictureInPictureMode() {
|
|
delegate?.playerViewWillEnterPictureInPictureMode(self)
|
|
|
|
snapshotPlayer { [weak self] image in
|
|
self?.externalStatusController.snapshot = image
|
|
}
|
|
|
|
pipController = PIPViewController()
|
|
pipController?.delegate = self
|
|
pipController?.setPlaying(self.isPlaying)
|
|
pipController?.aspectRatio = self.currentPresentationSize ?? NSSize(width: 640, height: 360)
|
|
pipController?.view.layer?.backgroundColor = NSColor.black.cgColor
|
|
|
|
pipController?.presentAsPicture(inPicture: self.pictureContainer)
|
|
|
|
self.isInPictureInPictureMode = true
|
|
}
|
|
|
|
fileprivate func exitPictureInPictureMode() {
|
|
if self.pictureContainer.presenting == pipController {
|
|
pipController?.dismissViewController(pictureContainer)
|
|
}
|
|
}
|
|
|
|
// MARK: - Visibility management
|
|
|
|
fileprivate var canHideControls: Bool {
|
|
guard let player = player else { return false }
|
|
|
|
guard isPlaying else { return false }
|
|
guard player.status == .readyToPlay else { return false }
|
|
guard let window = window else { return false }
|
|
|
|
guard !timelineView.isEditingAnnotation else { return false }
|
|
|
|
let windowMouseRect = window.convertFromScreen(NSRect(origin: NSEvent.mouseLocation(), size: CGSize(width: 1, height: 1)))
|
|
let viewMouseRect = convert(windowMouseRect, from: nil)
|
|
|
|
// don't hide the controls when the mouse is over them
|
|
return !viewMouseRect.intersects(controlsContainerView.frame)
|
|
}
|
|
|
|
fileprivate var mouseIdleTimer: Timer!
|
|
|
|
fileprivate func resetMouseIdleTimer(start: Bool = true) {
|
|
if mouseIdleTimer != nil {
|
|
mouseIdleTimer.invalidate()
|
|
mouseIdleTimer = nil
|
|
}
|
|
|
|
if start {
|
|
mouseIdleTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(mouseIdleTimerAction(_:)), userInfo: nil, repeats: false)
|
|
}
|
|
}
|
|
|
|
@objc fileprivate func mouseIdleTimerAction(_ sender: Timer) {
|
|
guard canHideControls else { return }
|
|
|
|
if !isInPictureInPictureMode {
|
|
NSCursor.hide()
|
|
}
|
|
|
|
hideControls(animated: true)
|
|
}
|
|
|
|
private func hideControls(animated: Bool) {
|
|
guard canHideControls else { return }
|
|
|
|
setControls(opacity: 0, animated: animated)
|
|
}
|
|
|
|
private func showControls(animated: Bool) {
|
|
NSCursor.unhide()
|
|
|
|
setControls(opacity: 1, animated: animated)
|
|
}
|
|
|
|
private func setControls(opacity: CGFloat, animated: Bool) {
|
|
NSAnimationContext.runAnimationGroup({ ctx in
|
|
ctx.duration = animated ? 0.4 : 0.0
|
|
self.scrimContainerView.animator().alphaValue = opacity
|
|
self.controlsContainerView.animator().alphaValue = opacity
|
|
self.extrasMenuContainerView.animator().alphaValue = opacity
|
|
}, completionHandler: nil)
|
|
}
|
|
|
|
public override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
NotificationCenter.default.removeObserver(self, name: .NSWindowWillEnterFullScreen, object: self.window)
|
|
NotificationCenter.default.removeObserver(self, name: .NSWindowWillExitFullScreen, object: self.window)
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(windowWillEnterFullScreen), name: .NSWindowWillEnterFullScreen, object: newWindow)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(windowWillExitFullScreen), name: .NSWindowWillExitFullScreen, object: newWindow)
|
|
}
|
|
|
|
public override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
|
|
resetMouseIdleTimer()
|
|
updateExtrasMenuPosition()
|
|
|
|
if window != nil {
|
|
self.lastKnownWindow = window
|
|
startMonitoringKeyEvents()
|
|
}
|
|
}
|
|
|
|
private var windowIsInFullScreen: Bool {
|
|
guard let window = window else { return false }
|
|
|
|
return window.styleMask.contains(.fullScreen)
|
|
}
|
|
|
|
@objc private func windowWillEnterFullScreen() {
|
|
fullScreenButton.isHidden = true
|
|
updateExtrasMenuPosition()
|
|
}
|
|
|
|
@objc private func windowWillExitFullScreen() {
|
|
fullScreenButton.isHidden = false
|
|
updateExtrasMenuPosition()
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
private var mouseTrackingArea: NSTrackingArea!
|
|
|
|
public override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
|
|
if mouseTrackingArea != nil {
|
|
removeTrackingArea(mouseTrackingArea)
|
|
}
|
|
|
|
let options: NSTrackingAreaOptions = [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect]
|
|
mouseTrackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
|
|
|
|
addTrackingArea(mouseTrackingArea)
|
|
}
|
|
|
|
public override func mouseEntered(with event: NSEvent) {
|
|
showControls(animated: true)
|
|
resetMouseIdleTimer()
|
|
|
|
super.mouseExited(with: event)
|
|
}
|
|
|
|
public override func mouseMoved(with event: NSEvent) {
|
|
showControls(animated: true)
|
|
resetMouseIdleTimer()
|
|
|
|
super.mouseMoved(with: event)
|
|
}
|
|
|
|
public override func mouseExited(with event: NSEvent) {
|
|
resetMouseIdleTimer(start: false)
|
|
|
|
hideControls(animated: true)
|
|
|
|
super.mouseExited(with: event)
|
|
}
|
|
|
|
public override var acceptsFirstResponder: Bool {
|
|
return true
|
|
}
|
|
|
|
public override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
return true
|
|
}
|
|
|
|
public override func mouseDown(with event: NSEvent) {
|
|
if event.type == .leftMouseDown && event.clickCount == 2 {
|
|
toggleFullscreen(self)
|
|
} else {
|
|
super.mouseDown(with: event)
|
|
}
|
|
}
|
|
|
|
// MARK: - External playback state management
|
|
|
|
fileprivate var currentExternalPlaybackProvider: PUIExternalPlaybackProvider? {
|
|
didSet {
|
|
if currentExternalPlaybackProvider != nil {
|
|
transitionToExternalPlayback()
|
|
} else {
|
|
transitionToInternalPlayback()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func transitionToExternalPlayback() {
|
|
// TODO: show some UI to indicate that the video is now being played externally
|
|
}
|
|
|
|
private func transitionToInternalPlayback() {
|
|
// TODO: reset the UI to internal playback mode
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - PUITimelineViewDelegate
|
|
|
|
extension PUIPlayerView: PUITimelineViewDelegate {
|
|
|
|
func timelineDidReceiveForceTouch(at timestamp: Double) {
|
|
guard let player = player else { return }
|
|
|
|
let timestamp = Double(CMTimeGetSeconds(player.currentTime()))
|
|
|
|
delegate?.playerViewDidSelectAddAnnotation(self, at: timestamp)
|
|
}
|
|
|
|
func timelineViewWillBeginInteractiveSeek() {
|
|
wasPlayingBeforeStartingInteractiveSeek = isPlaying
|
|
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.pause()
|
|
} else {
|
|
player?.pause()
|
|
}
|
|
}
|
|
|
|
func timelineViewDidSeek(to progress: Double) {
|
|
guard let duration = asset?.duration else { return }
|
|
|
|
let targetTime = progress * Double(CMTimeGetSeconds(duration))
|
|
let time = CMTimeMakeWithSeconds(targetTime, duration.timescale)
|
|
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.seek(to: targetTime)
|
|
} else {
|
|
player?.seek(to: time)
|
|
}
|
|
}
|
|
|
|
func timelineViewDidFinishInteractiveSeek() {
|
|
if wasPlayingBeforeStartingInteractiveSeek {
|
|
if isPlayingExternally {
|
|
currentExternalPlaybackProvider?.play()
|
|
} else {
|
|
player?.play()
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - External playback support
|
|
|
|
extension PUIPlayerView: PUIExternalPlaybackConsumer {
|
|
|
|
private func isCurrentProvider(_ provider: PUIExternalPlaybackProvider) -> Bool {
|
|
guard let currentProvider = currentExternalPlaybackProvider else { return false }
|
|
|
|
return type(of: provider).name == type(of: currentProvider).name
|
|
}
|
|
|
|
public func externalPlaybackProviderDidChangeMediaStatus(_ provider: PUIExternalPlaybackProvider) {
|
|
volumeSlider.doubleValue = Double(provider.status.volume)
|
|
|
|
if let speed = PUIPlaybackSpeed(rawValue: provider.status.rate) {
|
|
self.playbackSpeed = speed
|
|
}
|
|
|
|
let time = CMTimeMakeWithSeconds(Float64(provider.status.currentTime), 9000)
|
|
playerTimeDidChange(time: time)
|
|
|
|
updatePlayingState()
|
|
}
|
|
|
|
public func externalPlaybackProviderDidChangeAvailabilityStatus(_ provider: PUIExternalPlaybackProvider) {
|
|
updateExternalPlaybackMenus()
|
|
updateExternalPlaybackControlsAvailability()
|
|
|
|
if !provider.isAvailable && isCurrentProvider(provider) {
|
|
// current provider got invalidated, go back to internal playback
|
|
self.currentExternalPlaybackProvider = nil
|
|
}
|
|
}
|
|
|
|
public func externalPlaybackProviderDidInvalidatePlaybackSession(_ provider: PUIExternalPlaybackProvider) {
|
|
if isCurrentProvider(provider) {
|
|
let wasPlaying = !provider.status.rate.isZero
|
|
|
|
// provider session invalidated, go back to internal playback
|
|
self.currentExternalPlaybackProvider = nil
|
|
|
|
if wasPlaying {
|
|
self.player?.play()
|
|
self.updatePlayingState()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func externalPlaybackProvider(_ provider: PUIExternalPlaybackProvider, deviceSelectionMenuDidChangeWith menu: NSMenu) {
|
|
guard let registrationIndex = externalPlaybackProviders.index(where: { type(of: $0.provider).name == type(of: provider).name }) else { return }
|
|
|
|
externalPlaybackProviders[registrationIndex].menu = menu
|
|
}
|
|
|
|
public func externalPlaybackProviderDidBecomeCurrent(_ provider: PUIExternalPlaybackProvider) {
|
|
if isInternalPlayerPlaying {
|
|
player?.rate = 0
|
|
}
|
|
|
|
self.currentExternalPlaybackProvider = provider
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - PiP delegate
|
|
|
|
extension PUIPlayerView: PIPViewControllerDelegate, PUIPictureContainerViewControllerDelegate {
|
|
|
|
public func pipActionStop(_ pip: PIPViewController) {
|
|
self.pause(pip)
|
|
}
|
|
|
|
public func pipActionPause(_ pip: PIPViewController) {
|
|
self.pause(pip)
|
|
}
|
|
|
|
public func pipActionPlay(_ pip: PIPViewController) {
|
|
self.play(pip)
|
|
}
|
|
|
|
public func pipDidClose(_ pip: PIPViewController) {
|
|
pictureContainer.view.frame = self.bounds
|
|
|
|
self.addSubview(pictureContainer.view, positioned: .below, relativeTo: self.scrimContainerView)
|
|
|
|
self.isInPictureInPictureMode = false
|
|
self.pipController = nil
|
|
}
|
|
|
|
public func pipWillClose(_ pip: PIPViewController) {
|
|
delegate?.playerViewWillExitPictureInPictureMode(self)
|
|
|
|
if !NSApp.isActive {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
if let window = self.lastKnownWindow {
|
|
window.makeKeyAndOrderFront(pip)
|
|
|
|
if window.isMiniaturized {
|
|
window.deminiaturize(nil)
|
|
}
|
|
}
|
|
|
|
pip.replacementRect = self.frame
|
|
pip.replacementView = self
|
|
pip.replacementWindow = self.lastKnownWindow
|
|
}
|
|
|
|
func pictureContainerViewSuperviewDidChange(to superview: NSView?) {
|
|
guard let superview = superview else { return }
|
|
|
|
pictureContainer.view.frame = superview.bounds
|
|
|
|
if superview == self, pipController != nil {
|
|
if self.pictureContainer.presenting == pipController {
|
|
pipController?.dismissViewController(self.pictureContainer)
|
|
}
|
|
|
|
pipController = nil
|
|
}
|
|
}
|
|
|
|
}
|