yattee/Model/Player/PlayerModel.swift
2022-08-15 16:34:15 +02:00

673 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AVKit
import CoreData
#if os(iOS)
import CoreMotion
#endif
import Defaults
import Foundation
import Logging
import MediaPlayer
import Siesta
import SwiftUI
import SwiftyJSON
#if !os(macOS)
import UIKit
#endif
final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
let logger = Logger(label: "stream.yattee.app")
var avPlayerView = AppleAVPlayerView()
var playerItem: AVPlayerItem?
var mpvPlayerView = MPVPlayerView()
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var activeBackend = PlayerBackendType.mpv
var avPlayerBackend: AVPlayerBackend!
var mpvBackend: MPVBackend!
var backends: [PlayerBackend] {
[avPlayerBackend, mpvBackend]
}
var backend: PlayerBackend! {
switch activeBackend {
case .mpv:
return mpvBackend
case .appleAVPlayer:
return avPlayerBackend
}
}
@Published var playerSize: CGSize = .zero { didSet {
backend.setSize(playerSize.width, playerSize.height)
}}
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { backend.setRate(currentRate) } }
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { handleQueueChange() } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video?
@Published var historyVideos = [Video]()
@Published var preservedTime: CMTime?
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
@Published var musicMode = false
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
@Published var isSeeking = false { didSet {
backend.setNeedsNetworkStateUpdates(true)
}}
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@Published var lastOrientation: UIInterfaceOrientation?
#endif
var accounts: AccountsModel
var comments: CommentsModel
var controls: PlayerControlsModel { didSet {
backends.forEach { backend in
var backend = backend
backend.controls = controls
}
}}
var playerTime: PlayerTimeModel { didSet {
backends.forEach { backend in
var backend = backend
backend.playerTime = playerTime
backend.playerTime.player = self
}
}}
var networkState: NetworkStateModel { didSet {
backends.forEach { backend in
var backend = backend
backend.networkState = networkState
backend.networkState.player = self
}
}}
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
@Published var playingFullScreen = false
@Published var playingInPictureInPicture = false
var pipController: AVPictureInPictureController?
var pipDelegate = PiPDelegate()
@Published var presentingErrorDetails = false
var playerError: Error? { didSet {
#if !os(tvOS)
if !playerError.isNil {
presentingErrorDetails = true
}
#endif
}}
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
private var currentArtwork: MPMediaItemArtwork?
#if !os(macOS)
var playerLayerView: PlayerLayerView!
#endif
var onPresentPlayer: (() -> Void)?
init(
accounts: AccountsModel = AccountsModel(),
comments: CommentsModel = CommentsModel(),
controls: PlayerControlsModel = PlayerControlsModel(),
playerTime: PlayerTimeModel = PlayerTimeModel(),
networkState: NetworkStateModel = NetworkStateModel()
) {
self.accounts = accounts
self.comments = comments
self.controls = controls
self.playerTime = playerTime
self.networkState = networkState
self.avPlayerBackend = AVPlayerBackend(
model: self,
controls: controls,
playerTime: playerTime
)
self.mpvBackend = MPVBackend(
model: self,
playerTime: playerTime,
networkState: networkState
)
Defaults[.activeBackend] = .mpv
}
func show() {
#if os(macOS)
if presentingPlayer {
Windows.player.focus()
return
}
#endif
DispatchQueue.main.async { [weak self] in
self?.presentingPlayer = true
}
#if os(macOS)
Windows.player.open()
Windows.player.focus()
#endif
}
func hide() {
DispatchQueue.main.async { [weak self] in
self?.playingFullScreen = false
self?.presentingPlayer = false
}
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
#endif
}
func togglePlayer() {
#if os(macOS)
if !presentingPlayer {
Windows.player.open()
}
Windows.player.focus()
#else
if presentingPlayer {
hide()
} else {
show()
}
#endif
}
var isLoadingVideo: Bool {
guard !currentVideo.isNil else {
return false
}
return backend.isLoadingVideo
}
var isPlaying: Bool {
backend.isPlaying
}
var playerItemDuration: CMTime? {
guard !currentItem.isNil else {
return nil
}
return backend.playerItemDuration
}
var playerItemDurationWithoutSponsorSegments: CMTime? {
guard let playerItemDuration = playerItemDuration, !playerItemDuration.seconds.isZero else {
return nil
}
return playerItemDuration - .secondsInDefaultTimescale(
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
)
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length ?? playerItemDuration?.seconds
}
var time: CMTime? {
currentItem?.playbackTime
}
var live: Bool {
currentVideo?.live ?? false
}
func togglePlay() {
backend.togglePlay()
}
func play() {
backend.play()
}
func pause() {
backend.pause()
}
func play(_ video: Video, at time: CMTime? = nil, showingPlayer: Bool = true) {
pause()
#if os(iOS)
if !playingInPictureInPicture, showingPlayer {
onPresentPlayer = { [weak self] in self?.playNow(video, at: time) }
show()
return
}
#endif
playNow(video, at: time)
guard !playingInPictureInPicture else {
return
}
if showingPlayer {
show()
}
}
func playStream(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false,
upgrading: Bool = false
) {
playerError = nil
if !upgrading {
resetSegments()
DispatchQueue.main.async { [weak self] in
self?.sponsorBlock.loadSegments(
videoID: video.videoID,
categories: Defaults[.sponsorBlockCategories]
)
guard Defaults[.enableReturnYouTubeDislike] else {
return
}
self?.returnYouTubeDislike.loadDislikes(videoID: video.videoID) { [weak self] dislikes in
self?.currentItem?.video?.dislikes = dislikes
}
}
}
playerTime.reset()
backend.playStream(
stream,
of: video,
preservingTime: preservingTime,
upgrading: upgrading
)
if !upgrading {
updateCurrentArtwork()
}
}
func saveTime(completionHandler: @escaping () -> Void = {}) {
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
completionHandler()
return
}
DispatchQueue.main.async { [weak self] in
self?.preservedTime = currentTime
completionHandler()
}
}
func upgradeToStream(_ stream: Stream, force: Bool = false) {
guard let video = currentVideo else {
return
}
if !self.stream.isNil, force || self.stream != stream {
playStream(stream, of: video, preservingTime: true, upgrading: true)
}
}
private func handleAvailableStreamsChange() {
rebuildTVMenu()
guard stream.isNil else {
return
}
guard let stream = preferredStream(availableStreams) else {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo!,
preservingTime: !currentItem.playbackTime.isNil
)
}
private func handlePresentationChange() {
var delay = 0.0
#if os(iOS)
if presentingPlayer {
delay = 0.2
}
#endif
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.backend.setNeedsDrawing(self?.presentingPlayer ?? false)
}
controls.hide()
#if !os(macOS)
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
#endif
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
}
}
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pause()
}
}
if !presentingPlayer, !pauseOnHidingPlayer, backend.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
}
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
guard activeBackend != to else {
return
}
Defaults[.activeBackend] = to
self.activeBackend = to
guard var stream = stream else {
return
}
if to == .mpv {
addVideoTrackFromStream()
} else {
musicMode = false
}
inactiveBackends().forEach { $0.pause() }
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in
guard finished else {
return
}
toBackend.play()
}
self.stream = stream
streamSelection = stream
return
}
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
guard let preferredStream = preferredStream(availableStreams) else {
return
}
stream = preferredStream
streamSelection = preferredStream
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self = self else {
return
}
self.upgradeToStream(stream, force: true)
self.setNeedsDrawing(self.presentingPlayer)
}
}
private func inactiveBackends() -> [PlayerBackend] {
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
}
func rateLabel(_ rate: Float) -> String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
return "\(formatter.string(from: NSNumber(value: rate))!)×"
}
func closeCurrentItem(finished: Bool = false) {
prepareCurrentItemForHistory(finished: finished)
currentItem = nil
backend.closeItem()
}
func closePiP() {
guard playingInPictureInPicture else {
return
}
let wasPlaying = isPlaying
pause()
#if os(tvOS)
show()
#endif
backend.closePiP(wasPlaying: wasPlaying)
}
func handleQueueChange() {
Defaults[.queue] = queue
controls.objectWillChange.send()
}
func handleCurrentItemChange() {
#if os(macOS)
Windows.player.window?.title = windowTitle
#endif
Defaults[.lastPlayed] = currentItem
}
#if os(macOS)
var windowTitle: String {
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
}
#else
func handleEnterForeground() {
setNeedsDrawing(true)
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
}
show()
closePiP()
}
func handleEnterBackground() {
setNeedsDrawing(false)
if !playingInPictureInPicture, !musicMode {
pause()
}
}
func enterFullScreen() {
guard !playingFullScreen else {
return
}
logger.info("entering fullscreen")
backend.enterFullScreen()
}
func exitFullScreen() {
guard playingFullScreen else {
return
}
logger.info("exiting fullscreen")
if playingFullScreen {
toggleFullscreen(true)
}
backend.exitFullScreen()
}
#endif
func updateNowPlayingInfo() {
guard let video = currentItem?.video else {
return
}
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: video.title as AnyObject,
MPMediaItemPropertyArtist: video.author as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: video.live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
]
if !currentArtwork.isNil {
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
}
if !video.live {
let itemDuration = (backend.playerItemDuration ?? .zero).seconds
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
if !duration.isNil {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
func updateCurrentArtwork() {
guard let video = currentVideo,
let thumbnailURL = video.thumbnailURL(quality: .medium),
let thumbnailData = try? Data(contentsOf: thumbnailURL)
else {
return
}
#if os(macOS)
let image = NSImage(data: thumbnailData)
#else
let image = UIImage(data: thumbnailData)
#endif
if image.isNil {
return
}
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
}
func toggleFullscreen(_ isFullScreen: Bool) {
controls.resetTimer()
#if os(macOS)
Windows.player.toggleFullScreen()
#endif
#if os(iOS)
setNeedsDrawing(false)
#endif
playingFullScreen = !isFullScreen
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.setNeedsDrawing(true)
}
if playingFullScreen {
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
return
}
Orientation.lockOrientation(.landscape, andRotateTo: .landscapeRight)
} else {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
#endif
}
func setNeedsDrawing(_ needsDrawing: Bool) {
backends.forEach { $0.setNeedsDrawing(needsDrawing) }
}
func toggleMusicMode() {
musicMode.toggle()
if musicMode {
if playingInPictureInPicture {
avPlayerBackend.pause()
avPlayerBackend.switchToMPVOnPipClose = false
closePiP()
}
changeActiveBackend(from: .appleAVPlayer, to: .mpv)
controls.presentingControls = true
controls.removeTimer()
mpvBackend.setVideoToNo()
} else {
addVideoTrackFromStream()
mpvBackend.setVideoToAuto()
controls.resetTimer()
}
}
func addVideoTrackFromStream() {
if let videoTrackURL = stream?.videoAsset?.url,
mpvBackend.tracks < 2
{
logger.info("adding video track")
mpvBackend.addVideoTrack(videoTrackURL)
}
mpvBackend.setVideoToAuto()
}
}