diff --git a/NineAnimator/Controllers/Player Scene/AnimeViewController.swift b/NineAnimator/Controllers/Player Scene/AnimeViewController.swift index 0813eaa41..318167619 100644 --- a/NineAnimator/Controllers/Player Scene/AnimeViewController.swift +++ b/NineAnimator/Controllers/Player Scene/AnimeViewController.swift @@ -87,7 +87,7 @@ class AnimeViewController: UITableViewController, AVPlayerViewControllerDelegate private var animeRequestTask: NineAnimatorAsyncTask? - private var previousEpisodeRetrivalError: Error? + private var previousEpisodeRetrivalError: (Error, EpisodeLink)? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -128,6 +128,20 @@ class AnimeViewController: UITableViewController, AVPlayerViewControllerDelegate name: .playbackDidEnd, object: nil ) + + /*NotificationCenter.default.addObserver( + self, + selector: #selector(onPlaybackWillEnd(notification:)), + name: .playbackWillEnd, + object: nil + )*/ + + NotificationCenter.default.addObserver( + self, + selector: #selector(onAutoplayShouldPreload(notification:)), + name: .autoPlayShouldPreload, + object: nil + ) } override func didMove(toParent parent: UIViewController?) { @@ -417,13 +431,18 @@ extension AnimeViewController { // MARK: - Initiate playback extension AnimeViewController { - /// Retrieve the `Episode` and `PlaybackMedia` and attempt to initiate playback - private func retrieveAndPlay() { + /** + Retrieve the `Episode` and `PlaybackMedia` and attempt to initiate playback. + + - Note: Must set `self.episodeLink` and `self.selectedEpisodeCell` before calling this function. + + - Parameter forAutoPlay: Errors during retrieval will not be displayed immediately during autoplay. They will be displayed after playback has ended, to not interrupt the user. + */ + private func retrieveAndPlay(forAutoPlay: Bool = false) { // Always uses self.episodeLink since it may be different from the selected cell guard let episodeLink = episodeLink else { return } episodeRequestTask?.cancel() - NotificationCenter.default.removeObserver(self) let content = OfflineContentManager.shared.content(for: episodeLink) @@ -440,9 +459,9 @@ extension AnimeViewController { if CastController.default.isReady { Log.info("Offline content is available, but Google Cast has been setup. Using online media.") } else { - Log.info("Offline content is available. Using donloaded asset.") + Log.info("Offline content is available. Using downloaded asset.") clearSelection() - onPlaybackMediaRetrieved(media) + onPlaybackMediaRetrieved(media, forAutoPlay: forAutoPlay) return } } @@ -454,7 +473,7 @@ extension AnimeViewController { guard let episode = episode else { if let error = error { // `onEpisodeRetrivalStall` will make sure to unselect cell and release reference to selected cell - self.onEpisodeRetrivalStall(error, episodeLink: episodeLink) + self.onEpisodeRetrivalStall(error, episodeLink: episodeLink, retrievedForAutoPlay: forAutoPlay) } else { self.selectedEpisodeCell = nil self.tableView.deselectSelectedRows() @@ -486,12 +505,12 @@ extension AnimeViewController { guard let media = media else { guard let error = error else { return } Log.error("Item not retrived: \"%@\"", error) - self.onPlaybackMediaStall(episode.target, error: error) + self.onPlaybackMediaStall(episode.target, error: error, retreivedForAutoPlay: forAutoPlay) return } - + // Call media retrieved handler - self.onPlaybackMediaRetrieved(media, episode: episode) + self.onPlaybackMediaRetrieved(media, episode: episode, forAutoPlay: forAutoPlay) } } } else { @@ -500,7 +519,8 @@ extension AnimeViewController { episode.target, error: NineAnimatorError.providerError( "NineAnimator does not support playing back from the selected server" - ) + ), + retreivedForAutoPlay: forAutoPlay ) } } @@ -508,7 +528,12 @@ extension AnimeViewController { } /// Handles an episode retrival failiure - private func onEpisodeRetrivalStall(_ error: Error, episodeLink: EpisodeLink) { + private func onEpisodeRetrivalStall(_ error: Error, episodeLink: EpisodeLink, retrievedForAutoPlay: Bool) { + guard retrievedForAutoPlay == false else { + // Save the error so it can be displayed after playback has finished + self.previousEpisodeRetrivalError = (error, episodeLink) + return + } let restoreInterfaceElements = { [weak self] in self?.tableView.deselectSelectedRows() @@ -635,15 +660,16 @@ extension AnimeViewController { style: .cancel ) { _ in restoreInterfaceElements() }) - previousEpisodeRetrivalError = error + previousEpisodeRetrivalError = (error, episodeLink) present(alert, animated: true) } /// Handle when the link to the episode has been retrieved but no streamable link was found - private func onPlaybackMediaStall(_ fallbackURL: URL, error: Error) { + private func onPlaybackMediaStall(_ fallbackURL: URL, error: Error, retreivedForAutoPlay: Bool) { Log.info("[PlayerViewController] Playback media retrival stalled with error: %@", error) - if NineAnimator.default.user.playbackFallbackToBrowser { + // Fallback to in-app-browser if enabled, and not using autoPlay + if NineAnimator.default.user.playbackFallbackToBrowser && !retreivedForAutoPlay { // Cleanup selections self.tableView.deselectSelectedRows() self.selectedEpisodeCell = nil @@ -651,12 +677,12 @@ extension AnimeViewController { present(playbackController, animated: true) } else if let episodeLink = episodeLink { // Let onEpisodeRetrivalStall prompt the user for alternative options - onEpisodeRetrivalStall(error, episodeLink: episodeLink) + onEpisodeRetrivalStall(error, episodeLink: episodeLink, retrievedForAutoPlay: retreivedForAutoPlay) } } /// Handle the playback media - private func onPlaybackMediaRetrieved(_ media: PlaybackMedia, episode: Episode? = nil) { + private func onPlaybackMediaRetrieved(_ media: PlaybackMedia, episode: Episode? = nil, forAutoPlay: Bool) { // Clear previous episode error defer { previousEpisodeRetrivalError = nil } @@ -664,7 +690,12 @@ extension AnimeViewController { if let episode = episode, CastController.default.isReady { CastController.default.initiate(playbackMedia: media, with: episode) CastController.default.presentPlaybackController() - } else { NativePlayerController.default.play(media: media) } + } else if forAutoPlay { + // Append Media For Autoplay + NativePlayerController.default.append(media: media) + } else { + NativePlayerController.default.play(media: media) + } } /// Cancels the episode retrival task @@ -810,13 +841,39 @@ extension AnimeViewController { } } - // Update suggestion when playback did end + // Update suggestion when playback did end, and display any errors saved during autoPlay episode retrieval @objc private func onPlaybackDidEnd(_ notification: Notification) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { - [weak self] in self?.tableView?.reloadSections( + [weak self] in + guard let self = self else { return } + self.tableView?.reloadSections( Section.indexSet(.suggestion), with: .automatic ) + if let (retrivelError, episodeLink) = self.previousEpisodeRetrivalError { + self.onEpisodeRetrivalStall(retrivelError, episodeLink: episodeLink, retrievedForAutoPlay: false) + } + } + } + + // MARK: - Autoplay + // Called during the Last 2 minutes of video playback if enabled by user. Retrieves next episodeLink. + @objc private func onAutoplayShouldPreload(notification: Notification) { + // If next episode is already being requested, or the request has errored, ignore the notification + guard episodeRequestTask == nil && previousEpisodeRetrivalError == nil else { return } + // Retrieve the next EpisodeLink and it's corresponding UITableViewCell + DispatchQueue.main.async { + guard let currentEpisodeLink = NativePlayerController.default.mediaQueue.first?.link, + let currentEpisodeIndex = self.anime?.episodeLinks.firstIndex(of: currentEpisodeLink), + let nextEpisodeLink = self.anime?.episodeLink(at: currentEpisodeIndex + 1), + let episodeIndexPathForNextLink = self.indexPath(for: nextEpisodeLink), + let episodeCellForNextLink = self.tableView.cellForRow(at: episodeIndexPathForNextLink) + else { return } + + // Request episode + self.selectedEpisodeCell = episodeCellForNextLink + self.episodeLink = nextEpisodeLink + self.retrieveAndPlay(forAutoPlay: true) } } } diff --git a/NineAnimator/Controllers/Player Scene/NativePlayerController.swift b/NineAnimator/Controllers/Player Scene/NativePlayerController.swift index 693ae5d18..60b73f3a3 100644 --- a/NineAnimator/Controllers/Player Scene/NativePlayerController.swift +++ b/NineAnimator/Controllers/Player Scene/NativePlayerController.swift @@ -179,7 +179,7 @@ extension NativePlayerController { // Add observer for did reach end notification NotificationCenter.default.addObserver( self, - selector: #selector(onPlayerDidReachEnd(_:)), + selector: #selector(onPlayerItemDidReachEnd(_:)), name: .AVPlayerItemDidPlayToEndTime, object: item ) @@ -315,20 +315,27 @@ extension NativePlayerController { } } - @objc private func onPlayerDidReachEnd(_ notification: Notification) { + @objc private func onPlayerItemDidReachEnd(_ notification: Notification) { // Remove all did play to end time notificiation observer NotificationCenter.default.removeObserver( self, name: .AVPlayerItemDidPlayToEndTime, object: notification.object ) - - DispatchQueue.main.async { - [weak self] in - guard let self = self else { return } - - // Dismiss the player if no more item is in the queue - if self.mediaQueue.count == 1 { + // Remove the media from the queue, and post `playbackDidEnd` notification + Log.debug( + "[NativePlayerController] AVPlayerItem has finished playing. Removing old item from mediaQueue. New count is: %@", self.mediaQueue.count - 1) + let media = self.mediaQueue.removeFirst() + NotificationCenter.default.post( + name: .playbackDidEnd, + object: self, + userInfo: [ "media": media ] + ) + // Dismiss the player if no more items are in the queue + if self.mediaQueue.isEmpty { + DispatchQueue.main.async { + [weak self] in + guard let self = self else { return } self.playerViewController.dismiss(animated: true) } } @@ -352,10 +359,28 @@ extension NativePlayerController { // Last 15 seconds, fire will end events if case 14.0...15.0 = currentPlaybackTMinus { - NotificationCenter.default.post(name: .playbackWillEnd, object: self, userInfo: nil) + NotificationCenter.default.post( + name: .playbackWillEnd, + object: self, + userInfo: nil + ) if player.isExternalPlaybackActive { - NotificationCenter.default.post(name: .externalPlaybackWillEnd, object: self, userInfo: nil) + NotificationCenter.default.post( + name: .externalPlaybackWillEnd, + object: self, + userInfo: nil + ) + } + } + // In Last 2 minutes, if mediaQueue doesn't have more media, alert autoPlay (if enabled) to preload next media + if case 0.0...120.0 = currentPlaybackTMinus { + if mediaQueue.count <= 1 { + NotificationCenter.default.post( + name: .autoPlayShouldPreload, + object: self, + userInfo: nil + ) } } } diff --git a/NineAnimator/Models/Anime Source/aniwatch.me/Aniwatch+Episode.swift b/NineAnimator/Models/Anime Source/aniwatch.me/Aniwatch+Episode.swift index 54a073ec8..945dc965c 100644 --- a/NineAnimator/Models/Anime Source/aniwatch.me/Aniwatch+Episode.swift +++ b/NineAnimator/Models/Anime Source/aniwatch.me/Aniwatch+Episode.swift @@ -104,7 +104,9 @@ extension NASourceAniwatch { let additionalHeaders = HTTPCookie.requestHeaderFields(with: cookies) playbackHeaders.merge(additionalHeaders) { $1 } } - + if (Int.random(in: 0...1) == 1) { + throw NineAnimatorError.decodeError("Fake Error") + } return Episode( link, target: episodeURL, diff --git a/NineAnimator/Models/Anime.swift b/NineAnimator/Models/Anime.swift index c637dee51..cc6d7c4a9 100644 --- a/NineAnimator/Models/Anime.swift +++ b/NineAnimator/Models/Anime.swift @@ -137,8 +137,8 @@ struct Anime { } /// Retrieve an episode link at index under the current server selection - func episodeLink(at index: Int) -> EpisodeLink { - episodeLinks[index] + func episodeLink(at index: Int) -> EpisodeLink? { + episodeLinks[safe: index] } /// Find episodes on alternative different servers with the same name diff --git a/NineAnimator/Utilities/Notifications.swift b/NineAnimator/Utilities/Notifications.swift index d346f969e..bf2a92be7 100644 --- a/NineAnimator/Utilities/Notifications.swift +++ b/NineAnimator/Utilities/Notifications.swift @@ -43,6 +43,19 @@ extension Notification.Name { static let playbackWillEnd = Notification.Name("com.marcuszhou.nineanimator.playbackWillEnd") + /** + Fired within the last 2 minutes of video playback. Used to alert when the app should preload the next episode of an anime. + + ## Where its posted + - `NativePlayerController.persistProgress` + - Checked in `AnimeViewController` + + ## UserInfo + - Provides the currently playing media. + - ["currentMedia": `PlaybackMedia`] + */ + static let autoPlayShouldPreload = Notification.Name("com.marcuszhou.nineanimator.autoPlayShouldPreload") + /** Fired after the playback has ended