Restructure model

This commit is contained in:
Arkadiusz Fal
2021-10-21 11:30:33 +02:00
parent c3326a56af
commit bb8a8dee05
15 changed files with 38 additions and 16 deletions

View File

@@ -0,0 +1,332 @@
import AVKit
import Defaults
import Foundation
import Logging
#if !os(macOS)
import UIKit
#endif
import Siesta
import SwiftyJSON
final class PlayerModel: ObservableObject {
let logger = Logger(label: "net.arekf.Pearvidious.ps")
private(set) var player = AVPlayer()
var controller: PlayerViewController?
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
@Published var presentingPlayer = false
@Published var stream: Stream?
@Published var currentRate: Float?
@Published var availableStreams = [Stream]() { didSet { rebuildStreamsMenu() } }
@Published var streamSelection: Stream? { didSet { rebuildStreamsMenu() } }
@Published var queue = [PlayerQueueItem]()
@Published var currentItem: PlayerQueueItem!
@Published var live = false
@Published var time: CMTime?
@Published var history = [PlayerQueueItem]()
@Published var savedTime: CMTime?
@Published var composition = AVMutableComposition()
var accounts: AccountsModel
var instances: InstancesModel
var timeObserver: Any?
private var shouldResumePlaying = true
private var statusObservation: NSKeyValueObservation?
init(accounts: AccountsModel? = nil, instances: InstancesModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.instances = instances ?? InstancesModel()
addItemDidPlayToEndTimeObserver()
addTimeObserver()
}
func presentPlayer() {
presentingPlayer = true
}
var isPlaying: Bool {
player.timeControlStatus == .playing
}
func togglePlay() {
isPlaying ? pause() : play()
}
func play() {
guard !isPlaying else {
return
}
player.play()
}
func pause() {
guard isPlaying else {
return
}
player.pause()
}
func playVideo(_ video: Video) {
savedTime = nil
shouldResumePlaying = true
loadAvailableStreams(video) { streams in
guard let stream = streams.first else {
return
}
self.streamSelection = stream
self.playStream(stream, of: video, forcePlay: true)
}
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentItem.video, preservingTime: true)
}
}
func piped(_ instance: Instance) -> PipedAPI {
PipedAPI(account: instance.anonymousAccount)
}
func invidious(_ instance: Instance) -> InvidiousAPI {
InvidiousAPI(account: instance.anonymousAccount)
}
private func playStream(
_ stream: Stream,
of video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) {
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
insertPlayerItem(stream, for: video, forcePlay: forcePlay, 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)")
Task {
await self.loadComposition(stream, of: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
}
}
private func insertPlayerItem(
_ stream: Stream,
for video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) {
let playerItem = playerItem(stream)
guard playerItem != nil else {
return
}
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async {
self.stream = stream
self.composition = AVMutableComposition()
}
shouldResumePlaying = forcePlay || isPlaying
if preservingTime {
saveTime {
self.player.replaceCurrentItem(with: playerItem)
self.seekToSavedTime { finished in
guard finished else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
self.shouldResumePlaying = false
}
}
}
} else {
player.replaceCurrentItem(with: playerItem)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
self.shouldResumePlaying = false
}
}
}
private func loadComposition(
_ stream: Stream,
of video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) async {
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
guard streamSelection == stream else {
logger.critical("IGNORING LOADED")
return
}
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
private func loadCompositionAsset(_ asset: AVURLAsset, type: AVMediaType, of video: Video) async {
async let assetTracks = asset.loadTracks(withMediaType: type)
logger.info("loading \(type.rawValue) track")
guard let compositionTrack = composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
}
guard let assetTrack = try? await assetTracks.first else {
logger.critical("asset \(type.rawValue) track FAILED")
return
}
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
of: assetTrack,
at: .zero
)
logger.critical("\(type.rawValue) LOADED")
}
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
if let url = stream.singleAssetURL {
return AVPlayerItem(asset: AVURLAsset(url: url))
} 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
statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { playerItem, _ in
switch playerItem.status {
case .readyToPlay:
if self.isAutoplaying(playerItem), self.shouldResumePlaying {
self.play()
}
case .failed:
print("item error: \(String(describing: item.error))")
print((item.asset as! AVURLAsset).url)
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: nil
)
}
@objc func itemDidPlayToEndTime() {
if queue.isEmpty {
resetQueue()
#if os(tvOS)
avPlayerViewController!.dismiss(animated: true) {
self.controller!.dismiss(animated: true)
}
#endif
presentingPlayer = false
} else {
advanceToNextItem()
}
}
private func saveTime(completionHandler: @escaping () -> Void = {}) {
let currentTime = player.currentTime()
guard currentTime.seconds > 0 else {
return
}
DispatchQueue.main.async {
self.savedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
return
}
player.seek(
to: time,
toleranceBefore: .init(seconds: 1, preferredTimescale: 1000),
toleranceAfter: .zero,
completionHandler: completionHandler
)
}
private func addTimeObserver() {
let interval = CMTime(seconds: 0.5, preferredTimescale: 1000)
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in
self.currentRate = self.player.rate
self.live = self.currentVideo?.live ?? false
self.time = self.player.currentTime()
}
}
}

