import AVKit import CoreData import Defaults import Foundation import Logging import MediaPlayer #if !os(macOS) import UIKit #endif import Siesta import SwiftUI import SwiftyJSON final class PlayerModel: ObservableObject { static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2] static let assetKeysToLoad = ["tracks", "playable", "duration"] let logger = Logger(label: "stream.yattee.app") private(set) var player = AVPlayer() var playerView = Player() var controller: PlayerViewController? var playerItem: AVPlayerItem? @Published var presentingPlayer = false { didSet { handlePresentationChange() } } @Published var stream: Stream? @Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } } @Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } } @Published var streamSelection: Stream? { didSet { rebuildTVMenu() } } @Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } } @Published var currentItem: PlayerQueueItem! @Published var historyVideos = [Video]() @Published var preservedTime: CMTime? @Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } } @Published var sponsorBlock = SponsorBlockAPI() @Published var segmentRestorationTime: CMTime? @Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } } @Published var restoredSegments = [Segment]() @Published var channelWithDetails: Channel? var accounts: AccountsModel var comments: CommentsModel var asset: AVURLAsset? var composition = AVMutableComposition() var loadedCompositionAssets = [AVMediaType]() var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext private var currentArtwork: MPMediaItemArtwork? private var frequentTimeObserver: Any? private var infrequentTimeObserver: Any? private var playerTimeControlStatusObserver: Any? private var statusObservation: NSKeyValueObservation? private var timeObserverThrottle = Throttle(interval: 2) var playingInPictureInPicture = false @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 init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) { self.accounts = accounts ?? AccountsModel() self.comments = comments ?? CommentsModel() addFrequentTimeObserver() addInfrequentTimeObserver() addPlayerTimeControlStatusObserver() } func show() { guard !presentingPlayer else { #if os(macOS) OpenWindow.player.focus() #endif return } #if os(macOS) OpenWindow.player.open() OpenWindow.player.focus() #endif presentingPlayer = true } func hide() { guard presentingPlayer else { return } presentingPlayer = false } func togglePlayer() { #if os(macOS) if !presentingPlayer { OpenWindow.player.open() } OpenWindow.player.focus() #else if presentingPlayer { hide() } else { show() } #endif } var isLoadingVideo: Bool { guard !currentVideo.isNil else { return false } return player.currentItem == nil || time == nil || !time!.isValid } var isPlaying: Bool { player.timeControlStatus == .playing } var time: CMTime? { currentItem?.playbackTime } var live: Bool { currentVideo?.live ?? false } var playerItemDuration: CMTime? { player.currentItem?.asset.duration } var videoDuration: TimeInterval? { currentItem?.duration ?? currentVideo?.length } func togglePlay() { isPlaying ? pause() : play() } func play() { guard player.timeControlStatus != .playing else { return } player.play() } func pause() { guard player.timeControlStatus != .paused else { return } player.pause() } func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) { playNow(video, at: time) guard !playingInPictureInPicture else { return } if inNavigationView { playerNavigationLinkActive = true } else { 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] ) { [weak self] in if Defaults[.showChannelSubscribers] { self?.loadCurrentItemChannelDetails() } } } } if let url = stream.singleAssetURL { logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)") loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime) } else { logger.info("playing stream with many assets:") logger.info("composition audio asset: \(stream.audioAsset.url)") logger.info("composition video asset: \(stream.videoAsset.url)") loadComposition(stream, of: video, preservingTime: preservingTime) } if !upgrading { updateCurrentArtwork() } } func upgradeToStream(_ stream: Stream) { if !self.stream.isNil, self.stream != stream { playStream(stream, of: currentVideo!, 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() { 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, isPlaying { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in self?.play() } } } private func handleNavigationViewPlayerPresentationChange() { if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.pause() } } } private func insertPlayerItem( _ stream: Stream, for video: Video, preservingTime: Bool = false ) { removeItemDidPlayToEndTimeObserver() playerItem = playerItem(stream) guard playerItem != nil else { return } addItemDidPlayToEndTimeObserver() attachMetadata(to: playerItem!, video: video, for: stream) DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.stream = stream self.composition = AVMutableComposition() self.asset = nil } let startPlaying = { #if !os(macOS) try? AVAudioSession.sharedInstance().setActive(true) #endif if self.isAutoplaying(self.playerItem!) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self = self else { return } if !preservingTime, let segment = self.sponsorBlock.segments.first, segment.start < 3, self.lastSkipped.isNil { self.player.seek( to: segment.endTime, toleranceBefore: .secondsInDefaultTimescale(1), toleranceAfter: .zero ) { finished in guard finished else { return } self.lastSkipped = segment self.play() } } else { self.play() } } } } let replaceItemAndSeek = { guard video == self.currentVideo else { return } self.player.replaceCurrentItem(with: self.playerItem) self.seekToPreservedTime { finished in guard finished else { return } self.preservedTime = nil startPlaying() } } if preservingTime { if preservedTime.isNil { saveTime { replaceItemAndSeek() startPlaying() } } else { replaceItemAndSeek() startPlaying() } } else { player.replaceCurrentItem(with: playerItem) startPlaying() } } private func loadSingleAsset( _ url: URL, stream: Stream, of video: Video, preservingTime: Bool = false ) { asset?.cancelLoading() asset = AVURLAsset(url: url) asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in var error: NSError? switch self?.asset?.statusOfValue(forKey: "duration", error: &error) { case .loaded: DispatchQueue.main.async { [weak self] in self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime) } case .failed: self?.playerError = error default: return } } } private func loadComposition( _ stream: Stream, of video: Video, preservingTime: Bool = false ) { loadedCompositionAssets = [] loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime) loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime) } private func loadCompositionAsset( _ asset: AVURLAsset, stream: Stream, type: AVMediaType, of video: Video, preservingTime: Bool = false ) { asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in guard let self = self else { return } self.logger.info("loading \(type.rawValue) track") let assetTracks = asset.tracks(withMediaType: type) guard let compositionTrack = self.composition.addMutableTrack( withMediaType: type, preferredTrackID: kCMPersistentTrackID_Invalid ) else { self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED") return } guard let assetTrack = assetTracks.first else { self.logger.critical("asset \(type.rawValue) track FAILED") return } try! compositionTrack.insertTimeRange( CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)), of: assetTrack, at: .zero ) self.logger.critical("\(type.rawValue) LOADED") guard self.streamSelection == stream else { self.logger.critical("IGNORING LOADED") return } self.loadedCompositionAssets.append(type) if self.loadedCompositionAssets.count == 2 { self.insertPlayerItem(stream, for: video, preservingTime: preservingTime) } } } private func playerItem(_: Stream) -> AVPlayerItem? { if let asset = asset { return AVPlayerItem(asset: asset) } else { return AVPlayerItem(asset: composition) } } private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) { #if !os(macOS) var externalMetadata = [ makeMetadataItem(.commonIdentifierTitle, value: video.title), makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""), makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "") ] if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!), let image = UIImage(data: thumbnailData), let pngData = image.pngData() { let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData) externalMetadata.append(artworkItem) } item.externalMetadata = externalMetadata #endif item.preferredForwardBufferDuration = 5 observePlayerItemStatus(item) } private func observePlayerItemStatus(_ item: AVPlayerItem) { statusObservation?.invalidate() statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in guard let self = self else { return } switch playerItem.status { case .readyToPlay: if self.isAutoplaying(playerItem) { self.play() } case .failed: self.playerError = item.error default: return } } } #if !os(macOS) private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { let item = AVMutableMetadataItem() item.identifier = identifier item.value = value as? NSCopying & NSObjectProtocol item.extendedLanguageTag = "und" return item.copy() as! AVMetadataItem } #endif private func addItemDidPlayToEndTimeObserver() { NotificationCenter.default.addObserver( self, selector: #selector(itemDidPlayToEndTime), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem ) } private func removeItemDidPlayToEndTimeObserver() { NotificationCenter.default.removeObserver( self, name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem ) } @objc func itemDidPlayToEndTime() { prepareCurrentItemForHistory(finished: true) if queue.isEmpty { #if !os(macOS) try? AVAudioSession.sharedInstance().setActive(false) #endif resetQueue() #if os(tvOS) controller?.playerView.dismiss(animated: false) { [weak self] in self?.controller?.dismiss(animated: true) } #else hide() #endif } else { advanceToNextItem() } } private func saveTime(completionHandler: @escaping () -> Void = {}) { let currentTime = player.currentTime() guard currentTime.seconds > 0 else { return } DispatchQueue.main.async { [weak self] in self?.preservedTime = currentTime completionHandler() } } private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) { guard let time = preservedTime else { return } player.seek( to: time, toleranceBefore: .secondsInDefaultTimescale(1), toleranceAfter: .zero, completionHandler: completionHandler ) } private func addFrequentTimeObserver() { let interval = CMTime.secondsInDefaultTimescale(0.5) frequentTimeObserver = player.addPeriodicTimeObserver( forInterval: interval, queue: .main ) { [weak self] _ in guard let self = self else { return } guard !self.currentItem.isNil else { return } #if !os(tvOS) self.updateNowPlayingInfo() #endif self.handleSegments(at: self.player.currentTime()) } } private func addInfrequentTimeObserver() { let interval = CMTime.secondsInDefaultTimescale(5) infrequentTimeObserver = player.addPeriodicTimeObserver( forInterval: interval, queue: .main ) { [weak self] _ in guard let self = self else { return } guard !self.currentItem.isNil else { return } self.timeObserverThrottle.execute { self.updateWatch() } } } private func addPlayerTimeControlStatusObserver() { playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in guard let self = self, self.player == player else { return } if player.timeControlStatus != .waitingToPlayAtSpecifiedRate { self.objectWillChange.send() } if player.timeControlStatus == .playing, player.rate != self.currentRate { player.rate = self.currentRate } #if os(macOS) if player.timeControlStatus == .playing { ScreenSaverManager.shared.disable(reason: "Yattee is playing video") } else { ScreenSaverManager.shared.enable() } #endif self.timeObserverThrottle.execute { self.updateWatch() } } } fileprivate func updateNowPlayingInfo() { var nowPlayingInfo: [String: AnyObject] = [ MPMediaItemPropertyTitle: currentItem.video.title as AnyObject, MPMediaItemPropertyArtist: currentItem.video.author as AnyObject, MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject, MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject, MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject, MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject ] if !currentArtwork.isNil { nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject } if !currentItem.video.live { let itemDuration = currentItem.videoDuration ?? currentItem.duration let duration = itemDuration.isFinite ? Double(itemDuration) : nil if !duration.isNil { nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject } } MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } private func updateCurrentArtwork() { guard let thumbnailData = try? Data(contentsOf: currentItem.video.thumbnailURL(quality: .medium)!) 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 loadCurrentItemChannelDetails() { guard let video = currentVideo, !video.channel.detailsLoaded else { return } if restoreLoadedChannel() { return } accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in if let channel: Channel = response.typedContent() { self?.channelWithDetails = channel withAnimation { self?.currentItem?.video.channel = channel } } } } @discardableResult func restoreLoadedChannel() -> Bool { if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id { currentItem.video.channel = channelWithDetails! return true } return false } func rateLabel(_ rate: Float) -> String { let formatter = NumberFormatter() formatter.minimumFractionDigits = 0 formatter.maximumFractionDigits = 2 return "\(formatter.string(from: NSNumber(value: rate))!)×" } func closeCurrentItem() { prepareCurrentItemForHistory() currentItem = nil player.replaceCurrentItem(with: nil) } func closePiP() { guard playingInPictureInPicture else { return } let wasPlaying = isPlaying pause() #if os(tvOS) show() #endif doClosePiP(wasPlaying: wasPlaying) } #if os(tvOS) private func doClosePiP(wasPlaying: Bool) { let item = player.currentItem let time = player.currentTime() self.player.replaceCurrentItem(with: nil) guard !item.isNil else { return } self.player.seek(to: time) self.player.replaceCurrentItem(with: item) guard wasPlaying else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in self?.play() } } #else private func doClosePiP(wasPlaying: Bool) { controller?.playerView.player = nil controller?.playerView.player = player guard wasPlaying else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.play() } } #endif #if os(macOS) var windowTitle: String { currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)" } #else func handleEnterForeground() { guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else { return } show() closePiP() } #endif }