From e1143b0ce475e26415a665cad3a37c410f259ba3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 10 Mar 2021 14:36:28 +0800 Subject: [PATCH] feature: video & gifv support --- Mastodon.xcodeproj/project.pbxproj | 26 ++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Diffiable/Section/StatusSection.swift | 54 ++++++- Mastodon/Extension/AVPlayer.swift | 22 +++ ...dency+AVPlayerViewControllerDelegate.swift | 21 +++ .../StatusProvider+UITableViewDelegate.swift | 12 ++ .../HomeTimelineViewController.swift | 18 ++- .../PublicTimelineViewController.swift | 18 ++- .../View/Container/MosaicPlayerView.swift | 121 ++++++++++++++ .../View/Container/TouchBlockingView.swift | 34 ++++ .../Scene/Share/View/Content/StatusView.swift | 8 +- .../TableviewCell/StatusTableViewCell.swift | 14 ++ .../ViewModel/VideoPlayerViewModel.swift | 152 ++++++++++++++++++ Mastodon/Service/ViedeoPlaybackService.swift | 126 +++++++++++++++ Mastodon/State/AppContext.swift | 2 + 15 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Extension/AVPlayer.swift create mode 100644 Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift create mode 100644 Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift create mode 100644 Mastodon/Scene/Share/View/Container/TouchBlockingView.swift create mode 100644 Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift create mode 100644 Mastodon/Service/ViedeoPlaybackService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f2db77a6..69f43190 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -89,8 +89,13 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; + 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */; }; + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; @@ -329,6 +334,12 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; + 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicPlayerView.swift; sourceTree = ""; }; + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -459,7 +470,6 @@ 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -651,6 +661,7 @@ DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, ); path = Service; sourceTree = ""; @@ -673,6 +684,7 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, ); path = Protocol; sourceTree = ""; @@ -1113,6 +1125,7 @@ 2D206B7F25F5F45E00143C56 /* UIImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, ); path = Extension; sourceTree = ""; @@ -1165,6 +1178,8 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */, + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; sourceTree = ""; @@ -1174,6 +1189,7 @@ children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1551,11 +1567,13 @@ buildActionMask = 2147483647; files = ( DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, @@ -1603,6 +1621,7 @@ 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, @@ -1617,6 +1636,7 @@ DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, @@ -1662,6 +1682,7 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, @@ -1686,6 +1707,7 @@ 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, + 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 543a09da..3183f10d 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" + "revision": "81dd1ce8401137637663046c7314e7c885bcc56d", + "version": "6.1.1" } }, { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 489b9d3b..e27d6431 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -34,7 +34,11 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute + ) } cell.delegate = statusTableViewCellDelegate return cell @@ -45,7 +49,11 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute + ) } cell.delegate = statusTableViewCellDelegate return cell @@ -69,9 +77,9 @@ extension StatusSection { } extension StatusSection { - static func configure( cell: StatusTableViewCell, + dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, toot: Toot, @@ -165,6 +173,39 @@ extension StatusSection { cell.statusView.audioView.isHidden = true } + // set GIF & video + let playerViewMaxSize: CGSize = { + let maxWidth: CGFloat = { + // use statusView width as container width + // that width follows readable width and keep constant width after rotate + let containerFrame = readableLayoutFrame ?? cell.statusView.frame + return containerFrame.width + }() + let scale: CGFloat = 1.3 + return CGSize(width: maxWidth, height: maxWidth * scale) + }() + + if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, + let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) + { + let parent = cell.delegate?.parent() + let mosaicPlayerView = cell.statusView.mosaicPlayerView + let playerViewController = mosaicPlayerView.setupPlayer( + aspectRatio: videoPlayerViewModel.videoSize, + maxSize: playerViewMaxSize, + parent: parent + ) + playerViewController.delegate = cell.delegate?.playerViewControllerDelegate + playerViewController.player = videoPlayerViewModel.player + playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif + + mosaicPlayerView.gifIndicatorLabel.isHidden = videoPlayerViewModel.videoKind != .gif + mosaicPlayerView.isHidden = false + + } else { + cell.statusView.mosaicPlayerView.playerViewController.player?.pause() + cell.statusView.mosaicPlayerView.playerViewController.player = nil + } // set poll let poll = (toot.reblog ?? toot).poll StatusSection.configure( @@ -244,7 +285,8 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, - let managedObjectContext = poll.managedObjectContext else { + let managedObjectContext = poll.managedObjectContext + else { cell.statusView.pollTableView.isHidden = true cell.statusView.pollStatusStackView.isHidden = true cell.statusView.pollVoteButton.isHidden = true @@ -288,10 +330,10 @@ extension StatusSection { cell.statusView.pollTableView.allowsSelection = !poll.expired let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + (option.votedBy ?? Set()).map(\.id).contains(requestUserID) } let didVotedLocal = !votedOptions.isEmpty - let didVotedRemote = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) cell.statusView.pollVoteButton.isEnabled = didVotedLocal cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) diff --git a/Mastodon/Extension/AVPlayer.swift b/Mastodon/Extension/AVPlayer.swift new file mode 100644 index 00000000..3e9c06cc --- /dev/null +++ b/Mastodon/Extension/AVPlayer.swift @@ -0,0 +1,22 @@ +// +// AVPlayer.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit + +// MARK: - CustomDebugStringConvertible +extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .paused: return "paused" + case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate" + case .playing: return "playing" + @unknown default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift new file mode 100644 index 00000000..e52fdc05 --- /dev/null +++ b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift @@ -0,0 +1,21 @@ +// +// NeedsDependency+AVPlayerViewControllerDelegate.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import Foundation +import AVKit + +extension NeedsDependency where Self: AVPlayerViewControllerDelegate { + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true + } + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 93f627c0..3411919c 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -66,6 +66,18 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { 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) + + 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.willDisplay() + } + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b9d0f94e..c9498dbe 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -337,5 +337,21 @@ extension HomeTimelineViewController: ScrollViewContainer { } +// MARK: - AVPlayerViewControllerDelegate +extension HomeTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension HomeTimelineViewController: StatusTableViewCellDelegate { } +extension HomeTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 98d2dbd9..50d36d29 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -204,5 +204,21 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat } } +// MARK: - AVPlayerViewControllerDelegate +extension PublicTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension PublicTimelineViewController: StatusTableViewCellDelegate { } +extension PublicTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift new file mode 100644 index 00000000..e7c478ce --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift @@ -0,0 +1,121 @@ +// +// MosaicPlayerView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit +import UIKit + +final class MosaicPlayerView: UIView { + static let cornerRadius: CGFloat = 8 + + private let container = UIView() + private let touchBlockingView = TouchBlockingView() + private var containerHeightLayoutConstraint: NSLayoutConstraint! + + let playerViewController = AVPlayerViewController() + + let gifIndicatorLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .heavy) + label.text = "GIF" + label.textColor = .white + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension MosaicPlayerView { + private func _init() { + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + containerHeightLayoutConstraint, + ]) + + addSubview(gifIndicatorLabel) + gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + gifIndicatorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 4), + gifIndicatorLabel.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + + // will not influence full-screen playback + playerViewController.view.layer.masksToBounds = true + playerViewController.view.layer.cornerRadius = MosaicPlayerView.cornerRadius + playerViewController.view.layer.cornerCurve = .continuous + } +} + +extension MosaicPlayerView { + func reset() { + // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing + + gifIndicatorLabel.removeFromSuperview() + + playerViewController.willMove(toParent: nil) + playerViewController.view.removeFromSuperview() + playerViewController.removeFromParent() + + container.subviews.forEach { subview in + subview.removeFromSuperview() + } + } + + func setupPlayer(aspectRatio: CGSize, maxSize: CGSize, parent: UIViewController?) -> AVPlayerViewController { + reset() + + touchBlockingView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(touchBlockingView) + NSLayoutConstraint.activate([ + touchBlockingView.topAnchor.constraint(equalTo: container.topAnchor), + touchBlockingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + touchBlockingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + let rect = AVMakeRect( + aspectRatio: aspectRatio, + insideRect: CGRect(origin: .zero, size: maxSize) + ) + + parent?.addChild(playerViewController) + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + touchBlockingView.addSubview(playerViewController.view) + parent.flatMap { playerViewController.didMove(toParent: $0) } + NSLayoutConstraint.activate([ + playerViewController.view.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), + playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), + playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), + playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor), + touchBlockingView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1), + ]) + containerHeightLayoutConstraint.constant = floor(rect.height) + containerHeightLayoutConstraint.isActive = true + + gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false + touchBlockingView.addSubview(gifIndicatorLabel) + NSLayoutConstraint.activate([ + touchBlockingView.trailingAnchor.constraint(equalTo: gifIndicatorLabel.trailingAnchor, constant: 8), + touchBlockingView.bottomAnchor.constraint(equalTo: gifIndicatorLabel.bottomAnchor, constant: 8), + ]) + + return playerViewController + } +} diff --git a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift new file mode 100644 index 00000000..b86137f1 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift @@ -0,0 +1,34 @@ +// +// TouchBlockingView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import UIKit + +final class TouchBlockingView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TouchBlockingView { + + private func _init() { + isUserInteractionEnabled = true + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + // Blocking responder chain by not call super + // The subviews in this view will received touch event but superview not + } +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647f..ad773478 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -156,6 +156,8 @@ final class StatusView: UIView { return imageView }() + let mosaicPlayerView = MosaicPlayerView() + let audioView: AudioContainerView = { let audioView = AudioContainerView() return audioView @@ -342,6 +344,7 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + // audio audioView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(audioView) NSLayoutConstraint.activate([ @@ -349,6 +352,8 @@ extension StatusView { audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), audioView.heightAnchor.constraint(equalToConstant: 44) ]) + // video gif + statusContainerStackView.addArrangedSubview(mosaicPlayerView) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -359,7 +364,8 @@ extension StatusView { pollTableView.isHidden = true pollStatusStackView.isHidden = true audioView.isHidden = true - + mosaicPlayerView.isHidden = true + contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 13c3afba..2f4000b9 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -16,6 +16,11 @@ protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } + func parent() -> UIViewController + var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -25,6 +30,13 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) } +extension StatusTableViewCellDelegate { + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + playerViewController.showsPlaybackControls.toggle() + } +} + final class StatusTableViewCell: UITableViewCell { static let bottomPaddingHeight: CGFloat = 10 @@ -42,6 +54,8 @@ final class StatusTableViewCell: UITableViewCell { statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() statusView.pollTableView.dataSource = nil + statusView.mosaicPlayerView.reset() + statusView.mosaicPlayerView.isHidden = true disposeBag.removeAll() observations.removeAll() } diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift new file mode 100644 index 00000000..7d2f6809 --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -0,0 +1,152 @@ +// +// VideoPlayerViewModel.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import os.log +import UIKit +import AVKit +import CoreDataStack +import Combine + +final class VideoPlayerViewModel { + + var disposeBag = Set() + + // 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 } + switch videoKind { + case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + try? AVAudioSession.sharedInstance().setActive(true) + } + .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: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + try? AVAudioSession.sharedInstance().setActive(true) + 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() + } + +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift new file mode 100644 index 00000000..5f1f2a12 --- /dev/null +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -0,0 +1,126 @@ +// +// ViedeoPlaybackService.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import os.log +import Foundation +import AVKit +import Combine +import CoreDataStack + +final class VideoPlaybackService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] + + // only for video kind + weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? + +} + +extension VideoPlaybackService { + private func playerViewModel(_ playerViewModel: VideoPlayerViewModel, didUpdateTimeControlStatus: AVPlayer.TimeControlStatus) { + switch playerViewModel.videoKind { + case .gif: + // do nothing + return + case .video: + if playerViewModel.timeControlStatus.value != .paused { + latestPlayingVideoPlayerViewModel = playerViewModel + + // pause other player + for viewModel in viewPlayerViewModelDict.values { + guard viewModel.timeControlStatus.value != .paused else { continue } + guard viewModel !== playerViewModel else { continue } + viewModel.pause() + } + } else { + if latestPlayingVideoPlayerViewModel === playerViewModel { + latestPlayingVideoPlayerViewModel = nil + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + try? AVAudioSession.sharedInstance().setActive(false) + } + } + } + } +} + +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 } + + let previewImageURL = media.previewURL.flatMap({ URL(string: $0) }) + let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video + + var _viewModel: VideoPlayerViewModel? + workingQueue.sync { + if let viewModel = viewPlayerViewModelDict[url] { + _viewModel = viewModel + } else { + let viewModel = VideoPlayerViewModel( + previewImageURL: previewImageURL, + videoURL: url, + videoSize: CGSize(width: width, height: height), + videoKind: videoKind + ) + viewPlayerViewModelDict[url] = viewModel + setupListener(for: viewModel) + _viewModel = viewModel + } + } + return _viewModel + } + + func playerViewModel(for playerViewController: AVPlayerViewController) -> VideoPlayerViewModel? { + guard let url = (playerViewController.player?.currentItem?.asset as? AVURLAsset)?.url else { return nil } + return viewPlayerViewModelDict[url] + } + + private func setupListener(for viewModel: VideoPlayerViewModel) { + viewModel.timeControlStatus + .sink { [weak self] timeControlStatus in + guard let self = self else { return } + self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus) + } + .store(in: &disposeBag) + } + +} + +extension VideoPlaybackService { + func markTransitioning(for toot: Toot) { + guard let videoAttachment = toot.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } + guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } + videoPlayerViewModel.isTransitioning = true + } + + func viewDidDisappear(from viewController: UIViewController?) { + 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 + 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 + } + guard viewModel.videoKind == .video else { continue } + viewModel.pause() + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 08918496..30069ec3 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -27,6 +27,8 @@ class AppContext: ObservableObject { let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! + let videoPlaybackService = VideoPlaybackService() + let overrideTraitCollection = CurrentValueSubject(nil) init() {