Merge branch /develop into feature/in-reply-to-header

# Conflicts:
#	Localization/app.json
#	Mastodon.xcodeproj/project.pbxproj
#	Mastodon/Diffiable/Section/StatusSection.swift
#	Mastodon/Resources/en.lproj/Localizable.strings
#	MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
This commit is contained in:
CMK 2021-03-16 11:41:56 +08:00
commit 1c6f231fee
39 changed files with 2025 additions and 210 deletions

View File

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

View File

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

View File

@ -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 = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
@ -301,6 +311,7 @@
2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Toot.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = "<group>"; };
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = "<group>"; };
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = "<group>"; };
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -388,6 +405,8 @@
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; };
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = "<group>"; };
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; };
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
@ -400,6 +419,7 @@
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = "<group>"; };
@ -447,6 +467,7 @@
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
@ -1180,6 +1206,9 @@
children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */,
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */,
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */,
);
path = Container;
sourceTree = "<group>";
@ -1189,6 +1218,7 @@
children = (
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -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;
};

View File

@ -7,7 +7,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>10</integer>
<integer>16</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>

View File

@ -34,7 +34,15 @@ extension StatusSection {
// configure cell
managedObjectContext.performAndWait {
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute)
StatusSection.configure(
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: timelineIndex.toot,
requestUserID: timelineIndex.userID,
statusItemAttribute: attribute
)
}
cell.delegate = statusTableViewCellDelegate
return cell
@ -45,7 +53,15 @@ extension StatusSection {
// configure cell
managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute)
StatusSection.configure(
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: toot,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
}
cell.delegate = statusTableViewCellDelegate
return cell
@ -69,9 +85,9 @@ extension StatusSection {
}
extension StatusSection {
static func configure(
cell: StatusTableViewCell,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot,
@ -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<Date, Never>
) {
guard let poll = poll,
let managedObjectContext = poll.managedObjectContext else {
let managedObjectContext = poll.managedObjectContext
else {
cell.statusView.pollTableView.isHidden = true
cell.statusView.pollStatusStackView.isHidden = true
cell.statusView.pollVoteButton.isHidden = true
@ -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)

View File

@ -0,0 +1,22 @@
//
// AVPlayer.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import AVKit
// MARK: - CustomDebugStringConvertible
extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case .paused: return "paused"
case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate"
case .playing: return "playing"
@unknown default:
assertionFailure()
return ""
}
}
}

View File