View File

@@ -0,0 +1,150 @@
import AVFoundation
import Foundation
import Siesta
extension PlayerModel {
var currentVideo: Video? {
currentItem?.video
}
func playAll(_ videos: [Video]) {
let first = videos.first
videos.forEach { video in
enqueueVideo(video) { _, item in
if item.video == first {
self.advanceToItem(item)
}
}
}
}
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
}
func playNow(_ video: Video) {
addCurrentItemToHistory()
enqueueVideo(video, prepending: true) { _, item in
self.advanceToItem(item)
}
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil) {
currentItem = item
if video != nil {
currentItem.video = video!
}
playVideo(currentItem.video)
}
func advanceToNextItem() {
addCurrentItemToHistory()
if let nextItem = queue.first {
advanceToItem(nextItem)
}
}
func advanceToItem(_ newItem: PlayerQueueItem) {
let item = remove(newItem)!
loadDetails(newItem.video) { video in
self.playItem(item, video: video)
}
}
@discardableResult func remove(_ item: PlayerQueueItem) -> PlayerQueueItem? {
if let index = queue.firstIndex(where: { $0 == item }) {
return queue.remove(at: index)
}
return nil
}
func resetQueue() {
DispatchQueue.main.async {
self.currentItem = nil
self.stream = nil
self.removeQueueItems()
self.timeObserver = nil
}
player.replaceCurrentItem(with: nil)
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item
}
@discardableResult func enqueueVideo(
_ video: Video,
play: Bool = false,
prepending: Bool = false,
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
) -> PlayerQueueItem? {
let item = PlayerQueueItem(video)
queue.insert(item, at: prepending ? 0 : queue.endIndex)
loadDetails(video) { video in
videoDetailsLoadHandler(video, item)
if play {
self.playItem(item, video: video)
}
}
return item
}
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
guard video != nil else {
return
}
accounts.api.video(video!.videoID).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}
}
}
func addCurrentItemToHistory() {
if let item = currentItem, !history.contains(where: { $0.video.videoID == item.video.videoID }) {
history.insert(item, at: 0)
}
}
func playHistory(_ item: PlayerQueueItem) {
let newItem = enqueueVideo(item.video, prepending: true)
advanceToItem(newItem!)
if let historyItemIndex = history.firstIndex(of: item) {
history.remove(at: historyItemIndex)
}
}
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
if let index = history.firstIndex(where: { $0 == item }) {
return history.remove(at: index)
}
return nil
}
func removeQueueItems() {
queue.removeAll()
}
func removeHistoryItems() {
history.removeAll()
}
}

View File

@@ -0,0 +1,13 @@
import AVFoundation
import Foundation
struct PlayerQueueItem: Hashable, Identifiable {
var id = UUID()
var video: Video
init(_ video: Video) {
self.video = video
}
var playerItems = [AVPlayerItem]()
}

View File

@@ -0,0 +1,115 @@
import Foundation
import Siesta
import SwiftUI
extension PlayerModel {
var isLoadingAvailableStreams: Bool {
streamSelection.isNil || availableStreams.isEmpty
}
var isLoadingStream: Bool {
!stream.isNil && stream != streamSelection
}
var availableStreamsSorted: [Stream] {
availableStreams.sorted(by: streamsSorter)
}
func loadAvailableStreams(
_ video: Video,
completionHandler: @escaping ([Stream]) -> Void = { _ in }
) {
availableStreams = []
var instancesWithLoadedStreams = [Instance]()
instances.all.forEach { instance in
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
}
}
private func fetchStreams(
_ resource: Resource,
instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
resource
.load()
.onSuccess { response in
if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)
}
private func completeIfAllInstancesLoaded(
instance: Instance,
streams: [Stream],
instancesWithLoadedStreams: inout [Instance],
completionHandler: @escaping ([Stream]) -> Void
) {
instancesWithLoadedStreams.append(instance)
rebuildStreamsMenu()
if instances.all.count == instancesWithLoadedStreams.count {
completionHandler(streams.sorted { $0.kind < $1.kind })
}
}
#if os(tvOS)
var streamsMenu: UIMenu {
UIMenu(
title: "Streams",
image: UIImage(systemName: "antenna.radiowaves.left.and.right"),
children: streamsMenuActions
)
}
var streamsMenuActions: [UIAction] {
guard !availableStreams.isEmpty else {
return [ // swiftlint:disable:this implicit_return
UIAction(title: "Empty", attributes: .disabled) { _ in }
]
}
return availableStreamsSorted.map { stream in
let state = stream == streamSelection ? UIAction.State.on : .off
return UIAction(title: stream.description, state: state) { _ in
self.streamSelection = stream
self.upgradeToStream(stream)
}
}
}
#endif
func rebuildStreamsMenu() {
#if os(tvOS)
avPlayerViewController?.transportBarCustomMenuItems = [streamsMenu]
#endif
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance
return stream
}
}
func streamsWithAssetsFromInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in stream.withAssetsFrom(instance) }
}
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
}
}