Merge branch /develop into feature/reblog
# Conflicts: # Mastodon.xcodeproj/project.pbxproj # Mastodon/Scene/Share/View/Content/StatusView.swift # Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
This commit is contained in:
commit
8c466b67aa
|
@ -25,7 +25,7 @@
|
|||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
|
||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlayer.swift */; };
|
||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
|
||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
|
||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; };
|
||||
|
@ -60,6 +60,7 @@
|
|||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
|
||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; };
|
||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
|
||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
|
||||
|
@ -89,8 +90,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 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.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 */; };
|
||||
|
@ -267,7 +273,7 @@
|
|||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
||||
2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
|
||||
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
|
||||
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
|
||||
|
@ -299,6 +305,7 @@
|
|||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
|
||||
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
|
||||
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
|
||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
|
||||
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -332,6 +339,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 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.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; };
|
||||
|
@ -465,7 +478,6 @@
|
|||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
|
||||
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
|
||||
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
|
||||
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -572,6 +584,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D152A8B25C295CC009AA50C /* StatusView.swift */,
|
||||
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
|
@ -655,8 +668,9 @@
|
|||
DB45FB0425CA87B4005A8AC7 /* APIService */,
|
||||
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
||||
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
|
||||
2D206B8B25F6015000143C56 /* AudioPlayer.swift */,
|
||||
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -679,6 +693,7 @@
|
|||
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */,
|
||||
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
|
||||
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
|
||||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
|
||||
);
|
||||
path = Protocol;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1120,6 +1135,7 @@
|
|||
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */,
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */,
|
||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
|
||||
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
|
@ -1173,6 +1189,8 @@
|
|||
children = (
|
||||
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
|
||||
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */,
|
||||
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */,
|
||||
);
|
||||
path = Container;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1182,6 +1200,7 @@
|
|||
children = (
|
||||
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
|
||||
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
|
||||
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */,
|
||||
);
|
||||
path = ViewModel;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1560,11 +1579,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 */,
|
||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.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 */,
|
||||
|
@ -1612,6 +1633,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 */,
|
||||
|
@ -1622,10 +1644,12 @@
|
|||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
||||
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
||||
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 */,
|
||||
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
|
||||
|
@ -1672,6 +1696,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 */,
|
||||
|
@ -1697,6 +1722,7 @@
|
|||
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
||||
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
||||
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
|
||||
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
|
||||
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
||||
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -34,7 +34,15 @@ 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 +53,15 @@ 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 +85,9 @@ extension StatusSection {
|
|||
}
|
||||
|
||||
extension StatusSection {
|
||||
|
||||
static func configure(
|
||||
cell: StatusTableViewCell,
|
||||
dependency: NeedsDependency,
|
||||
readableLayoutFrame: CGRect?,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||
toot: Toot,
|
||||
|
@ -164,17 +180,53 @@ extension StatusSection {
|
|||
}
|
||||
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
|
||||
let isStatusSensitive = statusItemAttribute.isStatusSensitive
|
||||
cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
|
||||
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
|
||||
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
|
||||
// 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, audioService: dependency.context.audioPlaybackService)
|
||||
} else {
|
||||
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)
|
||||
}()
|
||||
|
||||
cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
|
||||
cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive
|
||||
|
||||
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 playerContainerView = cell.statusView.playerContainerView
|
||||
let playerViewController = playerContainerView.setupPlayer(
|
||||
aspectRatio: videoPlayerViewModel.videoSize,
|
||||
maxSize: playerViewMaxSize,
|
||||
parent: parent
|
||||
)
|
||||
playerViewController.delegate = cell.delegate?.playerViewControllerDelegate
|
||||
playerViewController.player = videoPlayerViewModel.player
|
||||
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
|
||||
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
|
||||
playerContainerView.isHidden = false
|
||||
|
||||
} else {
|
||||
cell.statusView.playerContainerView.playerViewController.player?.pause()
|
||||
cell.statusView.playerContainerView.playerViewController.player = nil
|
||||
}
|
||||
// set poll
|
||||
let poll = (toot.reblog ?? toot).poll
|
||||
StatusSection.configurePoll(
|
||||
|
@ -269,7 +321,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
|
||||
|
@ -313,10 +366,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)
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,6 +37,7 @@ internal enum Asset {
|
|||
internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled")
|
||||
internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight")
|
||||
}
|
||||
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/mediaTypeIndicotor")
|
||||
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
|
||||
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
|
||||
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -50,7 +50,16 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
|
||||
}
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
contentWarningOverlayView.isUserInteractionEnabled = false
|
||||
statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
|
||||
guard let item = item(for: cell, indexPath: nil) else { return }
|
||||
|
||||
|
@ -62,12 +71,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
default:
|
||||
return
|
||||
}
|
||||
|
||||
contentWarningOverlayView.isUserInteractionEnabled = false
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.reloadItems([item])
|
||||
UIView.animate(withDuration: 0.33) {
|
||||
cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil
|
||||
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0
|
||||
contentWarningOverlayView.blurVisualEffectView.effect = nil
|
||||
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0
|
||||
} completion: { _ in
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
@ -30,20 +30,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,
|
||||
|
@ -58,20 +58,48 @@ 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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) {
|
||||
DispatchQueue.main.async {
|
||||
videoPlayerViewModel.didEndDisplaying()
|
||||
}
|
||||
}
|
||||
if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = toot?.mediaAttachments?.contains(currentAudioAttachment) {
|
||||
self.context.audioPlaybackService.pause()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.600",
|
||||
"blue" : "0",
|
||||
"green" : "0",
|
||||
"red" : "0"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -141,7 +141,8 @@ extension HomeTimelineViewController {
|
|||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||
context.audioPlaybackService.viewDidDisappear(from: self)
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
@ -236,7 +237,10 @@ extension HomeTimelineViewController: UITableViewDelegate {
|
|||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||
|
@ -337,5 +341,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 }
|
||||
}
|
||||
|
|
|
@ -81,6 +81,11 @@ extension PublicTimelineViewController {
|
|||
)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||
context.audioPlaybackService.viewDidDisappear(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
@ -114,8 +119,11 @@ extension PublicTimelineViewController: UITableViewDelegate {
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||
}
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
|
@ -204,5 +212,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 }
|
||||
}
|
||||
|
|
|
@ -15,15 +15,12 @@ protocol MosaicImageViewContainerPresentable: class {
|
|||
|
||||
protocol MosaicImageViewContainerDelegate: class {
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
|
||||
}
|
||||
|
||||
final class MosaicImageViewContainer: UIView {
|
||||
|
||||
static let cornerRadius: CGFloat = 4
|
||||
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
|
||||
|
||||
weak var delegate: MosaicImageViewContainerDelegate?
|
||||
|
||||
let container = UIStackView()
|
||||
|
@ -37,14 +34,10 @@ final class MosaicImageViewContainer: UIView {
|
|||
}
|
||||
}
|
||||
}
|
||||
let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect)
|
||||
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect))
|
||||
let contentWarningLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
||||
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
|
||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||
return contentWarningOverlayView
|
||||
}()
|
||||
|
||||
private var containerHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
@ -61,9 +54,16 @@ final class MosaicImageViewContainer: UIView {
|
|||
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
private func _init() {
|
||||
contentWarningOverlayView.delegate = self
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.axis = .horizontal
|
||||
container.distribution = .fillEqually
|
||||
|
@ -77,32 +77,13 @@ extension MosaicImageViewContainer {
|
|||
containerHeightLayoutConstraint
|
||||
])
|
||||
|
||||
// add blur visual effect view in the setup method
|
||||
blurVisualEffectView.layer.masksToBounds = true
|
||||
blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
|
||||
blurVisualEffectView.layer.cornerCurve = .continuous
|
||||
|
||||
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
|
||||
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
|
||||
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
|
||||
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
])
|
||||
|
||||
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
|
||||
])
|
||||
|
||||
blurVisualEffectView.isUserInteractionEnabled = true
|
||||
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:)))
|
||||
blurVisualEffectView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -117,9 +98,9 @@ extension MosaicImageViewContainer {
|
|||
container.subviews.forEach { subview in
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
blurVisualEffectView.removeFromSuperview()
|
||||
blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect
|
||||
vibrancyVisualEffectView.alpha = 1.0
|
||||
contentWarningOverlayView.removeFromSuperview()
|
||||
contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect
|
||||
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
|
||||
imageViews = []
|
||||
|
||||
container.spacing = 1
|
||||
|
@ -140,7 +121,7 @@ extension MosaicImageViewContainer {
|
|||
let imageView = UIImageView()
|
||||
imageViews.append(imageView)
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
|
||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
||||
|
@ -155,13 +136,12 @@ extension MosaicImageViewContainer {
|
|||
containerHeightLayoutConstraint.constant = floor(rect.height)
|
||||
containerHeightLayoutConstraint.isActive = true
|
||||
|
||||
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(blurVisualEffectView)
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||
blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
||||
blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||
blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
||||
])
|
||||
|
||||
return imageView
|
||||
|
@ -193,7 +173,7 @@ extension MosaicImageViewContainer {
|
|||
self.imageViews.append(contentsOf: imageViews)
|
||||
imageViews.forEach { imageView in
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
|
||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
}
|
||||
|
@ -242,13 +222,12 @@ extension MosaicImageViewContainer {
|
|||
}
|
||||
}
|
||||
|
||||
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(blurVisualEffectView)
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
return imageViews
|
||||
|
@ -260,7 +239,7 @@ extension MosaicImageViewContainer {
|
|||
|
||||
@objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView)
|
||||
delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
@objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
//
|
||||
// PlayerContainerView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/10.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import UIKit
|
||||
|
||||
protocol PlayerContainerViewDelegate: class {
|
||||
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
}
|
||||
|
||||
final class PlayerContainerView: UIView {
|
||||
static let cornerRadius: CGFloat = 8
|
||||
|
||||
private let container = UIView()
|
||||
private let touchBlockingView = TouchBlockingView()
|
||||
private var containerHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||
return contentWarningOverlayView
|
||||
}()
|
||||
|
||||
let playerViewController = AVPlayerViewController()
|
||||
|
||||
let mediaTypeIndicotorLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = .white
|
||||
label.textAlignment = .right
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let mediaTypeIndicotorView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let rect = CGRect(x: 0, y: 0, width: 47, height: 50)
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topLeft], cornerRadii: CGSize(width: 50, height: 50))
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = rect
|
||||
maskLayer.path = path.cgPath
|
||||
view.layer.mask = maskLayer
|
||||
return view
|
||||
}()
|
||||
|
||||
weak var delegate: PlayerContainerViewDelegate?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerContainerView {
|
||||
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,
|
||||
])
|
||||
|
||||
// will not influence full-screen playback
|
||||
playerViewController.view.layer.masksToBounds = true
|
||||
playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius
|
||||
playerViewController.view.layer.cornerCurve = .continuous
|
||||
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
contentWarningOverlayView.delegate = self
|
||||
|
||||
// mediaType
|
||||
addSubview(mediaTypeIndicotorView)
|
||||
NSLayoutConstraint.activate([
|
||||
mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25),
|
||||
mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47)
|
||||
])
|
||||
|
||||
mediaTypeIndicotorView.addSubview(mediaTypeIndicotorLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
mediaTypeIndicotorLabel.topAnchor.constraint(equalTo: mediaTypeIndicotorView.topAnchor),
|
||||
mediaTypeIndicotorLabel.leadingAnchor.constraint(equalTo: mediaTypeIndicotorView.leadingAnchor),
|
||||
mediaTypeIndicotorLabel.bottomAnchor.constraint(equalTo: mediaTypeIndicotorView.bottomAnchor),
|
||||
mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: mediaTypeIndicotorLabel.trailingAnchor, constant: 8)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentWarningOverlayViewDelegate
|
||||
extension PlayerContainerView: ContentWarningOverlayViewDelegate {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerContainerView {
|
||||
func reset() {
|
||||
// note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing
|
||||
|
||||
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
|
||||
|
||||
bringSubviewToFront(mediaTypeIndicotorView)
|
||||
|
||||
return playerViewController
|
||||
}
|
||||
|
||||
func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont {
|
||||
let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
|
||||
guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont }
|
||||
let roundedFont = UIFont(descriptor: descriptor, size: fontSize)
|
||||
return roundedFont
|
||||
}
|
||||
func setMediaKind(kind: VideoPlayerViewModel.Kind) {
|
||||
let fontSize: CGFloat = 18
|
||||
|
||||
switch kind {
|
||||
case .gif:
|
||||
mediaTypeIndicotorLabel.font = roundedFont(weight: .heavy, fontSize: fontSize)
|
||||
mediaTypeIndicotorLabel.text = "GIF"
|
||||
case .video:
|
||||
let configuration = UIImage.SymbolConfiguration(font: roundedFont(weight: .regular, fontSize: fontSize))
|
||||
let image = UIImage(systemName: "video.fill", withConfiguration: configuration)!
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image.withTintColor(.white)
|
||||
mediaTypeIndicotorLabel.attributedText = NSAttributedString(attachment: attachment)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// ContentWarningOverlayView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/11.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
protocol ContentWarningOverlayViewDelegate: class {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView)
|
||||
}
|
||||
|
||||
class ContentWarningOverlayView: UIView {
|
||||
|
||||
static let cornerRadius: CGFloat = 4
|
||||
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
|
||||
let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
|
||||
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
|
||||
|
||||
let contentWarningLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
||||
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
|
||||
weak var delegate: ContentWarningOverlayViewDelegate?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentWarningOverlayView {
|
||||
private func _init() {
|
||||
backgroundColor = .clear
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// add blur visual effect view in the setup method
|
||||
blurVisualEffectView.layer.masksToBounds = true
|
||||
blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
blurVisualEffectView.layer.cornerCurve = .continuous
|
||||
|
||||
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
|
||||
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
|
||||
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
|
||||
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
|
||||
])
|
||||
|
||||
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
|
||||
])
|
||||
|
||||
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(blurVisualEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor),
|
||||
blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentWarningOverlayView {
|
||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.contentWarningOverlayViewDidPressed(self)
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ import AlamofireImage
|
|||
|
||||
protocol StatusViewDelegate: class {
|
||||
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
||||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||
}
|
||||
|
||||
|
@ -157,6 +158,8 @@ final class StatusView: UIView {
|
|||
return imageView
|
||||
}()
|
||||
|
||||
let playerContainerView = PlayerContainerView()
|
||||
|
||||
let audioView: AudioContainerView = {
|
||||
let audioView = AudioContainerView()
|
||||
return audioView
|
||||
|
@ -351,6 +354,7 @@ extension StatusView {
|
|||
pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
|
||||
|
||||
// audio
|
||||
audioView.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusContainerStackView.addArrangedSubview(audioView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -358,6 +362,8 @@ extension StatusView {
|
|||
audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor),
|
||||
audioView.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
// video gif
|
||||
statusContainerStackView.addArrangedSubview(playerContainerView)
|
||||
|
||||
// action toolbar container
|
||||
containerStackView.addArrangedSubview(actionToolbarContainer)
|
||||
|
@ -368,12 +374,15 @@ extension StatusView {
|
|||
pollTableView.isHidden = true
|
||||
pollStatusStackView.isHidden = true
|
||||
audioView.isHidden = true
|
||||
|
||||
playerContainerView.isHidden = true
|
||||
|
||||
avatarStackedContainerButton.isHidden = true
|
||||
contentWarningBlurContentImageView.isHidden = true
|
||||
statusContentWarningContainerStackView.isHidden = true
|
||||
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
||||
|
||||
playerContainerView.delegate = self
|
||||
|
||||
contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside)
|
||||
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
@ -424,6 +433,13 @@ extension StatusView {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - PlayerContainerViewDelegate
|
||||
extension StatusView: PlayerContainerViewDelegate {
|
||||
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
delegate?.statusView(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarConfigurableView
|
||||
extension StatusView: AvatarConfigurableView {
|
||||
static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize }
|
||||
|
@ -491,7 +507,7 @@ struct StatusView_Previews: PreviewProvider {
|
|||
imageView.image = images[i]
|
||||
}
|
||||
statusView.statusMosaicImageViewContainer.isHidden = false
|
||||
statusView.statusMosaicImageViewContainer.blurVisualEffectView.isHidden = true
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
||||
statusView.isStatusTextSensitive = false
|
||||
return statusView
|
||||
}
|
||||
|
|
|
@ -16,9 +16,15 @@ 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, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
|
||||
|
||||
|
@ -26,6 +32,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
|
||||
|
@ -43,6 +56,8 @@ final class StatusTableViewCell: UITableViewCell {
|
|||
statusView.isStatusTextSensitive = false
|
||||
statusView.cleanUpContentWarning()
|
||||
statusView.pollTableView.dataSource = nil
|
||||
statusView.playerContainerView.reset()
|
||||
statusView.playerContainerView.isHidden = true
|
||||
disposeBag.removeAll()
|
||||
observations.removeAll()
|
||||
}
|
||||
|
@ -184,6 +199,10 @@ extension StatusTableViewCell: StatusViewDelegate {
|
|||
delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button)
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
delegate?.statusTableViewCell(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) {
|
||||
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
|
||||
}
|
||||
|
@ -197,8 +216,8 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate {
|
|||
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index)
|
||||
}
|
||||
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
|
||||
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,60 +12,64 @@ import UIKit
|
|||
class AudioContainerViewModel {
|
||||
static func configure(
|
||||
cell: StatusTableViewCell,
|
||||
audioAttachment: Attachment
|
||||
audioAttachment: Attachment,
|
||||
audioService: AudioPlaybackService
|
||||
) {
|
||||
guard let duration = audioAttachment.meta?.original?.duration else { return }
|
||||
let audioView = cell.statusView.audioView
|
||||
audioView.timeLabel.text = duration.asString(style: .positional)
|
||||
|
||||
audioView.playButton.publisher(for: .touchUpInside)
|
||||
.sink { _ in
|
||||
if audioAttachment === AudioPlayer.shared.attachment {
|
||||
if AudioPlayer.shared.isPlaying() {
|
||||
AudioPlayer.shared.pause()
|
||||
.sink { [weak audioService] _ in
|
||||
guard let audioService = audioService else { return }
|
||||
if audioAttachment === audioService.attachment {
|
||||
if audioService.isPlaying() {
|
||||
audioService.pause()
|
||||
} else {
|
||||
AudioPlayer.shared.resume()
|
||||
audioService.resume()
|
||||
}
|
||||
if AudioPlayer.shared.currentTimeSubject.value == 0 {
|
||||
AudioPlayer.shared.playAudio(audioAttachment: audioAttachment)
|
||||
if audioService.currentTimeSubject.value == 0 {
|
||||
audioService.playAudio(audioAttachment: audioAttachment)
|
||||
}
|
||||
} else {
|
||||
AudioPlayer.shared.playAudio(audioAttachment: audioAttachment)
|
||||
audioService.playAudio(audioAttachment: audioAttachment)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
audioView.slider.publisher(for: .valueChanged)
|
||||
.sink { slider in
|
||||
.sink { [weak audioService] slider in
|
||||
guard let audioService = audioService else { return }
|
||||
let slider = slider as! UISlider
|
||||
let time = Double(slider.value) * duration
|
||||
AudioPlayer.shared.seekToTime(time: time)
|
||||
audioService.seekToTime(time: time)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
self.observePlayer(cell: cell, audioAttachment: audioAttachment)
|
||||
if audioAttachment != AudioPlayer.shared.attachment {
|
||||
observePlayer(cell: cell, audioAttachment: audioAttachment, audioService: audioService)
|
||||
if audioAttachment != audioService.attachment {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
}
|
||||
}
|
||||
|
||||
static func observePlayer(
|
||||
cell: StatusTableViewCell,
|
||||
audioAttachment: Attachment
|
||||
audioAttachment: Attachment,
|
||||
audioService: AudioPlaybackService
|
||||
) {
|
||||
let audioView = cell.statusView.audioView
|
||||
var lastCurrentTimeSubject: TimeInterval?
|
||||
AudioPlayer.shared.currentTimeSubject
|
||||
audioService.currentTimeSubject
|
||||
.throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true)
|
||||
.compactMap { time -> (TimeInterval, Float)? in
|
||||
.compactMap { [weak audioService] time -> (TimeInterval, Float)? in
|
||||
defer {
|
||||
lastCurrentTimeSubject = time
|
||||
}
|
||||
guard audioAttachment === AudioPlayer.shared.attachment else { return nil }
|
||||
guard audioAttachment === audioService?.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))
|
||||
}
|
||||
|
@ -74,10 +78,10 @@ class AudioContainerViewModel {
|
|||
audioView.slider.setValue(progress, animated: true)
|
||||
})
|
||||
.store(in: &cell.disposeBag)
|
||||
AudioPlayer.shared.playbackState
|
||||
audioService.playbackState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { playbackState in
|
||||
if audioAttachment === AudioPlayer.shared.attachment {
|
||||
if audioAttachment === audioService.attachment {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState)
|
||||
} else {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
//
|
||||
// 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<AnyCancellable>()
|
||||
|
||||
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.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<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 }
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -10,8 +10,12 @@ import Combine
|
|||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
final class AudioPlayer: NSObject {
|
||||
final class AudioPlaybackService: NSObject {
|
||||
|
||||
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var player = AVPlayer()
|
||||
|
@ -21,19 +25,16 @@ final class AudioPlayer: NSObject {
|
|||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
// MARK: - singleton
|
||||
public static let shared = AudioPlayer()
|
||||
|
||||
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
|
||||
|
||||
private override init() {
|
||||
override init() {
|
||||
super.init()
|
||||
addObserver()
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlayer {
|
||||
extension AudioPlaybackService {
|
||||
func playAudio(audioAttachment: Attachment) {
|
||||
guard let url = URL(string: audioAttachment.url) else {
|
||||
return
|
||||
|
@ -45,6 +46,7 @@ extension AudioPlayer {
|
|||
return
|
||||
}
|
||||
|
||||
notifyWillPlayAudioNotification()
|
||||
if audioAttachment == attachment {
|
||||
if self.playbackState.value == .stopped {
|
||||
self.seekToTime(time: .zero)
|
||||
|
@ -83,6 +85,12 @@ extension AudioPlayer {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.pauseIfNeed()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
|
@ -111,12 +119,22 @@ 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 notifyWillPlayAudioNotification() {
|
||||
NotificationCenter.default.post(name: AudioPlaybackService.appWillPlayAudioNotification, object: nil)
|
||||
}
|
||||
func isPlaying() -> Bool {
|
||||
return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing
|
||||
return playbackState.value == .readyToPlay || playbackState.value == .playing
|
||||
}
|
||||
func resume() {
|
||||
notifyWillPlayAudioNotification()
|
||||
player.play()
|
||||
playbackState.value = .playing
|
||||
}
|
||||
|
@ -125,8 +143,19 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlaybackService {
|
||||
func viewDidDisappear(from viewController: UIViewController?) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
pause()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// ViedeoPlaybackService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/10.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
NotificationCenter.default.publisher(for: AudioPlaybackService.appWillPlayAudioNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.pauseWhenPlayAudio()
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,9 @@ class AppContext: ObservableObject {
|
|||
let documentStore: DocumentStore
|
||||
private var documentStoreSubscription: AnyCancellable!
|
||||
|
||||
let videoPlaybackService = VideoPlaybackService()
|
||||
let audioPlaybackService = AudioPlaybackService()
|
||||
|
||||
let overrideTraitCollection = CurrentValueSubject<UITraitCollection?, Never>(nil)
|
||||
|
||||
init() {
|
||||
|
|
Loading…
Reference in New Issue