// // VideoPlayerViewModel.swift // Mastodon // // Created by xiaojian sun on 2021/3/10. // import AVKit import Combine import CoreDataStack import os.log import UIKit final class VideoPlayerViewModel { var disposeBag = Set() static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.video-playback-service.appWillPlayVideo") // input let previewImageURL: URL? let videoURL: URL let videoSize: CGSize let videoKind: Kind var isTransitioning = false var isFullScreenPresentationing = false var isPlayingWhenEndDisplaying = false // prevent player state flick when tableView reload private typealias Play = Bool private let debouncePlayingState = PassthroughSubject() private var updateDate = Date() // output let player: AVPlayer private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) private var timeControlStatusObservation: NSKeyValueObservation? let timeControlStatus = CurrentValueSubject(.paused) init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { self.previewImageURL = previewImageURL self.videoURL = videoURL self.videoSize = videoSize self.videoKind = videoKind let playerItem = AVPlayerItem(url: videoURL) let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem) player.isMuted = true self.player = player if videoKind == .gif { setupLooper() } timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) self.timeControlStatus.value = player.timeControlStatus } // update audio session category for user interactive event stream timeControlStatus .sink { [weak self] timeControlStatus in guard let _ = self else { return } guard timeControlStatus == .playing else { return } NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) switch videoKind { case .gif: break case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } } .store(in: &disposeBag) debouncePlayingState .debounce(for: 0.3, scheduler: DispatchQueue.main) .sink { [weak self] isPlay in guard let self = self else { return } isPlay ? self.play() : self.pause() } .store(in: &disposeBag) } deinit { timeControlStatusObservation = nil } } extension VideoPlayerViewModel { enum Kind { case gif case video } } extension VideoPlayerViewModel { func setupLooper() { guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } guard let templateItem = queuePlayer.items().first else { return } looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) } func play() { switch videoKind { case .gif: break case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } player.play() updateDate = Date() } func pause() { player.pause() updateDate = Date() } func willDisplay() { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) switch videoKind { case .gif: play() // always auto play GIF case .video: guard isPlayingWhenEndDisplaying else { return } // mute before resume if updateDate.timeIntervalSinceNow < -3 { player.isMuted = true } debouncePlayingState.send(true) } updateDate = Date() } func didEndDisplaying() { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) isPlayingWhenEndDisplaying = timeControlStatus.value != .paused switch videoKind { case .gif: pause() // always pause GIF immediately case .video: debouncePlayingState.send(false) } updateDate = Date() } }