feature: video & gifv support

This commit is contained in:
sunxiaojian 2021-03-10 14:36:28 +08:00
parent 1b654dcabc
commit e1143b0ce4
15 changed files with 619 additions and 13 deletions

View File

@ -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 = "<group>"; };
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = "<group>"; };
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicPlayerView.swift; sourceTree = "<group>"; };
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = "<group>"; };
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>";
@ -673,6 +684,7 @@
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */,
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
);
path = Protocol;
sourceTree = "<group>";
@ -1113,6 +1125,7 @@
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
2D206B8525F5FB0900143C56 /* Double.swift */,
2D206B9125F60EA700143C56 /* UIControl.swift */,
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -1165,6 +1178,8 @@
children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */,
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */,
);
path = Container;
sourceTree = "<group>";
@ -1174,6 +1189,7 @@
children = (
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -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;

View File

@ -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"
}
},
{

View File

@ -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<Date, Never>,
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<Date, Never>
) {
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)

View File

@ -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 ""
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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 }
}

View File

@ -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 }
}

View File

@ -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
}
}

View File

@ -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<UITouch>, with event: UIEvent?) {
// Blocking responder chain by not call super
// The subviews in this view will received touch event but superview not
}
}

View File

@ -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

View File

@ -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()
}

View File

@ -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<AnyCancellable>()
// 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<Play, Never>()
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<AVPlayer.TimeControlStatus, Never>(.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()
}
}

View File

@ -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<AnyCancellable>()
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()
}
}
}

View File

@ -27,6 +27,8 @@ class AppContext: ObservableObject {
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!
let videoPlaybackService = VideoPlaybackService()
let overrideTraitCollection = CurrentValueSubject<UITraitCollection?, Never>(nil)
init() {