diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index e27d64314..1d0169ab8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -168,7 +168,7 @@ extension StatusSection { // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { cell.statusView.audioView.isHidden = false - AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment) + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, videoPlaybackService: dependency.context.videoPlaybackService) } else { cell.statusView.audioView.isHidden = true } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 3411919c1..c68ff6e77 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -5,11 +5,11 @@ // Created by MainasuK Cirno on 2021-3-3. // -import os.log -import UIKit import Combine import CoreDataStack import MastodonSDK +import os.log +import UIKit extension StatusTableViewCellDelegate where Self: StatusProvider { // TODO: @@ -29,20 +29,20 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // not expired AND last update > 60s guard !poll.expired else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id) return nil } let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) #if DEBUG - let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing + let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing #else let autoRefreshTimeInterval: TimeInterval = 60 #endif guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id, timeIntervalSinceUpdate) return nil } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id) return self.context.apiService.poll( domain: toot.domain, @@ -57,13 +57,13 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { .sink(receiveCompletion: { completion in switch completion { case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", ((#file as NSString).lastPathComponent), #line, #function, pollID ?? "?", error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", (#file as NSString).lastPathComponent, #line, #function, pollID ?? "?", error.localizedDescription) case .finished: break } }, receiveValue: { response in let poll = response.value - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", (#file as NSString).lastPathComponent, #line, #function, poll.id) }) .store(in: &disposeBag) @@ -79,10 +79,22 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } .store(in: &disposeBag) } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - + func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + toot(for: cell, indexPath: indexPath) + .sink { [weak self] toot in + guard let self = self else { return } + guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } + + DispatchQueue.main.async { + videoPlayerViewModel.didEndDisplaying() + } + } + .store(in: &disposeBag) + } } + +extension StatusTableViewCellDelegate where Self: StatusProvider {} diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 70509d40e..7370c4fef 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -12,7 +12,8 @@ import UIKit class AudioContainerViewModel { static func configure( cell: StatusTableViewCell, - audioAttachment: Attachment + audioAttachment: Attachment, + videoPlaybackService: VideoPlaybackService ) { guard let duration = audioAttachment.meta?.original?.duration else { return } let audioView = cell.statusView.audioView @@ -25,12 +26,15 @@ class AudioContainerViewModel { AudioPlayer.shared.pause() } else { AudioPlayer.shared.resume() + videoPlaybackService.pauseWhenPlayAudio() } if AudioPlayer.shared.currentTimeSubject.value == 0 { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + videoPlaybackService.pauseWhenPlayAudio() } } else { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + videoPlaybackService.pauseWhenPlayAudio() } } .store(in: &cell.disposeBag) @@ -41,7 +45,7 @@ class AudioContainerViewModel { AudioPlayer.shared.seekToTime(time: time) } .store(in: &cell.disposeBag) - self.observePlayer(cell: cell, audioAttachment: audioAttachment) + observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } @@ -61,11 +65,11 @@ class AudioContainerViewModel { } guard audioAttachment === AudioPlayer.shared.attachment else { return nil } guard let duration = audioAttachment.meta?.original?.duration else { return nil } - + if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 { - guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce + guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce } - + guard !audioView.slider.isTracking else { return nil } return (time, Float(time / duration)) } diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 7d2f68091..e0a2f5ef4 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -5,14 +5,13 @@ // Created by xiaojian sun on 2021/3/10. // +import AVKit +import Combine +import CoreDataStack import os.log import UIKit -import AVKit -import CoreDataStack -import Combine final class VideoPlayerViewModel { - var disposeBag = Set() // input @@ -33,7 +32,7 @@ final class VideoPlayerViewModel { // output let player: AVPlayer - private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) + private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) private var timeControlStatusObservation: NSKeyValueObservation? let timeControlStatus = CurrentValueSubject(.paused) @@ -55,7 +54,7 @@ final class VideoPlayerViewModel { 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) + 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 } @@ -64,11 +63,13 @@ final class VideoPlayerViewModel { .sink { [weak self] timeControlStatus in guard let _ = self else { return } guard timeControlStatus == .playing else { return } + AudioPlayer.shared.pauseIfNeed() switch videoKind { - case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) - case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } - try? AVAudioSession.sharedInstance().setActive(true) } .store(in: &disposeBag) @@ -84,7 +85,6 @@ final class VideoPlayerViewModel { deinit { timeControlStatusObservation = nil } - } extension VideoPlayerViewModel { @@ -95,7 +95,6 @@ extension VideoPlayerViewModel { } extension VideoPlayerViewModel { - func setupLooper() { guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } guard let templateItem = queuePlayer.items().first else { return } @@ -104,10 +103,12 @@ extension VideoPlayerViewModel { func play() { switch videoKind { - case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) - case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } - try? AVAudioSession.sharedInstance().setActive(true) + player.play() updateDate = Date() } @@ -118,11 +119,11 @@ extension VideoPlayerViewModel { } func willDisplay() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + 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 + play() // always auto play GIF case .video: guard isPlayingWhenEndDisplaying else { return } // mute before resume @@ -136,17 +137,16 @@ extension VideoPlayerViewModel { } func didEndDisplaying() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + 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 + pause() // always pause GIF immediately case .video: debouncePlayingState.send(false) } updateDate = Date() } - } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 13479f0af..02c948c29 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -111,6 +111,12 @@ extension AudioPlayer { self.currentTimeSubject.value = 0 } .store(in: &disposeBag) + NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + self.pause() + } + .store(in: &disposeBag) } func isPlaying() -> Bool { @@ -125,7 +131,11 @@ extension AudioPlayer { player.pause() playbackState.value = .paused } - + func pauseIfNeed() { + if isPlaying() { + pause() + } + } func seekToTime(time: TimeInterval) { player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) } diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 5f1f2a121..724026fdd 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -5,14 +5,13 @@ // Created by xiaojian sun on 2021/3/10. // -import os.log -import Foundation import AVKit import Combine import CoreDataStack +import Foundation +import os.log final class VideoPlaybackService { - var disposeBag = Set() let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") @@ -20,7 +19,6 @@ final class VideoPlaybackService { // only for video kind weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? - } extension VideoPlaybackService { @@ -43,7 +41,6 @@ extension VideoPlaybackService { if latestPlayingVideoPlayerViewModel === playerViewModel { latestPlayingVideoPlayerViewModel = nil try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) - try? AVAudioSession.sharedInstance().setActive(false) } } } @@ -51,16 +48,15 @@ extension VideoPlaybackService { } extension VideoPlaybackService { - func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { // Core Data entity not thread-safe. Save attribute before enter working queue guard let height = media.meta?.original?.height, let width = media.meta?.original?.width, let url = URL(string: media.url), - media.type == .gifv || media.type == .video else - { return nil } + media.type == .gifv || media.type == .video + else { return nil } - let previewImageURL = media.previewURL.flatMap({ URL(string: $0) }) + let previewImageURL = media.previewURL.flatMap { URL(string: $0) } let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video var _viewModel: VideoPlayerViewModel? @@ -95,7 +91,6 @@ extension VideoPlaybackService { } .store(in: &disposeBag) } - } extension VideoPlaybackService { @@ -106,7 +101,7 @@ extension VideoPlaybackService { } func viewDidDisappear(from viewController: UIViewController?) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) // note: do not retain view controller // pause all player when view disppear exclude full screen player and other transitioning scene @@ -116,11 +111,25 @@ extension VideoPlaybackService { continue } guard !viewModel.isFullScreenPresentationing else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) continue } guard viewModel.videoKind == .video else { continue } viewModel.pause() } } + + func pauseWhenPlayAudio() { + for viewModel in viewPlayerViewModelDict.values { + guard !viewModel.isTransitioning else { + viewModel.isTransitioning = false + continue + } + guard !viewModel.isFullScreenPresentationing else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) + continue + } + viewModel.pause() + } + } }