diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 0c2a0eb1..93c17b80 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -13,11 +13,13 @@ 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() } } @@ -45,6 +47,7 @@ final class PlayerModel: ObservableObject { var accounts: AccountsModel var comments: CommentsModel + var asset: AVURLAsset? var composition = AVMutableComposition() var loadedCompositionAssets = [AVMediaType]() @@ -82,7 +85,6 @@ final class PlayerModel: ObservableObject { self.accounts = accounts ?? AccountsModel() self.comments = comments ?? CommentsModel() - addItemDidPlayToEndTimeObserver() addFrequentTimeObserver() addInfrequentTimeObserver() addPlayerTimeControlStatusObserver() @@ -197,20 +199,21 @@ final class PlayerModel: ObservableObject { if !upgrading { resetSegments() - sponsorBlock.loadSegments( - videoID: video.videoID, - categories: Defaults[.sponsorBlockCategories] - ) { [weak self] in - if Defaults[.showChannelSubscribers] { - self?.loadCurrentItemChannelDetails() + 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)") - - insertPlayerItem(stream, for: video, preservingTime: preservingTime) + loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime) } else { logger.info("playing stream with many assets:") logger.info("composition audio asset: \(stream.audioAsset.url)") @@ -282,11 +285,14 @@ final class PlayerModel: ObservableObject { for video: Video, preservingTime: Bool = false ) { - let playerItem = playerItem(stream) + removeItemDidPlayToEndTimeObserver() + + playerItem = playerItem(stream) guard playerItem != nil else { return } + addItemDidPlayToEndTimeObserver() attachMetadata(to: playerItem!, video: video, for: stream) DispatchQueue.main.async { [weak self] in @@ -296,6 +302,7 @@ final class PlayerModel: ObservableObject { self.stream = stream self.composition = AVMutableComposition() + self.asset = nil } let startPlaying = { @@ -303,7 +310,7 @@ final class PlayerModel: ObservableObject { try? AVAudioSession.sharedInstance().setActive(true) #endif - if self.isAutoplaying(playerItem!) { + if self.isAutoplaying(self.playerItem!) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self = self else { return @@ -334,7 +341,10 @@ final class PlayerModel: ObservableObject { } let replaceItemAndSeek = { - self.player.replaceCurrentItem(with: playerItem) + guard video == self.currentVideo else { + return + } + self.player.replaceCurrentItem(with: self.playerItem) self.seekToPreservedTime { finished in guard finished else { return @@ -361,6 +371,30 @@ final class PlayerModel: ObservableObject { } } + 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, @@ -378,7 +412,7 @@ final class PlayerModel: ObservableObject { of video: Video, preservingTime: Bool = false ) { - asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in + asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in guard let self = self else { return } @@ -420,9 +454,9 @@ final class PlayerModel: ObservableObject { } } - private func playerItem(_ stream: Stream) -> AVPlayerItem? { - if let url = stream.singleAssetURL { - return AVPlayerItem(asset: AVURLAsset(url: url)) + private func playerItem(_: Stream) -> AVPlayerItem? { + if let asset = asset { + return AVPlayerItem(asset: asset) } else { return AVPlayerItem(asset: composition) } @@ -489,7 +523,15 @@ final class PlayerModel: ObservableObject { self, selector: #selector(itemDidPlayToEndTime), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, - object: nil + object: playerItem + ) + } + + private func removeItemDidPlayToEndTimeObserver() { + NotificationCenter.default.removeObserver( + self, + name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, + object: playerItem ) } diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 8d687b9e..8d0f2735 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -62,7 +62,13 @@ extension PlayerModel { preservedTime = currentItem.playbackTime restoreLoadedChannel() - loadAvailableStreams(currentVideo!) + DispatchQueue.main.async { [weak self] in + guard let video = self?.currentVideo else { + return + } + + self?.loadAvailableStreams(video) + } } func preferredStream(_ streams: [Stream]) -> Stream? { @@ -95,6 +101,9 @@ extension PlayerModel { remove(newItem) + currentItem = newItem + player.pause() + accounts.api.loadDetails(newItem) { newItem in self.playItem(newItem, video: newItem.video, at: time) } @@ -135,6 +144,12 @@ extension PlayerModel { ) -> PlayerQueueItem? { let item = PlayerQueueItem(video, playbackTime: atTime) + if play { + currentItem = item + // pause playing current video as it's going to be replaced with next one + player.pause() + } + queue.insert(item, at: prepending ? 0 : queue.endIndex) accounts.api.loadDetails(item) { newItem in diff --git a/Model/Player/PlayerStreams.swift b/Model/Player/PlayerStreams.swift index 65919f81..18a01d83 100644 --- a/Model/Player/PlayerStreams.swift +++ b/Model/Player/PlayerStreams.swift @@ -43,6 +43,10 @@ extension PlayerModel { .load() .onSuccess { response in if let video: Video = response.typedContent() { + guard video == self.currentVideo else { + self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed") + return + } self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams) } else { self.logger.critical("no streams available from \(instance.description)") diff --git a/Model/SponsorBlock/SponsorBlockAPI.swift b/Model/SponsorBlock/SponsorBlockAPI.swift index e26604e8..68daa507 100644 --- a/Model/SponsorBlock/SponsorBlockAPI.swift +++ b/Model/SponsorBlock/SponsorBlockAPI.swift @@ -35,7 +35,9 @@ final class SponsorBlockAPI: ObservableObject { self.videoID = videoID - requestSegments(categories: categories, completionHandler: completionHandler) + DispatchQueue.main.async { [weak self] in + self?.requestSegments(categories: categories, completionHandler: completionHandler) + } } private func requestSegments(categories: Set, completionHandler: @escaping () -> Void = {}) {