mirror of
https://github.com/insidegui/WWDC.git
synced 2026-04-07 19:17:41 +00:00
1699 lines
57 KiB
Swift
1699 lines
57 KiB
Swift
//
|
||
// PUIPlayerView.swift
|
||
// PlayerProto
|
||
//
|
||
// Created by Guilherme Rambo on 29/04/17.
|
||
// Copyright © 2017 Guilherme Rambo. All rights reserved.
|
||
//
|
||
|
||
import Cocoa
|
||
import AVFoundation
|
||
import OSLog
|
||
import AVKit
|
||
import Combine
|
||
import SwiftUI
|
||
import ConfUIFoundation
|
||
|
||
public final class PUIPlayerView: NSView {
|
||
|
||
private let settings = PUISettings()
|
||
|
||
private let log = Logger(subsystem: "PlayerUI", category: "PUIPlayerView")
|
||
private var cancellables: Set<AnyCancellable> = []
|
||
/// Bindings that only need to be established once and are not dependent on the player (model).
|
||
/// The nature of Combine forces us to store them somewhere, though. And as long as they only capture `self`
|
||
/// weakly, the ui bindings don't need to be reset when the player changes.
|
||
private var uiBindings: Set<AnyCancellable> = []
|
||
|
||
// MARK: - Public API
|
||
|
||
public weak var delegate: PUIPlayerViewDelegate?
|
||
|
||
public var isInPictureInPictureMode: Bool { pipController?.isPictureInPictureActive == 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 {
|
||
sortedAnnotations = newValue.filter({ $0.isValid }).sorted(by: { $0.timestamp < $1.timestamp })
|
||
|
||
timelineView.annotations = sortedAnnotations
|
||
|
||
validateAnnotationButton()
|
||
}
|
||
}
|
||
|
||
public weak var player: AVPlayer? {
|
||
didSet {
|
||
guard oldValue != player else { return }
|
||
|
||
if let oldPlayer = oldValue {
|
||
teardown(player: oldPlayer)
|
||
}
|
||
|
||
guard let player else { return }
|
||
|
||
setupPlayer(player)
|
||
}
|
||
}
|
||
|
||
public var isInFullScreenPlayerWindow: Bool {
|
||
return window is PUIPlayerWindow
|
||
}
|
||
|
||
public var remoteMediaUrl: URL?
|
||
public var mediaPosterUrl: URL?
|
||
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() {
|
||
self.pipController = AVPictureInPictureController(contentSource: .init(playerLayer: playerLayer))
|
||
} else {
|
||
self.pipController = nil
|
||
}
|
||
|
||
super.init(frame: .zero)
|
||
|
||
wantsLayer = true
|
||
layer = PUIBoringLayer()
|
||
backgroundColor = .black
|
||
|
||
setupPlayer(player)
|
||
setupControls()
|
||
}
|
||
|
||
public required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
public var nowPlayingInfo: PUINowPlayingInfo? {
|
||
didSet {
|
||
nowPlayingCoordinator?.basicNowPlayingInfo = nowPlayingInfo
|
||
}
|
||
}
|
||
|
||
public var isPlaying: Bool {
|
||
guard let player = player else { return false }
|
||
|
||
return !player.rate.isZero
|
||
}
|
||
|
||
public var currentTimestamp: Double {
|
||
guard let player = player else { return 0 }
|
||
|
||
return Double(CMTimeGetSeconds(player.currentTime()))
|
||
}
|
||
|
||
public var firstAnnotationBeforeCurrentTime: PUITimelineAnnotation? {
|
||
return annotations.reversed().first { $0.isValid && $0.timestamp + 1 < currentTimestamp }
|
||
}
|
||
|
||
public var firstAnnotationAfterCurrentTime: PUITimelineAnnotation? {
|
||
return annotations.first { $0.isValid && $0.timestamp > currentTimestamp + 1 }
|
||
}
|
||
|
||
public func seek(to annotation: PUITimelineAnnotation) {
|
||
seek(to: annotation.timestamp)
|
||
}
|
||
|
||
public var playbackSpeed: PUIPlaybackSpeed = .normal {
|
||
didSet {
|
||
guard let player = player else { return }
|
||
|
||
if playbackSpeed != oldValue, isPlaying {
|
||
player.rate = playbackSpeed.rawValue
|
||
player.seek(to: player.currentTime()) // Helps the AV sync when speeds change with the TimeDomain algorithm enabled
|
||
}
|
||
|
||
settings.playbackRate = Double(playbackSpeed.rawValue)
|
||
|
||
updatePlaybackSpeedState()
|
||
|
||
invalidateTouchBar()
|
||
}
|
||
}
|
||
|
||
public var hideAllControls: Bool = false {
|
||
didSet {
|
||
controlsContainerView.isHidden = hideAllControls
|
||
topTrailingMenuContainerView.isHidden = hideAllControls
|
||
}
|
||
}
|
||
|
||
public func invalidateAppearance() {
|
||
configureWithAppearanceFromDelegate()
|
||
}
|
||
|
||
// MARK: - Private API
|
||
|
||
fileprivate weak var lastKnownWindow: NSWindow?
|
||
|
||
private var sortedAnnotations: [PUITimelineAnnotation] = []
|
||
|
||
private var playerTimeObserver: Any?
|
||
private var annotationTimeDistanceObserver: Any?
|
||
|
||
fileprivate var asset: AVAsset? {
|
||
return player?.currentItem?.asset
|
||
}
|
||
|
||
private let playerLayer = PUIBoringPlayerLayer()
|
||
|
||
private func setupPlayer(_ player: AVPlayer) {
|
||
/// User settings are applied before setting up player observations, avoiding accidental overrides when initial values come in.
|
||
applyUserSettings(to: player)
|
||
|
||
if let pipController {
|
||
pipPossibleObservation = pipController.observe(
|
||
\AVPictureInPictureController.isPictureInPicturePossible, options: [.initial, .new]
|
||
) { [weak self] _, change in
|
||
self?.pipButton.isEnabled = change.newValue ?? false
|
||
}
|
||
pipController.delegate = self
|
||
} else {
|
||
pipButton.isEnabled = false
|
||
}
|
||
|
||
leadingTimeButton.title = elapsedTimePlaceholder
|
||
trailingTimeButton.title = timeRemainingPlaceholder
|
||
timelineView.resetUI()
|
||
|
||
playerLayer.player = player
|
||
playerLayer.videoGravity = .resizeAspect
|
||
|
||
let options: NSKeyValueObservingOptions = [.initial, .new]
|
||
player.publisher(for: \.status, options: options).sink { [weak self] change in
|
||
self?.playerStatusChanged()
|
||
}.store(in: &cancellables)
|
||
player.publisher(for: \.volume, options: options).sink { [weak self] change in
|
||
self?.playerVolumeChanged()
|
||
}.store(in: &cancellables)
|
||
player.publisher(for: \.rate, options: options).sink { [weak self] change in
|
||
self?.updatePlayingState()
|
||
self?.updatePowerAssertion()
|
||
}.store(in: &cancellables)
|
||
player.publisher(for: \.currentItem, options: options).sink { [weak self] change in
|
||
if let playerItem = self?.player?.currentItem {
|
||
playerItem.audioTimePitchAlgorithm = .timeDomain
|
||
}
|
||
}.store(in: &cancellables)
|
||
player.publisher(for: \.currentItem?.loadedTimeRanges, options: [.initial, .new]).sink { [weak self] change in
|
||
self?.updateBufferedSegments()
|
||
}.store(in: &cancellables)
|
||
|
||
player.publisher(for: \.currentItem?.tracks, options: [.initial, .new]).sink { [weak self] _ in
|
||
self?.needsLayout = true
|
||
}.store(in: &cancellables)
|
||
|
||
Task { [weak self] in
|
||
guard let asset = self?.asset else { return }
|
||
async let duration = asset.load(.duration)
|
||
async let legible = asset.loadMediaSelectionGroup(for: .legible)
|
||
self?.timelineView.mediaDuration = Double(CMTimeGetSeconds(try await duration))
|
||
self?.updateSubtitleSelectionMenu(subtitlesGroup: try await legible)
|
||
}
|
||
|
||
playerTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(0.5, preferredTimescale: 9000), queue: .main) { [weak self] currentTime in
|
||
self?.playerTimeDidChange(time: currentTime)
|
||
}
|
||
|
||
annotationTimeDistanceObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1, preferredTimescale: 9000), queue: .main) { [weak self] _ in
|
||
self?.validateAnnotationButton()
|
||
}
|
||
|
||
player.allowsExternalPlayback = true
|
||
routeButton.player = player
|
||
|
||
setupNowPlayingCoordinatorIfSupported()
|
||
setupRemoteCommandCoordinator()
|
||
}
|
||
|
||
private func teardown(player oldValue: AVPlayer) {
|
||
oldValue.pause()
|
||
oldValue.cancelPendingPrerolls()
|
||
|
||
cancellables.removeAll()
|
||
if let observer = playerTimeObserver {
|
||
oldValue.removeTimeObserver(observer)
|
||
playerTimeObserver = nil
|
||
}
|
||
if let observer = annotationTimeDistanceObserver {
|
||
oldValue.removeTimeObserver(observer)
|
||
annotationTimeDistanceObserver = nil
|
||
}
|
||
}
|
||
|
||
private func applyUserSettings(to player: AVPlayer) {
|
||
trailingLabelDisplaysDuration = settings.trailingLabelDisplaysDuration
|
||
|
||
player.volume = Float(settings.playerVolume)
|
||
|
||
let speed = Float(settings.playbackRate)
|
||
if PUIPlaybackSpeed.validateCustomSpeed(speed) {
|
||
playbackSpeed = PUIPlaybackSpeed(rawValue: speed) ?? .normal
|
||
} else {
|
||
log.error("Playback rate in user preference is invalid, using default (rate: \(speed, privacy: .public))")
|
||
}
|
||
}
|
||
|
||
private func playerVolumeChanged() {
|
||
guard let player = player else { return }
|
||
|
||
settings.playerVolume = Double(player.volume)
|
||
|
||
if player.volume.isZero {
|
||
volumeButton.image = .PUIVolumeMuted
|
||
volumeButton.toolTip = "Unmute"
|
||
volumeSlider.doubleValue = 0
|
||
} else {
|
||
switch player.volume {
|
||
case 0..<0.33:
|
||
volumeButton.image = .PUIVolume1
|
||
case 0.33..<0.66:
|
||
volumeButton.image = .PUIVolume2
|
||
default:
|
||
volumeButton.image = .PUIVolume3
|
||
}
|
||
|
||
volumeButton.toolTip = "Mute"
|
||
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)
|
||
}
|
||
|
||
fileprivate func updatePlayingState() {
|
||
if isPlaying {
|
||
playButton.state = .on
|
||
} else {
|
||
playButton.state = .off
|
||
}
|
||
}
|
||
|
||
fileprivate var activity: NSObjectProtocol?
|
||
|
||
fileprivate func updatePowerAssertion() {
|
||
if player?.rate == 0 {
|
||
if let activity = activity {
|
||
ProcessInfo.processInfo.endActivity(activity)
|
||
self.activity = nil
|
||
}
|
||
} else {
|
||
if activity == nil {
|
||
activity = ProcessInfo.processInfo.beginActivity(options: [.idleDisplaySleepDisabled, .userInitiated], reason: "Playing WWDC session video")
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
fileprivate func updatePlaybackSpeedState() {
|
||
guard playbackSpeed != speedButton.speed else { return }
|
||
speedButton.speed = playbackSpeed
|
||
}
|
||
|
||
fileprivate var currentPresentationSize: NSSize? {
|
||
guard let size = player?.currentItem?.presentationSize, size != NSSize.zero else { return nil }
|
||
|
||
return size
|
||
}
|
||
|
||
private func playerStatusChanged() {
|
||
guard let player = player else { return }
|
||
|
||
switch player.status {
|
||
case .readyToPlay:
|
||
updateTimeLabels()
|
||
default: break
|
||
}
|
||
}
|
||
|
||
fileprivate func playerTimeDidChange(time: CMTime) {
|
||
guard let player = player else { return }
|
||
guard player.hasValidMediaDuration else { return }
|
||
guard let duration = asset?.durationIfLoaded else { return }
|
||
|
||
DispatchQueue.main.async {
|
||
let progress = Double(CMTimeGetSeconds(time) / CMTimeGetSeconds(duration))
|
||
self.timelineView.playbackProgress = progress
|
||
|
||
self.updateTimeLabels()
|
||
}
|
||
}
|
||
|
||
private var trailingLabelDisplaysDuration = false {
|
||
didSet {
|
||
guard trailingLabelDisplaysDuration != oldValue else { return }
|
||
|
||
settings.trailingLabelDisplaysDuration = trailingLabelDisplaysDuration
|
||
updateTimeLabels()
|
||
}
|
||
}
|
||
|
||
private func updateTimeLabels() {
|
||
guard let player = player else { return }
|
||
|
||
guard player.hasValidMediaDuration else { return }
|
||
guard let duration = asset?.durationIfLoaded else { return }
|
||
|
||
let time = player.currentTime()
|
||
|
||
leadingTimeButton.title = String(time: time) ?? ""
|
||
|
||
if trailingLabelDisplaysDuration {
|
||
trailingTimeButton.title = String(time: duration) ?? durationPlaceholder
|
||
} else {
|
||
let remainingTime = CMTimeSubtract(duration, time)
|
||
trailingTimeButton.title = "−" + (String(time: remainingTime) ?? timeRemainingPlaceholder)
|
||
}
|
||
}
|
||
|
||
public override func layout() {
|
||
updateVideoLayoutGuide()
|
||
|
||
super.layout()
|
||
}
|
||
|
||
private lazy var videoLayoutGuideConstraints = [NSLayoutConstraint]()
|
||
|
||
private var currentBounds: CGRect?
|
||
|
||
private func updateVideoLayoutGuide() {
|
||
guard let player else { return }
|
||
|
||
guard bounds != currentBounds else { return }
|
||
|
||
player.updateLayout(guide: videoLayoutGuide, container: self, constraints: &videoLayoutGuideConstraints)
|
||
|
||
currentBounds = bounds
|
||
}
|
||
|
||
deinit {
|
||
if let player = player {
|
||
teardown(player: player)
|
||
}
|
||
}
|
||
|
||
// MARK: - Now Playing Coordination
|
||
|
||
private var nowPlayingCoordinator: PUINowPlayingInfoCoordinator?
|
||
|
||
private func setupNowPlayingCoordinatorIfSupported() {
|
||
guard let player = player else { return }
|
||
|
||
nowPlayingCoordinator = PUINowPlayingInfoCoordinator(player: player)
|
||
nowPlayingCoordinator?.basicNowPlayingInfo = nowPlayingInfo
|
||
}
|
||
|
||
// MARK: - Remote command support (AirPlay 2)
|
||
|
||
private var remoteCommandCoordinator: PUIRemoteCommandCoordinator?
|
||
|
||
private func setupRemoteCommandCoordinator() {
|
||
remoteCommandCoordinator = PUIRemoteCommandCoordinator()
|
||
|
||
remoteCommandCoordinator?.pauseHandler = { [weak self] in
|
||
self?.pause(nil)
|
||
}
|
||
remoteCommandCoordinator?.playHandler = { [weak self] in
|
||
self?.play(nil)
|
||
}
|
||
remoteCommandCoordinator?.stopHandler = { [weak self] in
|
||
self?.pause(nil)
|
||
}
|
||
remoteCommandCoordinator?.togglePlayingHandler = { [weak self] in
|
||
self?.togglePlaying(nil)
|
||
}
|
||
remoteCommandCoordinator?.nextTrackHandler = { [weak self] in
|
||
self?.goForwardInTime(nil)
|
||
}
|
||
remoteCommandCoordinator?.previousTrackHandler = { [weak self] in
|
||
self?.goBackInTime(nil)
|
||
}
|
||
remoteCommandCoordinator?.likeHandler = { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
self.delegate?.playerViewDidSelectLike(self)
|
||
}
|
||
remoteCommandCoordinator?.changePlaybackPositionHandler = { [weak self] time in
|
||
self?.seek(to: time)
|
||
}
|
||
remoteCommandCoordinator?.changePlaybackRateHandler = { [weak self] speed in
|
||
self?.playbackSpeed = speed
|
||
}
|
||
}
|
||
|
||
// MARK: Controls
|
||
|
||
fileprivate var wasPlayingBeforeStartingInteractiveSeek = false
|
||
|
||
private var topTrailingMenuContainerView: NSStackView!
|
||
fileprivate var scrimContainerView: PUIScrimView!
|
||
private var controlsContainerView: NSStackView!
|
||
private var volumeControlsContainerView: NSStackView!
|
||
private var centerButtonsContainerView: NSStackView!
|
||
private var timelineContainerView: NSStackView!
|
||
|
||
fileprivate lazy var timelineView: PUITimelineView = {
|
||
let v = PUITimelineView(frame: .zero)
|
||
|
||
v.viewDelegate = self
|
||
|
||
return v
|
||
}()
|
||
|
||
private var elapsedTimePlaceholder = "00:00"
|
||
private var timeRemainingPlaceholder = "−00:00"
|
||
private var durationPlaceholder = "00:00"
|
||
|
||
/// Displays the elapsed time.
|
||
/// This is a button for consistency with `trailingTimeButton`, but it doesn't have an action.
|
||
private lazy var leadingTimeButton: NSButton = {
|
||
let b = PUIFirstMouseButton(title: elapsedTimePlaceholder, target: nil, action: nil)
|
||
|
||
b.contentTintColor = .timeLabel
|
||
b.isBordered = false
|
||
b.alignment = .left
|
||
b.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||
b.isEnabled = false
|
||
/// Prevents the disabled button from dimming its contents.
|
||
(b.cell as? NSButtonCell)?.imageDimsWhenDisabled = false
|
||
|
||
return b
|
||
}()
|
||
|
||
/// Displays either elapsed time or duration according to user preference (toggle by clicking).
|
||
private lazy var trailingTimeButton: NSButton = {
|
||
let b = PUIFirstMouseButton(title: timeRemainingPlaceholder, target: self, action: #selector(toggleTrailingTimeLabelMode))
|
||
|
||
b.contentTintColor = .timeLabel
|
||
b.isBordered = false
|
||
b.alignment = .right
|
||
b.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var fullScreenButton: PUIVibrantButton = {
|
||
let b = PUIVibrantButton(frame: .zero)
|
||
|
||
b.button.image = .PUIFullScreen
|
||
b.button.target = self
|
||
b.button.action = #selector(toggleFullscreen)
|
||
b.button.toolTip = "Toggle full screen"
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var volumeButton: NSButton = {
|
||
let b = PUIFirstMouseButton(frame: .zero)
|
||
|
||
b.image = .PUIVolume3
|
||
b.font = NSFont.wwdcRoundedSystemFont(ofSize: 16, weight: .medium)
|
||
b.target = self
|
||
b.action = #selector(toggleMute)
|
||
b.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||
b.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||
b.title = ""
|
||
b.isBordered = false
|
||
(b.cell as? NSButtonCell)?.imageScaling = .scaleNone
|
||
|
||
return b
|
||
}()
|
||
|
||
fileprivate lazy var volumeSlider: NSSlider = {
|
||
let s = NSSlider(frame: .zero)
|
||
|
||
s.widthAnchor.constraint(equalToConstant: 88).isActive = true
|
||
s.isContinuous = true
|
||
s.target = self
|
||
s.minValue = 0
|
||
s.maxValue = 1
|
||
s.controlSize = .small
|
||
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.isToggle = true
|
||
b.image = .PUIPlay
|
||
b.alternateImage = .PUIPause
|
||
b.tintColor = .labelColor
|
||
b.activeTintColor = .labelColor
|
||
b.target = self
|
||
b.action = #selector(togglePlaying)
|
||
b.toolTip = "Play/pause"
|
||
b.metrics = .large
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var backButton: PUIButton = {
|
||
let b = PUIButton(frame: .zero)
|
||
|
||
b.image = .PUIBack15s
|
||
b.target = self
|
||
b.action = #selector(goBackInTime)
|
||
b.toolTip = "Go back 15s"
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var forwardButton: PUIButton = {
|
||
let b = PUIButton(frame: .zero)
|
||
|
||
b.image = .PUIForward15s
|
||
b.target = self
|
||
b.action = #selector(goForwardInTime)
|
||
b.toolTip = "Go forward 15s"
|
||
|
||
return b
|
||
}()
|
||
|
||
fileprivate lazy var speedButton = PUIPlaybackSpeedToggle(frame: .zero)
|
||
|
||
private lazy var addAnnotationButton: PUIButton = {
|
||
let b = PUIButton(frame: .zero)
|
||
|
||
b.image = .PUIAnnotation
|
||
b.target = self
|
||
b.action = #selector(addAnnotation)
|
||
b.toolTip = "Add bookmark"
|
||
b.metrics = .medium
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var pipButton: PUIButton = {
|
||
let b = PUIButton(frame: .zero)
|
||
|
||
b.isToggle = true
|
||
b.image = AVPictureInPictureController.pictureInPictureButtonStartImage
|
||
b.alternateImage = AVPictureInPictureController.pictureInPictureButtonStopImage
|
||
b.target = self
|
||
b.action = #selector(togglePip)
|
||
b.toolTip = "Toggle picture in picture"
|
||
b.isEnabled = false
|
||
b.metrics = .medium
|
||
|
||
return b
|
||
}()
|
||
|
||
private lazy var routeButton: PUIButton = {
|
||
let b = PUIButton(frame: .zero)
|
||
|
||
b.isToggle = true
|
||
b.image = .PUIAirPlay
|
||
b.toolTip = "AirPlay"
|
||
b.metrics = .medium
|
||
b.isAVRoutePickerMasquerade = true
|
||
|
||
return b
|
||
}()
|
||
|
||
public private(set) lazy var videoLayoutGuide = NSLayoutGuide()
|
||
|
||
private var topTrailingMenuTopConstraint: NSLayoutConstraint!
|
||
|
||
private func setupControls() {
|
||
addLayoutGuide(videoLayoutGuide)
|
||
|
||
let playerView = NSView()
|
||
playerView.translatesAutoresizingMaskIntoConstraints = false
|
||
playerView.wantsLayer = true
|
||
playerView.layer = playerLayer
|
||
playerLayer.backgroundColor = .clear
|
||
addSubview(playerView)
|
||
playerView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
||
playerView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
|
||
playerView.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
||
playerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||
|
||
// 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, forward, backward)
|
||
centerButtonsContainerView.addView(backButton, in: .center)
|
||
centerButtonsContainerView.addView(playButton, in: .center)
|
||
centerButtonsContainerView.addView(forwardButton, in: .center)
|
||
|
||
// Trailing controls (speed, add annotation, AirPlay, PiP)
|
||
centerButtonsContainerView.addView(speedButton, in: .trailing)
|
||
centerButtonsContainerView.addView(addAnnotationButton, in: .trailing)
|
||
centerButtonsContainerView.addView(routeButton, in: .trailing)
|
||
centerButtonsContainerView.addView(pipButton, in: .trailing)
|
||
|
||
centerButtonsContainerView.orientation = .horizontal
|
||
centerButtonsContainerView.spacing = 16
|
||
centerButtonsContainerView.distribution = .gravityAreas
|
||
centerButtonsContainerView.alignment = .centerY
|
||
|
||
// Visibility priorities
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: volumeButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: volumeSlider)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: subtitlesButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: backButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.mustHold, for: playButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: forwardButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: speedButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: addAnnotationButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: routeButton)
|
||
centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: pipButton)
|
||
centerButtonsContainerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
|
||
timelineContainerView = NSStackView(views: [
|
||
leadingTimeButton,
|
||
timelineView,
|
||
trailingTimeButton
|
||
])
|
||
timelineContainerView.distribution = .equalSpacing
|
||
timelineContainerView.orientation = .horizontal
|
||
timelineContainerView.alignment = .centerY
|
||
|
||
// Main stack view and background scrim
|
||
controlsContainerView = NSStackView(views: [
|
||
timelineContainerView,
|
||
centerButtonsContainerView
|
||
])
|
||
|
||
controlsContainerView.orientation = .vertical
|
||
controlsContainerView.spacing = 12
|
||
controlsContainerView.distribution = .fill
|
||
controlsContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||
controlsContainerView.wantsLayer = true
|
||
controlsContainerView.layer?.masksToBounds = false
|
||
controlsContainerView.layer?.zPosition = 10
|
||
|
||
scrimContainerView = PUIScrimView(frame: controlsContainerView.bounds)
|
||
scrimContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
addSubview(scrimContainerView)
|
||
addSubview(controlsContainerView)
|
||
|
||
/// Ensure a minimum amount of padding between the control area leading and trailing edges and the container.
|
||
let scrimLeading = scrimContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16)
|
||
scrimLeading.priority = .defaultLow
|
||
let scrimTrailing = scrimContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
|
||
scrimTrailing.priority = .defaultLow
|
||
|
||
/// Define an absolute maximum width for the control area so that it doesn't look comically wide in full screen,
|
||
/// set as lower priority so that it can potentially expand beyond this size if needed due to content size.
|
||
let scrimMaxWidth = scrimContainerView.widthAnchor.constraint(lessThanOrEqualToConstant: 600)
|
||
scrimMaxWidth.priority = .defaultLow
|
||
|
||
NSLayoutConstraint.activate([
|
||
scrimMaxWidth,
|
||
scrimLeading,
|
||
scrimTrailing,
|
||
scrimContainerView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
scrimContainerView.bottomAnchor.constraint(equalTo: videoLayoutGuide.bottomAnchor, constant: -16),
|
||
controlsContainerView.leadingAnchor.constraint(equalTo: scrimContainerView.leadingAnchor, constant: 16),
|
||
controlsContainerView.trailingAnchor.constraint(equalTo: scrimContainerView.trailingAnchor, constant: -16),
|
||
controlsContainerView.topAnchor.constraint(equalTo: scrimContainerView.topAnchor, constant: 16),
|
||
controlsContainerView.bottomAnchor.constraint(equalTo: scrimContainerView.bottomAnchor, constant: -16)
|
||
])
|
||
|
||
centerButtonsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true
|
||
centerButtonsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true
|
||
|
||
topTrailingMenuContainerView = NSStackView(views: [fullScreenButton])
|
||
topTrailingMenuContainerView.orientation = .horizontal
|
||
topTrailingMenuContainerView.alignment = .centerY
|
||
topTrailingMenuContainerView.distribution = .equalSpacing
|
||
topTrailingMenuContainerView.spacing = 30
|
||
|
||
addSubview(topTrailingMenuContainerView)
|
||
|
||
topTrailingMenuContainerView.trailingAnchor.constraint(equalTo: videoLayoutGuide.trailingAnchor, constant: -12).isActive = true
|
||
updateTopTrailingMenuPosition()
|
||
|
||
speedButton.$speed.removeDuplicates().sink { [weak self] speed in
|
||
guard let self else { return }
|
||
self.playbackSpeed = speed
|
||
}
|
||
.store(in: &uiBindings)
|
||
|
||
speedButton.$isEditingCustomSpeed.sink { [weak self] isEditing in
|
||
guard let self else { return }
|
||
|
||
showControls(animated: false)
|
||
resetMouseIdleTimer()
|
||
}
|
||
.store(in: &uiBindings)
|
||
}
|
||
|
||
var backAndForwardSkipDuration: BackForwardSkipDuration = .thirtySeconds {
|
||
didSet {
|
||
invalidateTouchBar()
|
||
}
|
||
}
|
||
|
||
var goBackInTimeImage: NSImage {
|
||
switch backAndForwardSkipDuration {
|
||
case .fiveSeconds:
|
||
.PUIBack5s
|
||
case .tenSeconds:
|
||
.PUIBack10s
|
||
case .fifteenSeconds:
|
||
.PUIBack15s
|
||
case .thirtySeconds:
|
||
.PUIBack30s
|
||
}
|
||
}
|
||
|
||
var goBackInTimeDescription: String {
|
||
"Go back \(backAndForwardSkipDuration.rawValue.formatted())s"
|
||
}
|
||
|
||
var goForwardInTimeImage: NSImage {
|
||
switch backAndForwardSkipDuration {
|
||
case .fiveSeconds:
|
||
.PUIForward5s
|
||
case .tenSeconds:
|
||
.PUIForward10s
|
||
case .fifteenSeconds:
|
||
.PUIForward15s
|
||
case .thirtySeconds:
|
||
.PUIForward30s
|
||
}
|
||
}
|
||
|
||
var goForwardInTimeDescription: String {
|
||
"Go forward \(backAndForwardSkipDuration.rawValue.formatted())s"
|
||
}
|
||
|
||
private func configureWithAppearanceFromDelegate() {
|
||
guard let d = appearanceDelegate else { return }
|
||
|
||
/// We need to update based on the availability of subtitles, updating only from the delegate can result in
|
||
/// the subtitles button becoming visible when there are no subtitles available.
|
||
updateSubtitleSelectionMenu()
|
||
|
||
pipButton.isHidden = !d.playerViewShouldShowPictureInPictureControl(self)
|
||
speedButton.isHidden = !d.playerViewShouldShowSpeedControl(self)
|
||
|
||
updateAnnotationButtonVisibility()
|
||
|
||
let disableBackAndForward = !d.playerViewShouldShowBackAndForwardControls(self)
|
||
backButton.isHidden = disableBackAndForward
|
||
forwardButton.isHidden = disableBackAndForward
|
||
|
||
backAndForwardSkipDuration = d.playerViewBackAndForwardDuration(self)
|
||
backButton.image = goBackInTimeImage
|
||
backButton.action = #selector(goBackInTime)
|
||
backButton.toolTip = goBackInTimeDescription
|
||
forwardButton.image = goForwardInTimeImage
|
||
forwardButton.action = #selector(goForwardInTime)
|
||
forwardButton.toolTip = goForwardInTimeDescription
|
||
|
||
fullScreenButton.isHidden = !d.playerViewShouldShowFullScreenButton(self)
|
||
timelineView.isHidden = !d.playerViewShouldShowTimelineView(self)
|
||
}
|
||
|
||
private func updateAnnotationButtonVisibility() {
|
||
defer { validateAnnotationButton() }
|
||
|
||
guard let d = appearanceDelegate else { return }
|
||
|
||
addAnnotationButton.isHidden = !d.playerViewShouldShowAnnotationControls(self)
|
||
}
|
||
|
||
private func validateAnnotationButton() {
|
||
let timestamp = self.currentTimestamp
|
||
let tooCloseForComfort = timelineView.annotations.contains(where: { $0.isValid && abs($0.timestamp - timestamp) < 30 })
|
||
self.addAnnotationButton.isEnabled = !tooCloseForComfort
|
||
}
|
||
|
||
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 updateTopTrailingMenuPosition() {
|
||
let topConstant: CGFloat = isDominantViewInWindow ? 34 : 12
|
||
|
||
if topTrailingMenuTopConstraint == nil {
|
||
topTrailingMenuTopConstraint = topTrailingMenuContainerView.topAnchor.constraint(equalTo: videoLayoutGuide.topAnchor, constant: topConstant)
|
||
topTrailingMenuTopConstraint.isActive = true
|
||
} else {
|
||
topTrailingMenuTopConstraint.constant = topConstant
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
|
||
player.volume = Float(volumeSlider.doubleValue)
|
||
}
|
||
|
||
@IBAction public func togglePlaying(_ sender: Any?) {
|
||
if isPlaying {
|
||
pause(sender)
|
||
} else {
|
||
play(sender)
|
||
}
|
||
|
||
invalidateTouchBar()
|
||
}
|
||
|
||
@IBAction public func pause(_ sender: Any?) {
|
||
player?.rate = 0
|
||
}
|
||
|
||
@IBAction public func play(_ sender: Any?) {
|
||
guard isEnabled else { return }
|
||
|
||
if player?.error != nil
|
||
|| player?.currentItem?.error != nil,
|
||
let asset = player?.currentItem?.asset as? AVURLAsset {
|
||
|
||
// reset the player on error
|
||
player?.replaceCurrentItem(with: AVPlayerItem(asset: AVURLAsset(url: asset.url)))
|
||
}
|
||
|
||
guard let player = player else { return }
|
||
if player.hasFinishedPlaying {
|
||
seek(to: 0)
|
||
}
|
||
|
||
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: backAndForwardSkipDuration.rawValue, using: CMTimeSubtract)
|
||
}
|
||
|
||
@IBAction public func goForwardInTime(_ sender: Any?) {
|
||
modifyCurrentTime(with: backAndForwardSkipDuration.rawValue, using: CMTimeAdd)
|
||
}
|
||
|
||
@IBAction public func toggleSpeed(_ sender: Any?) {
|
||
if NSEvent.modifierFlags.contains(.option) {
|
||
playbackSpeed = playbackSpeed.previous
|
||
} else {
|
||
playbackSpeed = playbackSpeed.next
|
||
}
|
||
}
|
||
|
||
@IBAction public func toggleTrailingTimeLabelMode(_ sender: Any?) {
|
||
trailingLabelDisplaysDuration.toggle()
|
||
}
|
||
|
||
public func reduceSpeed() {
|
||
guard let speedIndex = PUIPlaybackSpeed.all.firstIndex(of: playbackSpeed) else { return }
|
||
if speedIndex > 0 {
|
||
playbackSpeed = PUIPlaybackSpeed.all[speedIndex - 1]
|
||
showControls(animated: true)
|
||
resetMouseIdleTimer()
|
||
}
|
||
}
|
||
|
||
public func increaseSpeed() {
|
||
guard let speedIndex = PUIPlaybackSpeed.all.firstIndex(of: playbackSpeed) else { return }
|
||
if speedIndex < PUIPlaybackSpeed.all.count - 1 {
|
||
playbackSpeed = PUIPlaybackSpeed.all[speedIndex + 1]
|
||
showControls(animated: true)
|
||
resetMouseIdleTimer()
|
||
}
|
||
}
|
||
|
||
@IBAction public func addAnnotation(_ sender: NSView?) {
|
||
guard let player = player else { return }
|
||
|
||
/// Prevent several clicks in quick succession from creating lots of annotations at the same location.
|
||
addAnnotationButton.isEnabled = false
|
||
|
||
let timestamp = Double(CMTimeGetSeconds(player.currentTime()))
|
||
|
||
delegate?.playerViewDidSelectAddAnnotation(self, at: timestamp)
|
||
}
|
||
|
||
@IBAction public func togglePip(_ sender: NSView?) {
|
||
if isInPictureInPictureMode {
|
||
pipController?.stopPictureInPicture()
|
||
} else {
|
||
pipController?.startPictureInPicture()
|
||
}
|
||
}
|
||
|
||
@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, preferredTimescale: durationTime.timescale)
|
||
let targetTime = function(player.currentTime(), modifier)
|
||
|
||
seek(to: targetTime)
|
||
}
|
||
|
||
private func seek(to timestamp: TimeInterval) {
|
||
seek(to: CMTimeMakeWithSeconds(timestamp, preferredTimescale: 90000))
|
||
}
|
||
|
||
private func seek(to time: CMTime) {
|
||
guard time.isValid && time.isNumeric else { return }
|
||
|
||
player?.seek(to: time)
|
||
}
|
||
|
||
private func invalidateTouchBar(destructive: Bool = false) {
|
||
touchBarController.invalidate(destructive)
|
||
}
|
||
|
||
// MARK: - Subtitles
|
||
|
||
private var subtitlesMenu: NSMenu?
|
||
private var subtitlesGroup: AVMediaSelectionGroup?
|
||
|
||
@MainActor
|
||
private func updateSubtitleSelectionMenu(subtitlesGroup: AVMediaSelectionGroup?) {
|
||
guard let subtitlesGroup else {
|
||
subtitlesButton.isHidden = true
|
||
return
|
||
}
|
||
|
||
self.subtitlesGroup = subtitlesGroup
|
||
|
||
let hideSubtitlesButton = !(appearanceDelegate?.playerViewShouldShowSubtitlesControl(self) ?? true)
|
||
|
||
subtitlesButton.isHidden = hideSubtitlesButton
|
||
|
||
let menu = NSMenu()
|
||
|
||
subtitlesGroup.options.forEach { option in
|
||
let item = NSMenuItem(title: option.displayName, action: #selector(didSelectSubtitleOption), keyEquivalent: "")
|
||
item.representedObject = option
|
||
item.target = self
|
||
|
||
menu.addItem(item)
|
||
}
|
||
|
||
subtitlesMenu = menu
|
||
}
|
||
|
||
private func updateSubtitleSelectionMenu() {
|
||
if let appearanceDelegate {
|
||
guard appearanceDelegate.playerViewShouldShowSubtitlesControl(self) else {
|
||
self.subtitlesButton.isHidden = true
|
||
return
|
||
}
|
||
}
|
||
|
||
guard let subtitlesGroup else { return }
|
||
|
||
updateSubtitleSelectionMenu(subtitlesGroup: subtitlesGroup)
|
||
}
|
||
|
||
@objc fileprivate func didSelectSubtitleOption(_ sender: NSMenuItem) {
|
||
guard let subtitlesGroup = subtitlesGroup else { return }
|
||
guard let option = sender.representedObject as? AVMediaSelectionOption else { return }
|
||
|
||
// reset all item's states
|
||
sender.menu?.items.forEach({ $0.state = .off })
|
||
|
||
// The current language was clicked again, turn subtitles off
|
||
if option.extendedLanguageTag == player?.currentItem?.currentMediaSelection.selectedMediaOption(in: subtitlesGroup)?.extendedLanguageTag {
|
||
player?.currentItem?.select(nil, in: subtitlesGroup)
|
||
return
|
||
}
|
||
|
||
player?.currentItem?.select(option, in: subtitlesGroup)
|
||
sender.state = .on
|
||
}
|
||
|
||
@IBAction func showSubtitlesMenu(_ sender: PUIButton) {
|
||
subtitlesMenu?.popUp(positioning: nil, at: .zero, in: sender)
|
||
}
|
||
|
||
// MARK: - Key commands
|
||
|
||
private var keyDownEventMonitor: Any?
|
||
|
||
private enum KeyCommands {
|
||
case spaceBar
|
||
case leftArrow
|
||
case rightArrow
|
||
case minus
|
||
case lessThan
|
||
case plus
|
||
case greaterThan
|
||
case j
|
||
case k
|
||
case l
|
||
|
||
static func fromEvent(_ event: NSEvent) -> KeyCommands? {
|
||
// `keyCode` and `charactersIgnoringModifiers` both will raise exceptions if called on
|
||
// events that are not key events
|
||
guard event.type == .keyDown else { return nil }
|
||
|
||
switch event.keyCode {
|
||
case 123: return .leftArrow
|
||
case 124: return .rightArrow
|
||
default: break
|
||
}
|
||
|
||
// Correctly support keyboard localization, different keyboard layouts produce different
|
||
// characters for the same `keyCode`
|
||
guard let character = event.charactersIgnoringModifiers else {
|
||
return nil
|
||
}
|
||
|
||
switch character {
|
||
case " ": return .spaceBar
|
||
case "-": return .minus
|
||
case "<": return .lessThan
|
||
case "+": return .plus
|
||
case ">": return .greaterThan
|
||
case "j": return .j
|
||
case "k": return .k
|
||
case "l": return .l
|
||
default: return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
public var isEnabled = true {
|
||
didSet {
|
||
guard isEnabled != oldValue else { return }
|
||
if !isEnabled { hideControls(animated: true) }
|
||
}
|
||
}
|
||
|
||
private func startMonitoringKeyEvents() {
|
||
#if DEBUG
|
||
/// I was having weird crashes in previews related to key event monitoring...
|
||
guard !ProcessInfo.isSwiftUIPreview else { return }
|
||
#endif
|
||
|
||
if keyDownEventMonitor != nil {
|
||
stopMonitoringKeyEvents()
|
||
}
|
||
|
||
keyDownEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [unowned self] event in
|
||
guard self.isEnabled else { return event }
|
||
|
||
guard let command = KeyCommands.fromEvent(event) else {
|
||
return event
|
||
}
|
||
|
||
let allWindows = NSApp.windows
|
||
let firstResponders = allWindows.compactMap { $0.firstResponder }
|
||
let fieldEditors = firstResponders.filter { ($0 as? NSText)?.isEditable == true }
|
||
guard fieldEditors.isEmpty else { return event }
|
||
guard !self.timelineView.isEditingAnnotation else { return event }
|
||
|
||
switch command {
|
||
case .spaceBar, .k:
|
||
self.togglePlaying(nil)
|
||
return nil
|
||
|
||
case .leftArrow, .j:
|
||
self.goBackInTime(nil)
|
||
return nil
|
||
|
||
case .rightArrow, .l:
|
||
self.goForwardInTime(nil)
|
||
return nil
|
||
|
||
case .minus, .lessThan:
|
||
self.reduceSpeed()
|
||
return nil
|
||
|
||
case .plus, .greaterThan:
|
||
self.increaseSpeed()
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopMonitoringKeyEvents() {
|
||
if let keyDownEventMonitor = keyDownEventMonitor {
|
||
NSEvent.removeMonitor(keyDownEventMonitor)
|
||
}
|
||
|
||
keyDownEventMonitor = nil
|
||
}
|
||
|
||
// MARK: - Touch Bar
|
||
|
||
private lazy var touchBarController: PUITouchBarController = {
|
||
return PUITouchBarController(playerView: self)
|
||
}()
|
||
|
||
public override func makeTouchBar() -> NSTouchBar? {
|
||
return touchBarController.makeTouchBar()
|
||
}
|
||
|
||
// MARK: - PiP Support
|
||
|
||
public func snapshotPlayer(completion: @escaping (CGImage?) -> Void) {
|
||
guard let currentTime = player?.currentTime() else {
|
||
completion(nil)
|
||
return
|
||
}
|
||
guard let asset = player?.currentItem?.asset else {
|
||
completion(nil)
|
||
return
|
||
}
|
||
|
||
let generator = AVAssetImageGenerator(asset: asset)
|
||
generator.appliesPreferredTrackTransform = true
|
||
|
||
let time = NSValue(time: currentTime)
|
||
generator.generateCGImagesAsynchronously(forTimes: [time]) { _, rawImage, _, result, error in
|
||
guard let rawImage = rawImage, error == nil else {
|
||
DispatchQueue.main.async { completion(nil) }
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.async { completion(rawImage) }
|
||
}
|
||
}
|
||
|
||
private let pipController: AVPictureInPictureController?
|
||
private var pipPossibleObservation: Any?
|
||
|
||
// 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 window.isOnActiveSpace && window.isVisible else { return false }
|
||
|
||
guard !timelineView.isEditingAnnotation else { return false }
|
||
|
||
guard !speedButton.isEditingCustomSpeed 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()
|
||
|
||
guard isEnabled else { return }
|
||
|
||
setControls(opacity: 1, animated: animated)
|
||
}
|
||
|
||
private func setControls(opacity: CGFloat, animated: Bool) {
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = animated ? 0.4 : 0.0
|
||
scrimContainerView.animator().alphaValue = opacity
|
||
controlsContainerView.animator().alphaValue = opacity
|
||
topTrailingMenuContainerView.animator().alphaValue = opacity
|
||
}, completionHandler: nil)
|
||
}
|
||
|
||
public override func viewWillMove(toWindow newWindow: NSWindow?) {
|
||
|
||
NotificationCenter.default.removeObserver(self, name: NSWindow.willEnterFullScreenNotification, object: window)
|
||
NotificationCenter.default.removeObserver(self, name: NSWindow.willExitFullScreenNotification, object: window)
|
||
NotificationCenter.default.removeObserver(self, name: NSWindow.didResignMainNotification, object: window)
|
||
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window)
|
||
|
||
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)
|
||
}
|
||
|
||
public override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
|
||
resetMouseIdleTimer()
|
||
updateTopTrailingMenuPosition()
|
||
|
||
if window != nil {
|
||
lastKnownWindow = window
|
||
startMonitoringKeyEvents()
|
||
invalidateTouchBar(destructive: true)
|
||
}
|
||
}
|
||
|
||
var windowIsInFullScreen: Bool {
|
||
guard let window = window else { return false }
|
||
|
||
return window.styleMask.contains(.fullScreen)
|
||
}
|
||
|
||
private var isTransitioningFromFullScreenPlayback = false
|
||
|
||
@objc private func windowWillEnterFullScreen() {
|
||
guard window is PUIPlayerWindow else { return }
|
||
|
||
appearanceDelegate?.presentDetachedStatus(.fullScreen.snapshot(using: snapshotClosure), for: self)
|
||
|
||
fullScreenButton.isHidden = true
|
||
updateTopTrailingMenuPosition()
|
||
}
|
||
|
||
@objc private func windowWillExitFullScreen() {
|
||
guard window is PUIPlayerWindow else { return }
|
||
|
||
/// Set this because it's not safe to check for our window's class in `windowDidExitFullScreen`.
|
||
isTransitioningFromFullScreenPlayback = true
|
||
|
||
/// 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() {
|
||
guard isTransitioningFromFullScreenPlayback else { return }
|
||
|
||
isTransitioningFromFullScreenPlayback = false
|
||
|
||
/// 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
|
||
if windowIsInFullScreen {
|
||
resetMouseIdleTimer(start: true)
|
||
}
|
||
}
|
||
|
||
@objc private func windowDidResignMain() {
|
||
|
||
// resigning main in full screen means we're leaving the space
|
||
if windowIsInFullScreen {
|
||
resetMouseIdleTimer(start: false)
|
||
}
|
||
}
|
||
|
||
// MARK: - Events
|
||
|
||
private var mouseTrackingArea: NSTrackingArea!
|
||
|
||
public override func updateTrackingAreas() {
|
||
super.updateTrackingAreas()
|
||
|
||
if mouseTrackingArea != nil {
|
||
removeTrackingArea(mouseTrackingArea)
|
||
}
|
||
|
||
let options: NSTrackingArea.Options = [.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.mouseEntered(with: event)
|
||
}
|
||
|
||
private func isMouseEventInTimelineArea(_ event: NSEvent) -> Bool {
|
||
isPointInsideTimelineArea(convert(event.locationInWindow, from: nil))
|
||
}
|
||
|
||
private func isPointInsideTimelineArea(_ pointInViewCoordinates: CGPoint) -> Bool {
|
||
let point = convert(pointInViewCoordinates, to: timelineView)
|
||
return timelineView.hoverBounds.contains(point)
|
||
}
|
||
|
||
public override func mouseMoved(with event: NSEvent) {
|
||
showControls(animated: true)
|
||
resetMouseIdleTimer()
|
||
|
||
super.mouseMoved(with: event)
|
||
|
||
if isMouseEventInTimelineArea(event) {
|
||
/// We don't want timeline hover activation when the app is not active.
|
||
guard NSApp.isActive else { return }
|
||
|
||
if !timelineView.hasMouseInside {
|
||
UILog("🐭 Sending mouse entered to timeline view")
|
||
|
||
timelineView.mouseEntered(with: event)
|
||
}
|
||
timelineView.mouseMoved(with: event)
|
||
} else {
|
||
if timelineView.hasMouseInside {
|
||
UILog("🐭 Sending mouse exited to timeline view")
|
||
|
||
timelineView.mouseExited(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
|
||
}
|
||
|
||
/// Dynamically modifying the return value for this doesn't work reliably, and we don't want window dragging
|
||
/// when the cursor is inside the expanded timeline view area, so window drag is handled in `mouseDown`.
|
||
public override var mouseDownCanMoveWindow: Bool { false }
|
||
|
||
public override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||
guard let event else { return true }
|
||
|
||
if event.type == .leftMouseDown {
|
||
guard !isMouseEventInTimelineArea(event) else {
|
||
UILog("🐭 Rejecting first mouse down event because it's within the timeline area")
|
||
return false
|
||
}
|
||
return true
|
||
} else {
|
||
return true
|
||
}
|
||
}
|
||
|
||
public override func mouseDown(with event: NSEvent) {
|
||
guard let window else { return }
|
||
|
||
/// This is important so that clicking outside the custom playback speed editor closes the editor.
|
||
window.makeFirstResponder(self)
|
||
|
||
if timelineView.hasMouseInside, isMouseEventInTimelineArea(event) {
|
||
UILog("🐭 Sending mouse down to timeline view")
|
||
timelineView.mouseDown(with: event)
|
||
return
|
||
}
|
||
|
||
if event.type == .leftMouseDown && event.clickCount == 2 {
|
||
toggleFullscreen(self)
|
||
} else {
|
||
/// `mouseDownCanMoveWindow` is `false`, so drag the window manually.
|
||
/// Once we reach here, we've guaranteed that the cursor is not inside the timeline view area.
|
||
window.performDrag(with: event)
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// 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
|
||
|
||
pause(nil)
|
||
}
|
||
|
||
func timelineViewDidSeek(to progress: Double) {
|
||
guard let duration = asset?.duration else { return }
|
||
|
||
let targetTime = progress * Double(CMTimeGetSeconds(duration))
|
||
let time = CMTimeMakeWithSeconds(targetTime, preferredTimescale: duration.timescale)
|
||
|
||
player?.seek(to: time)
|
||
}
|
||
|
||
func timelineViewDidFinishInteractiveSeek() {
|
||
if wasPlayingBeforeStartingInteractiveSeek {
|
||
play(nil)
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - PiP delegate
|
||
|
||
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)
|
||
|
||
appearanceDelegate?.presentDetachedStatus(.pictureInPicture.snapshot(using: snapshotClosure), for: self)
|
||
}
|
||
|
||
public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||
fullScreenButton.isHidden = true
|
||
pipButton.state = .on
|
||
|
||
invalidateTouchBar()
|
||
}
|
||
|
||
public func pictureInPictureController(
|
||
_ pictureInPictureController: AVPictureInPictureController,
|
||
failedToStartPictureInPictureWithError error: Error
|
||
) {
|
||
log.error("Failed to start PiP \(error, privacy: .public)")
|
||
}
|
||
|
||
// Stop
|
||
|
||
// Called 1st
|
||
public func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||
|
||
}
|
||
|
||
// Called 2nd, not called when the exit button is pressed
|
||
// TODO: The restore button doesn't attempt to do a restoration if the source view is no longer in a window
|
||
public func pictureInPictureController(
|
||
_ pictureInPictureController: AVPictureInPictureController,
|
||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||
) {
|
||
delegate?.playerWillRestoreUserInterfaceForPictureInPictureStop(self)
|
||
|
||
if !NSApp.isActive {
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
|
||
if let window = lastKnownWindow {
|
||
window.makeKeyAndOrderFront(pictureInPictureController)
|
||
|
||
if window.isMiniaturized {
|
||
window.deminiaturize(nil)
|
||
}
|
||
}
|
||
|
||
fullScreenButton.isHidden = false
|
||
|
||
completionHandler(true)
|
||
}
|
||
|
||
// Called Last
|
||
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||
appearanceDelegate?.dismissDetachedStatus(.pictureInPicture, for: self)
|
||
pipButton.state = .off
|
||
invalidateTouchBar()
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
struct PUIPlayerView_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
PUIPlayerViewPreviewWrapper()
|
||
.frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
private struct PUIPlayerViewPreviewWrapper: NSViewRepresentable {
|
||
typealias NSViewType = PUIPlayerView
|
||
|
||
func makeNSView(context: Context) -> PUIPlayerView {
|
||
let player = AVPlayer(url: .previewVideoURL)
|
||
let view = PUIPlayerView(player: player)
|
||
player.seek(to: CMTimeMakeWithSeconds(30, preferredTimescale: 9000))
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: PUIPlayerView, context: Context) {
|
||
|
||
}
|
||
}
|
||
|
||
import UniformTypeIdentifiers
|
||
|
||
private extension URL {
|
||
static let bipbop = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")!
|
||
|
||
static let previewVideoURL: URL = {
|
||
let dirURL = URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support/WWDC")
|
||
guard let enumerator = FileManager.default.enumerator(at: dirURL, includingPropertiesForKeys: [.contentTypeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
|
||
return bipbop
|
||
}
|
||
|
||
while let url = enumerator.nextObject() as? URL {
|
||
let isMovie = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .movie) == true
|
||
guard isMovie else { continue }
|
||
return url
|
||
}
|
||
|
||
return bipbop
|
||
}()
|
||
}
|
||
#endif
|