@ -0,0 +1,20 @@
//
// NSManagedObjectContext.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import Foundation
import CoreData
extension NSManagedObjectContext {
func safeFetch<T>(_ request: NSFetchRequest<T>) -> [T] where T : NSFetchRequestResult {
do {
return try fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
//
// NeedsDependency+AVPlayerViewControllerDelegate.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import Foundation
import AVKit
extension NeedsDependency where Self: AVPlayerViewControllerDelegate {
func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true
}
func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false
}
}

View File

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

View File

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

View File

@ -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<Toot?, Never>) {
// 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
//
// TouchBlockingView.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import UIKit
final class TouchBlockingView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TouchBlockingView {
private func _init() {
isUserInteractionEnabled = true
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Blocking responder chain by not call super
// The subviews in this view will received touch event but superview not
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,153 @@
//
// VideoPlayerViewModel.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import AVKit
import Combine
import CoreDataStack
import os.log
import UIKit
final class VideoPlayerViewModel {
var disposeBag = Set<AnyCancellable>()
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.video-playback-service.appWillPlayVideo")
// input
let previewImageURL: URL?
let videoURL: URL
let videoSize: CGSize
let videoKind: Kind
var isTransitioning = false
var isFullScreenPresentationing = false
var isPlayingWhenEndDisplaying = false
// prevent player state flick when tableView reload
private typealias Play = Bool
private let debouncePlayingState = PassthroughSubject<Play, Never>()
private var updateDate = Date()
// output
let player: AVPlayer
private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+)
private var timeControlStatusObservation: NSKeyValueObservation?
let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused)
init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) {
self.previewImageURL = previewImageURL
self.videoURL = videoURL
self.videoSize = videoSize
self.videoKind = videoKind
let playerItem = AVPlayerItem(url: videoURL)
let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem)
player.isMuted = true
self.player = player
if videoKind == .gif {
setupLooper()
}
timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription)
self.timeControlStatus.value = player.timeControlStatus
}
// update audio session category for user interactive event stream
timeControlStatus
.sink { [weak self] timeControlStatus in
guard let _ = self else { return }
guard timeControlStatus == .playing else { return }
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
switch videoKind {
case .gif:
break
case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
}
}
.store(in: &disposeBag)
debouncePlayingState
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.sink { [weak self] isPlay in
guard let self = self else { return }
isPlay ? self.play() : self.pause()
}
.store(in: &disposeBag)
}
deinit {
timeControlStatusObservation = nil
}
}
extension VideoPlayerViewModel {
enum Kind {
case gif
case video
}
}
extension VideoPlayerViewModel {
func setupLooper() {
guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return }
guard let templateItem = queuePlayer.items().first else { return }
looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem)
}
func play() {
switch videoKind {
case .gif:
break
case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
}
player.play()
updateDate = Date()
}
func pause() {
player.pause()
updateDate = Date()
}
func willDisplay() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription)
switch videoKind {
case .gif:
play() // always auto play GIF
case .video:
guard isPlayingWhenEndDisplaying else { return }
// mute before resume
if updateDate.timeIntervalSinceNow < -3 {
player.isMuted = true
}
debouncePlayingState.send(true)
}
updateDate = Date()
}
func didEndDisplaying() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription)
isPlayingWhenEndDisplaying = timeControlStatus.value != .paused
switch videoKind {
case .gif:
pause() // always pause GIF immediately
case .video:
debouncePlayingState.send(false)
}
updateDate = Date()
}
}

View File

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

View File

@ -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<Toot.ID, Error> {
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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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" } ?? "<nil>", entity.reblogsCount )
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> 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()
}
}

View File

