diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index a2ad3b5a8..43e728283 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -176,7 +176,7 @@ public extension Toot { func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -188,7 +188,7 @@ public extension Toot { func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { @@ -200,7 +200,7 @@ public extension Toot { func update(muted: Bool, mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { @@ -212,7 +212,7 @@ public extension Toot { func update(bookmarked: Bool, mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { diff --git a/Localization/app.json b/Localization/app.json index 5d86f1f81..0a5e5cdc7 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -36,7 +36,7 @@ "open_in_safari": "Open in Safari" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", "user_replied_to": "Replied to %s", "show_post": "Show Post", "status_content_warning": "content warning", @@ -45,11 +45,11 @@ "vote": "Vote", "vote_count": { "single": "%d vote", - "multiple": "%d votes", + "multiple": "%d votes" }, "voter_count": { "single": "%d voter", - "multiple": "%d voters", + "multiple": "%d voters" }, "time_left": "%s left", "closed": "Closed" diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6332da884..be7d02db4 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 */; }; @@ -136,6 +142,8 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -149,6 +157,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; }; @@ -194,6 +203,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -269,7 +279,7 @@ 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; - 2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; @@ -301,6 +311,7 @@ 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Toot.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; + 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -334,6 +345,12 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; + 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; }; + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -388,6 +405,8 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -400,6 +419,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; @@ -447,6 +467,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -469,7 +490,6 @@ 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -576,6 +596,7 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, + 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, ); path = Content; sourceTree = ""; @@ -660,8 +681,9 @@ DB45FB0425CA87B4005A8AC7 /* APIService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, - 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, + 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, ); path = Service; @@ -687,6 +709,7 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, ); path = Protocol; sourceTree = ""; @@ -934,6 +957,7 @@ DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, @@ -1128,6 +1152,8 @@ 2D206B7F25F5F45E00143C56 /* UIImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, ); path = Extension; sourceTree = ""; @@ -1180,6 +1206,9 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; sourceTree = ""; @@ -1189,6 +1218,7 @@ children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1196,6 +1226,7 @@ DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, ); path = Control; @@ -1566,11 +1597,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 */, @@ -1621,6 +1654,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 */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, @@ -1632,12 +1666,15 @@ 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 */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, @@ -1660,6 +1697,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, @@ -1682,6 +1720,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 */, @@ -1701,12 +1740,15 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..9cf0f1a07 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 16 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index a6b9397ad..744e873cf 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -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, toot: Toot, @@ -91,13 +107,23 @@ extension StatusSection { } .store(in: &cell.disposeBag) - // set name username avatar + // set name username cell.statusView.nameLabel.text = { let author = (toot.reblog ?? toot).author return author.displayName.isEmpty ? author.username : author.displayName }() cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + // set avatar + if let reblog = toot.reblog { + cell.statusView.avatarButton.isHidden = true + cell.statusView.avatarStackedContainerButton.isHidden = false + cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) + cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + } else { + cell.statusView.avatarButton.isHidden = false + cell.statusView.avatarStackedContainerButton.isHidden = true + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + } // set text cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) @@ -159,20 +185,74 @@ 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 + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive // 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) + if videoPlayerViewModel.videoKind == .gif { + playerContainerView.setMediaIndicator(isHidden: false) + } else { + videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in + UIView.animate(withDuration: 0.33) { + switch timeControlStatus { + case .playing: + playerContainerView.setMediaIndicator(isHidden: true) + case .paused, .waitingToPlayAtSpecifiedRate: + playerContainerView.setMediaIndicator(isHidden: false) + @unknown default: + assertionFailure() + } + } + } + .store(in: &cell.disposeBag) + } + 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.configure( + StatusSection.configurePoll( cell: cell, poll: poll, requestUserID: requestUserID, @@ -186,7 +266,7 @@ extension StatusSection { } receiveValue: { change in guard case .update(let object) = change.changeType, let newPoll = object as? Poll else { return } - StatusSection.configure( + StatusSection.configurePoll( cell: cell, poll: newPoll, requestUserID: requestUserID, @@ -198,19 +278,7 @@ extension StatusSection { } // toolbar - let replyCountTitle: String = { - let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - - let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCountTitle: String = { - let count = (toot.reblog ?? toot).favouritesCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) // set date let createdAt = (toot.reblog ?? toot).createdAt @@ -228,18 +296,15 @@ extension StatusSection { // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let newToot = object as? Toot else { return } - let targetToot = newToot.reblog ?? newToot - let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCount = targetToot.favouritesCount.intValue - let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount) - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount) + let toot = object as? Toot else { return } + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) } .store(in: &cell.disposeBag) } - + static func configureHeader( cell: StatusTableViewCell, toot: Toot @@ -250,7 +315,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) + return L10n.Common.Controls.Status.userReblogged(name) }() } else if let replyTo = toot.replyTo { cell.statusView.headerContainerStackView.isHidden = false @@ -265,7 +330,38 @@ extension StatusSection { } } - static func configure( + static func configureActionToolBar( + cell: StatusTableViewCell, + toot: Toot, + requestUserID: String + ) { + let toot = toot.reblog ?? toot + + // set reply + let replyCountTitle: String = { + let count = toot.repliesCount?.intValue ?? 0 + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) + // set reblog + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let reblogCountTitle: String = { + let count = toot.reblogsCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged + // set like + let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let favoriteCountTitle: String = { + let count = toot.favouritesCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + } + + static func configurePoll( cell: StatusTableViewCell, poll: Poll?, requestUserID: String, @@ -273,7 +369,8 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, - let managedObjectContext = poll.managedObjectContext else { + let managedObjectContext = poll.managedObjectContext + else { cell.statusView.pollTableView.isHidden = true cell.statusView.pollStatusStackView.isHidden = true cell.statusView.pollVoteButton.isHidden = true @@ -317,10 +414,10 @@ extension StatusSection { cell.statusView.pollTableView.allowsSelection = !poll.expired let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + (option.votedBy ?? Set()).map(\.id).contains(requestUserID) } let didVotedLocal = !votedOptions.isEmpty - let didVotedRemote = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) cell.statusView.pollVoteButton.isEnabled = didVotedLocal cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) diff --git a/Mastodon/Extension/AVPlayer.swift b/Mastodon/Extension/AVPlayer.swift new file mode 100644 index 000000000..3e9c06cc2 --- /dev/null +++ b/Mastodon/Extension/AVPlayer.swift @@ -0,0 +1,22 @@ +// +// AVPlayer.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit + +// MARK: - CustomDebugStringConvertible +extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .paused: return "paused" + case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate" + case .playing: return "playing" + @unknown default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Extension/NSManagedObjectContext.swift b/Mastodon/Extension/NSManagedObjectContext.swift new file mode 100644 index 000000000..9c569a8f4 --- /dev/null +++ b/Mastodon/Extension/NSManagedObjectContext.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + func safeFetch(_ request: NSFetchRequest) -> [T] where T : NSFetchRequestResult { + do { + return try fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f68170460..f0222a9e0 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -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") @@ -77,6 +78,7 @@ internal enum Asset { internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") + internal static let systemGreen = ColorAsset(name: "Colors/system.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Welcome { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 74595d5b6..ec1a7012d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -76,9 +76,9 @@ internal enum L10n { internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// content warning internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// %@ reblogged + internal static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } /// Replied to %@ internal static func userRepliedTo(_ p1: Any) -> String { diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6c51d576c..6391066e1 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -23,7 +23,13 @@ extension AvatarConfigurableView { public func configure(with configuration: AvatarConfigurableViewConfiguration) { let placeholderImage: UIImage = { let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill) - return placeholderImage.af.imageRoundedIntoCircle() + if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { + return placeholderImage + .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) + .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) + } else { + return placeholderImage.af.imageRoundedIntoCircle() + } }() // cancel previous task @@ -65,7 +71,8 @@ extension AvatarConfigurableView { ) avatarImageView.layer.masksToBounds = true avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarImageView.layer.cornerCurve = .circular + avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( @@ -92,7 +99,7 @@ extension AvatarConfigurableView { ) avatarButton.layer.masksToBounds = true avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarButton.layer.cornerCurve = .continuous + avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift new file mode 100644 index 000000000..e52fdc059 --- /dev/null +++ b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift @@ -0,0 +1,21 @@ +// +// NeedsDependency+AVPlayerViewControllerDelegate.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import Foundation +import AVKit + +extension NeedsDependency where Self: AVPlayerViewControllerDelegate { + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true + } + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 12dfeadf5..f3d31ff33 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -16,6 +16,10 @@ import ActiveLabel // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) + } + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) } @@ -46,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 } @@ -58,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) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 93f627c09..89ae8e6eb 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -5,11 +5,11 @@ // Created by MainasuK Cirno on 2021-3-3. // -import os.log -import UIKit import Combine import CoreDataStack import MastodonSDK +import os.log +import UIKit extension StatusTableViewCellDelegate where Self: StatusProvider { // TODO: @@ -17,6 +17,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // update poll when toot appear let now = Date() var pollID: Mastodon.Entity.Poll.ID? toot(for: cell, indexPath: indexPath) @@ -29,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, @@ -57,20 +58,49 @@ 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 } + let toot = toot?.reblog ?? toot + 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 {} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 894461566..cab16e68c 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -16,6 +16,7 @@ import ActiveLabel enum StatusProviderFacade { } + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { @@ -56,10 +57,9 @@ extension StatusProviderFacade { toot .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot else { return nil } + guard let toot = toot?.reblog ?? toot else { return nil } let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let targetToot = (toot.reblog ?? toot) - let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() return (toot.objectID, favoriteKind) @@ -120,6 +120,115 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + + static func responseToStatusReblogAction(provider: StatusProvider) { + _responseToStatusReblogAction( + provider: provider, + toot: provider.toot() + ) + } + + static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusReblogAction( + provider: provider, + toot: provider.toot(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future) { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return + } + + // prepare current user infos + guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { + assertionFailure() + return + } + let mastodonUserID = activeMastodonAuthenticationBox.userID + assert(_currentMastodonUser.id == mastodonUserID) + let mastodonUserObjectID = _currentMastodonUser.objectID + + guard let context = provider.context else { return } + + // haptic feedback generator + let generator = UIImpactFeedbackGenerator(style: .light) + let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + + toot + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in + guard let toot = toot?.reblog ?? toot else { return nil } + let reblogKind: Mastodon.API.Reblog.ReblogKind = { + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) + }() + return (toot.objectID, reblogKind) + } + .map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in + return context.apiService.reblog( + tootObjectID: tootObjectID, + mastodonUserObjectID: mastodonUserObjectID, + reblogKind: reblogKind + ) + .map { tootID in (tootID, reblogKind) } + .eraseToAnyPublisher() + } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .switchToLatest() + .receive(on: DispatchQueue.main) + .handleEvents { _ in + generator.prepare() + responseFeedbackGenerator.prepare() + } receiveOutput: { _, reblogKind in + generator.impactOccurred() + switch reblogKind { + case .reblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") + case .undoReblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") + } + } receiveCompletion: { completion in + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } + .map { tootID, reblogKind in + return context.apiService.reblog( + statusID: tootID, + reblogKind: reblogKind, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak provider] completion in + guard let provider = provider else { return } + if provider.view.window != nil { + responseFeedbackGenerator.impactOccurred() + } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { response in + // do nothing + } + .store(in: &provider.disposeBag) + } + +} + extension StatusProviderFacade { enum Target { case toot diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json new file mode 100644 index 000000000..e9c583c0d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json @@ -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 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json new file mode 100644 index 000000000..8716dcb74 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.604", + "green" : "0.741", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a1a6db78e..4eacbdca4 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -30,7 +30,7 @@ "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0937e1fb4..9f2b4e720 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -45,10 +45,18 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToTopGapAction(action) }), + UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogToot(action) + }), UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.moveToFirstPollToot(action) }), + UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioToot(action) + }), // UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in // guard let self = self else { return } // self.moveToFirstReplyToot(action) @@ -101,6 +109,26 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstReblogToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.toot.reblog != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found reblog toot") + } + } + @objc private func moveToFirstPollToot(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() @@ -122,6 +150,27 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstAudioToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found audio toot") + } + } + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index db285c2c3..9702fbe28 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -142,7 +142,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) { @@ -237,7 +238,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: - UITableViewDataSourcePrefetching @@ -345,5 +349,21 @@ extension HomeTimelineViewController: ScrollViewContainer { } +// MARK: - AVPlayerViewControllerDelegate +extension HomeTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension HomeTimelineViewController: StatusTableViewCellDelegate { } +extension HomeTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index ddca93fe7..abe46257c 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -82,6 +82,11 @@ extension PublicTimelineViewController { ) } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) + } } // MARK: - UIScrollViewDelegate @@ -115,8 +120,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 } @@ -212,5 +220,21 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat } } +// MARK: - AVPlayerViewControllerDelegate +extension PublicTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension PublicTimelineViewController: StatusTableViewCellDelegate { } +extension PublicTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 5240d4e2c..b3c03a46b 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -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,6 +54,12 @@ final class MosaicImageViewContainer: UIView { } +extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + extension MosaicImageViewContainer { private func _init() { @@ -77,32 +76,7 @@ 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) - 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.isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:))) - blurVisualEffectView.addGestureRecognizer(tapGesture) + contentWarningOverlayView.delegate = self } } @@ -117,9 +91,10 @@ 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 + contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] container.spacing = 1 @@ -140,7 +115,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 +130,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 +167,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 +216,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 +233,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) { diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift new file mode 100644 index 000000000..12f822986 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -0,0 +1,138 @@ +// +// PlayerContainerView+MediaTypeIndicotorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import UIKit + +extension PlayerContainerView { + + final class MediaTypeIndicotorView: UIView { + + static let indicatorViewSize = CGSize(width: 47, height: 25) + + let maskLayer = CAShapeLayer() + + let label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .right + label.adjustsFontSizeToFitWidth = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.width, y: bounds.height)) + path.addLine(to: CGPoint(x: bounds.width, y: 0)) + path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0)) + path.addCurve( + to: CGPoint(x: 0, y: bounds.height), + controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0), + controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3) + ) + path.close() + + maskLayer.frame = bounds + maskLayer.path = path.cgPath + layer.mask = maskLayer + + layer.cornerRadius = PlayerContainerView.cornerRadius + layer.maskedCorners = [.layerMaxXMaxYCorner] + layer.cornerCurve = .continuous + } + } + +} + +extension PlayerContainerView.MediaTypeIndicotorView { + + private func _init() { + backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color + layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6) + + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + private static 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: + label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize) + label.text = "GIF" + case .video: + let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize)) + let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.white) + label.attributedText = NSAttributedString(attachment: attachment) + } + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .gif) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .video) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift new file mode 100644 index 000000000..3a42560a9 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -0,0 +1,158 @@ +// +// 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 mediaTypeIndicotorView = MediaTypeIndicotorView() + let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView() + + 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 + + // mediaType + mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false + playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) + NSLayoutConstraint.activate([ + mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), + mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + ]) + + 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 + + mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false + contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) + NSLayoutConstraint.activate([ + mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + ]) + } +} + +// 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 setMediaKind(kind: VideoPlayerViewModel.Kind) { + mediaTypeIndicotorView.setMediaKind(kind: kind) + mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind) + } + + func setMediaIndicator(isHidden: Bool) { + mediaTypeIndicotorView.alpha = isHidden ? 0 : 1 + mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1 + } + +} diff --git a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift new file mode 100644 index 000000000..b86137f1c --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift @@ -0,0 +1,34 @@ +// +// TouchBlockingView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import UIKit + +final class TouchBlockingView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TouchBlockingView { + + private func _init() { + isUserInteractionEnabled = true + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + // Blocking responder chain by not call super + // The subviews in this view will received touch event but superview not + } +} diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift new file mode 100644 index 000000000..ec2607e25 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -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) + } +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index f908ce5c1..7b2971fa6 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -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) } @@ -64,7 +65,7 @@ final class StatusView: UIView { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) label.textColor = Asset.Colors.Label.secondary.color - label.text = "Bob boosted" + label.text = "Bob reblogged" return label }() @@ -76,6 +77,7 @@ final class StatusView: UIView { button.setImage(placeholderImage, for: .normal) return button }() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() let nameLabel: UILabel = { let label = UILabel() @@ -172,6 +174,8 @@ final class StatusView: UIView { return imageView }() + let playerContainerView = PlayerContainerView() + let audioView: AudioContainerView = { let audioView = AudioContainerView() return audioView @@ -254,6 +258,14 @@ extension StatusView { avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), ]) + avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarStackedContainerButton) + NSLayoutConstraint.activate([ + avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) // author meta container: [title container | subtitle container] let authorMetaContainerStackView = UIStackView() @@ -358,6 +370,7 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + // audio audioView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(audioView) NSLayoutConstraint.activate([ @@ -365,6 +378,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) @@ -375,11 +390,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) } @@ -430,6 +449,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 } @@ -445,6 +471,7 @@ import SwiftUI struct StatusView_Previews: PreviewProvider { static let avatarFlora = UIImage(named: "tiraya-adam") + static let avatarMarkus = UIImage(named: "markus-spiske") static var previews: some View { Group { @@ -459,6 +486,49 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Normal") + UIViewPreview(width: 375) { + let statusView = StatusView() + statusView.headerContainerStackView.isHidden = false + statusView.avatarButton.isHidden = true + statusView.avatarStackedContainerButton.isHidden = false + statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarMarkus + ) + ) + return statusView + } + .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Reblog") + UIViewPreview(width: 375) { + let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) + statusView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.headerContainerStackView.isHidden = false + let images = MosaicImageView_Previews.images + let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] + } + statusView.statusMosaicImageViewContainer.isHidden = false + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + statusView.isStatusTextSensitive = false + return statusView + } + .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Image Meida") UIViewPreview(width: 375) { let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) statusView.configure( @@ -482,6 +552,7 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Content Sensitive") } } diff --git a/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift new file mode 100644 index 000000000..1e4bd24fe --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift @@ -0,0 +1,177 @@ +// +// AvatarStackContainerButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import UIKit +final class AvatarStackedImageView: UIImageView { } + +// MARK: - AvatarConfigurableView +extension AvatarStackedImageView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) } + static var configurableAvatarImageCornerRadius: CGFloat { 4 } + var configurableAvatarImageView: UIImageView? { self } + var configurableAvatarButton: UIButton? { nil } +} + +final class AvatarStackContainerButton: UIControl { + + static let containerSize = CGSize(width: 42, height: 42) + static let maskOffset: CGFloat = 2 + + // UIControl.Event - Application: 0x0F000000 + static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000 + var primaryActionState: UIControl.State = .normal + + let topLeadingAvatarStackedImageView = AvatarStackedImageView() + let bottomTrailingAvatarStackedImageView = AvatarStackedImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AvatarStackContainerButton { + + private func _init() { + topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topLeadingAvatarStackedImageView) + NSLayoutConstraint.activate([ + topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor), + topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomTrailingAvatarStackedImageView) + NSLayoutConstraint.activate([ + bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + // mask topLeadingAvatarStackedImageView + let offset: CGFloat = 2 + let path: CGPath = { + let path = CGMutablePath() + path.addRect(CGRect(origin: .zero, size: AvatarStackedImageView.configurableAvatarImageSize)) + let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1 + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset), + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + return path + }() + let maskShapeLayer = CAShapeLayer() + maskShapeLayer.backgroundColor = UIColor.black.cgColor + maskShapeLayer.fillRule = .evenOdd + maskShapeLayer.path = path + topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer + + topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + } + + override var intrinsicContentSize: CGSize { + return AvatarStackContainerButton.containerSize + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.beginTracking(touch, with: event) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.continueTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + defer { updateAppearance() } + resetState() + + if let touch = touch { + if AvatarStackContainerButton.isTouching(touch, view: self, event: event) { + sendActions(for: AvatarStackContainerButton.primaryAction) + } else { + // do nothing + } + } + + super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + defer { updateAppearance() } + + resetState() + super.cancelTracking(with: event) + } + +} + +extension AvatarStackContainerButton { + + private func updateAppearance() { + topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + } + + private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool { + let location = touch.location(in: view) + return view.point(inside: location, with: event) + } + + private func resetState() { + primaryActionState = .normal + } + + private func updateState(touch: UITouch, event: UIEvent?) { + primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AvatarStackContainerButton_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 42) { + let avatarStackContainerButton = AvatarStackContainerButton() + avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42), + avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42), + ]) + return avatarStackContainerButton + } + .previewLayout(.fixed(width: 42, height: 42)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 13c3afba4..75845d6a7 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -16,15 +16,29 @@ 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, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) 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 +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() } @@ -183,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) } @@ -196,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) } } @@ -207,8 +227,8 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) { - + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 02f60d518..daaa607d9 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -10,7 +10,7 @@ import UIKit protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -19,12 +19,16 @@ protocol ActionToolbarContainerDelegate: class { final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let retootButton = HitTestExpandedButton() - let starButton = HitTestExpandedButton() + let reblogButton = HitTestExpandedButton() + let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isStarButtonHighlight: Bool = false { - didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) } + var isReblogButtonHighlight: Bool = false { + didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) } + } + + var isFavoriteButtonHighlight: Bool = false { + didSet { isFavoriteButtonHighlightStateDidChange(to: isFavoriteButtonHighlight) } } weak var delegate: ActionToolbarContainerDelegate? @@ -57,8 +61,8 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - retootButton.addTarget(self, action: #selector(ActionToolbarContainer.retootButtonDidPressed(_:)), for: .touchUpInside) - starButton.addTarget(self, action: #selector(ActionToolbarContainer.starButtonDidPressed(_:)), for: .touchUpInside) + reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } @@ -89,7 +93,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, retootButton, starButton, moreButton] + let buttons = [replyButton, reblogButton, favoriteButton, moreButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -109,28 +113,28 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .leading } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) moreButton.setImage(moreImage, for: .normal) container.axis = .horizontal container.distribution = .fill replyButton.translatesAutoresizingMaskIntoConstraints = false - retootButton.translatesAutoresizingMaskIntoConstraints = false - starButton.translatesAutoresizingMaskIntoConstraints = false + reblogButton.translatesAutoresizingMaskIntoConstraints = false + favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: retootButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: starButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: retootButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: starButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), ]) moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) moreButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -140,16 +144,16 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) container.axis = .horizontal container.spacing = 8 container.distribution = .fillEqually container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) } } @@ -158,11 +162,18 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isStarButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color + reblogButton.tintColor = tintColor + reblogButton.setTitleColor(tintColor, for: .normal) + reblogButton.setTitleColor(tintColor, for: .highlighted) + } + + private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color - starButton.tintColor = tintColor - starButton.setTitleColor(tintColor, for: .normal) - starButton.setTitleColor(tintColor, for: .highlighted) + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) } } @@ -173,12 +184,12 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func retootButtonDidPressed(_ sender: UIButton) { + @objc private func reblogButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, retootButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, reblogButtonDidPressed: sender) } - @objc private func starButtonDidPressed(_ sender: UIButton) { + @objc private func favoriteButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.actionToolbarContainer(self, starButtonDidPressed: sender) } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 70509d40e..56bf0cbc3 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -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) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift new file mode 100644 index 000000000..d34e73eba --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -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() + + 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() + + private var updateDate = Date() + + // output + let player: AVPlayer + private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) + + private var timeControlStatusObservation: NSKeyValueObservation? + let timeControlStatus = CurrentValueSubject(.paused) + + init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { + self.previewImageURL = previewImageURL + self.videoURL = videoURL + self.videoSize = videoSize + self.videoKind = videoKind + + let playerItem = AVPlayerItem(url: videoURL) + let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem) + player.isMuted = true + self.player = player + + if videoKind == .gif { + setupLooper() + } + + timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) + self.timeControlStatus.value = player.timeControlStatus + } + + // update audio session category for user interactive event stream + timeControlStatus + .sink { [weak self] timeControlStatus in + guard let _ = self else { return } + guard timeControlStatus == .playing else { return } + NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) + switch videoKind { + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + } + .store(in: &disposeBag) + + debouncePlayingState + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { [weak self] isPlay in + guard let self = self else { return } + isPlay ? self.play() : self.pause() + } + .store(in: &disposeBag) + } + + deinit { + timeControlStatusObservation = nil + } +} + +extension VideoPlayerViewModel { + enum Kind { + case gif + case video + } +} + +extension VideoPlayerViewModel { + func setupLooper() { + guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } + guard let templateItem = queuePlayer.items().first else { return } + looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) + } + + func play() { + switch videoKind { + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + + player.play() + updateDate = Date() + } + + func pause() { + player.pause() + updateDate = Date() + } + + func willDisplay() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) + + switch videoKind { + case .gif: + play() // always auto play GIF + case .video: + guard isPlayingWhenEndDisplaying else { return } + // mute before resume + if updateDate.timeIntervalSinceNow < -3 { + player.isMuted = true + } + debouncePlayingState.send(true) + } + + updateDate = Date() + } + + func didEndDisplaying() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) + + isPlayingWhenEndDisplaying = timeControlStatus.value != .paused + switch videoKind { + case .gif: + pause() // always pause GIF immediately + case .video: + debouncePlayingState.send(false) + } + + updateDate = Date() + } +} diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 3eee6b6e1..4150e796e 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -78,7 +78,8 @@ extension APIService { }() let _oldToot: Toot? = { let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: entity.id) + request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { @@ -95,6 +96,9 @@ extension APIService { return } APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + if favoriteKind == .destroy { + oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1))) + } os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) @@ -112,7 +116,8 @@ extension APIService { .handleEvents(receiveCompletion: { completion in switch completion { case .failure(let error): - print(error) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) case .finished: break } diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift new file mode 100644 index 000000000..92ff85c10 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -0,0 +1,132 @@ +// +// APIService+Reblog.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +import CommonOSLog + +extension APIService { + + // make local state change only + func reblog( + tootObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + reblogKind: Mastodon.API.Reblog.ReblogKind + ) -> AnyPublisher { + var _targetTootID: Toot.ID? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let targetToot = toot.reblog ?? toot + let targetTootID = targetToot.id + _targetTootID = targetTootID + + switch reblogKind { + case .reblog: + targetToot.update(reblogged: true, mastodonUser: mastodonUser) + case .undoReblog: + targetToot.update(reblogged: false, mastodonUser: mastodonUser) + } + + } + .tryMap { result in + switch result { + case .success: + guard let targetTootID = _targetTootID else { + throw APIError.implicit(.badRequest) + } + return targetTootID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + // send reblog request to remote + func reblog( + statusID: Mastodon.Entity.Status.ID, + reblogKind: Mastodon.API.Reblog.ReblogKind, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + return Mastodon.API.Reblog.reblog( + session: session, + domain: domain, + statusID: statusID, + reblogKind: reblogKind, + authorization: authorization + ) + .map { response -> AnyPublisher, Error> in + let log = OSLog.api + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + guard let requestMastodonUser: MastodonUser = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() else { + return + } + + guard let oldToot: Toot = { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: statusID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + return managedObjectContext.safeFetch(request).first + }() else { + return + } + + APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + switch reblogKind { + case .undoReblog: + oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + default: + break + } + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .handleEvents(receiveCompletion: { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) + case .finished: + break + } + }) + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlaybackService.swift similarity index 72% rename from Mastodon/Service/AudioPlayer.swift rename to Mastodon/Service/AudioPlaybackService.swift index 13479f0af..34ceb3bbe 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -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() var player = AVPlayer() @@ -21,19 +25,16 @@ final class AudioPlayer: NSObject { let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) - - // MARK: - singleton - public static let shared = AudioPlayer() let currentTimeSubject = CurrentValueSubject(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() + } +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift new file mode 100644 index 000000000..24ea6e6ce --- /dev/null +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -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() + + 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() + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index ffa1321ab..0d52f2cb4 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -29,6 +29,9 @@ class AppContext: ObservableObject { let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! + let videoPlaybackService = VideoPlaybackService() + let audioPlaybackService = AudioPlaybackService() + let overrideTraitCollection = CurrentValueSubject(nil) init() { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index ce77a51d9..3b01c2c13 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -114,14 +114,14 @@ extension Mastodon.API.Favorites { } -public extension Mastodon.API.Favorites { +extension Mastodon.API.Favorites { - enum FavoriteKind { + public enum FavoriteKind { case create case destroy } - struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,TimelineQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift new file mode 100644 index 000000000..cb7a96188 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -0,0 +1,176 @@ +// +// Mastodon+API+Status+Reblog.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine + +extension Mastodon.API.Reblog { + + static func rebloggedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblogged_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boosted by + /// + /// View who boosted a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func rebloggedBy( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: rebloggedByEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Reblog { + + static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boost + /// + /// Reshare a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func reblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + query: ReblogQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias Visibility = Mastodon.Entity.Source.Privacy + + public struct ReblogQuery: Codable, PostQuery { + public let visibility: Visibility? + + public init(visibility: Visibility?) { + self.visibility = visibility + } + } + +} + +extension Mastodon.API.Reblog { + + static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/unreblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Undo reblog + /// + /// Undo a reshare of a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func undoReblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unreblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Reblog { + + public enum ReblogKind { + case reblog(query: ReblogQuery) + case undoReblog + } + + public static func reblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + reblogKind: ReblogKind, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch reblogKind { + case .reblog(let query): + return reblog(session: session, domain: domain, statusID: statusID, query: query, authorization: authorization) + case .undoReblog: + return undoReblog(session: session, domain: domain, statusID: statusID, authorization: authorization) + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5516a0492..6235e455b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -95,6 +95,7 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } + public enum Reblog { } public enum Statuses { } public enum Timeline { } public enum Favorites { }