@ -10,8 +10,12 @@ import Combine
import CoreDataStack
import Foundation
import UIKit
import os.log
final class AudioPlayer: NSObject {
final class AudioPlaybackService: NSObject {
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio")
var disposeBag = Set<AnyCancellable>()
var player = AVPlayer()
@ -21,19 +25,16 @@ final class AudioPlayer: NSObject {
let session = AVAudioSession.sharedInstance()
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
// MARK: - singleton
public static let shared = AudioPlayer()
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
private override init() {
override init() {
super.init()
addObserver()
}
}
extension AudioPlayer {
extension AudioPlaybackService {
func playAudio(audioAttachment: Attachment) {
guard let url = URL(string: audioAttachment.url) else {
return
@ -45,6 +46,7 @@ extension AudioPlayer {
return
}
notifyWillPlayAudioNotification()
if audioAttachment == attachment {
if self.playbackState.value == .stopped {
self.seekToTime(time: .zero)
@ -83,6 +85,12 @@ extension AudioPlayer {
}
}
.store(in: &disposeBag)
NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification)
.sink { [weak self] _ in
guard let self = self else { return }
self.pauseIfNeed()
}
.store(in: &disposeBag)
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in
guard let self = self else { return }
@ -111,12 +119,22 @@ extension AudioPlayer {
self.currentTimeSubject.value = 0
}
.store(in: &disposeBag)
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: nil)
.sink { [weak self] _ in
guard let self = self else { return }
self.pause()
}
.store(in: &disposeBag)
}
func notifyWillPlayAudioNotification() {
NotificationCenter.default.post(name: AudioPlaybackService.appWillPlayAudioNotification, object: nil)
}
func isPlaying() -> Bool {
return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing
return playbackState.value == .readyToPlay || playbackState.value == .playing
}
func resume() {
notifyWillPlayAudioNotification()
player.play()
playbackState.value = .playing
}
@ -125,8 +143,19 @@ extension AudioPlayer {
player.pause()
playbackState.value = .paused
}
func pauseIfNeed() {
if isPlaying() {
pause()
}
}
func seekToTime(time: TimeInterval) {
player.seek(to: CMTimeMake(value:Int64(time), timescale: 1))
}
}
extension AudioPlaybackService {
func viewDidDisappear(from viewController: UIViewController?) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
pause()
}
}

View File

@ -0,0 +1,142 @@
//
// ViedeoPlaybackService.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import AVKit
import Combine
import CoreDataStack
import Foundation
import os.log
final class VideoPlaybackService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue")
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
// only for video kind
weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel?
}
extension VideoPlaybackService {
private func playerViewModel(_ playerViewModel: VideoPlayerViewModel, didUpdateTimeControlStatus: AVPlayer.TimeControlStatus) {
switch playerViewModel.videoKind {
case .gif:
// do nothing
return
case .video:
if playerViewModel.timeControlStatus.value != .paused {
latestPlayingVideoPlayerViewModel = playerViewModel
// pause other player
for viewModel in viewPlayerViewModelDict.values {
guard viewModel.timeControlStatus.value != .paused else { continue }
guard viewModel !== playerViewModel else { continue }
viewModel.pause()
}
} else {
if latestPlayingVideoPlayerViewModel === playerViewModel {
latestPlayingVideoPlayerViewModel = nil
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
}
}
}
}
}
extension VideoPlaybackService {
func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? {
// Core Data entity not thread-safe. Save attribute before enter working queue
guard let height = media.meta?.original?.height,
let width = media.meta?.original?.width,
let url = URL(string: media.url),
media.type == .gifv || media.type == .video
else { return nil }
let previewImageURL = media.previewURL.flatMap { URL(string: $0) }
let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video
var _viewModel: VideoPlayerViewModel?
workingQueue.sync {
if let viewModel = viewPlayerViewModelDict[url] {
_viewModel = viewModel
} else {
let viewModel = VideoPlayerViewModel(
previewImageURL: previewImageURL,
videoURL: url,
videoSize: CGSize(width: width, height: height),
videoKind: videoKind
)
viewPlayerViewModelDict[url] = viewModel
setupListener(for: viewModel)
_viewModel = viewModel
}
}
return _viewModel
}
func playerViewModel(for playerViewController: AVPlayerViewController) -> VideoPlayerViewModel? {
guard let url = (playerViewController.player?.currentItem?.asset as? AVURLAsset)?.url else { return nil }
return viewPlayerViewModelDict[url]
}
private func setupListener(for viewModel: VideoPlayerViewModel) {
viewModel.timeControlStatus
.sink { [weak self] timeControlStatus in
guard let self = self else { return }
self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus)
}
.store(in: &disposeBag)
NotificationCenter.default.publisher(for: AudioPlaybackService.appWillPlayAudioNotification)
.sink { [weak self] _ in
guard let self = self else { return }
self.pauseWhenPlayAudio()
}
.store(in: &disposeBag)
}
}
extension VideoPlaybackService {
func markTransitioning(for toot: Toot) {
guard let videoAttachment = toot.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return }
guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return }
videoPlayerViewModel.isTransitioning = true
}
func viewDidDisappear(from viewController: UIViewController?) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
// note: do not retain view controller
// pause all player when view disppear exclude full screen player and other transitioning scene
for viewModel in viewPlayerViewModelDict.values {
guard !viewModel.isTransitioning else {
viewModel.isTransitioning = false
continue
}
guard !viewModel.isFullScreenPresentationing else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function)
continue
}
guard viewModel.videoKind == .video else { continue }
viewModel.pause()
}
}
func pauseWhenPlayAudio() {
for viewModel in viewPlayerViewModelDict.values {
guard !viewModel.isTransitioning else {
viewModel.isTransitioning = false
continue
}
guard !viewModel.isFullScreenPresentationing else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function)
continue
}
viewModel.pause()
}
}
}

View File

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

View File

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

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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)
}
}
}

View File

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