feat: add video player for audio/video kind media
This commit is contained in:
parent
1789e6eb86
commit
582843f54a
|
@ -27,9 +27,7 @@
|
|||
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
|
||||
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; };
|
||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
|
||||
|
@ -80,7 +78,6 @@
|
|||
2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */; };
|
||||
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; };
|
||||
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; };
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; };
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
|
||||
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; };
|
||||
|
@ -111,11 +108,7 @@
|
|||
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; };
|
||||
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; };
|
||||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; };
|
||||
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.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 */; };
|
||||
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 */; };
|
||||
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
||||
|
@ -262,7 +255,6 @@
|
|||
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; };
|
||||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
|
||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; };
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; };
|
||||
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; };
|
||||
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; };
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
|
||||
|
@ -455,8 +447,6 @@
|
|||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; };
|
||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
|
||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; };
|
||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */; };
|
||||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
|
||||
DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D7C20269824B80054B3DF /* APIService+Filter.swift */; };
|
||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; };
|
||||
DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */; };
|
||||
|
@ -489,6 +479,9 @@
|
|||
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; };
|
||||
DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; };
|
||||
DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; };
|
||||
DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */; };
|
||||
DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */; };
|
||||
DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */; };
|
||||
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
|
||||
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
|
||||
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
|
||||
|
@ -723,9 +716,7 @@
|
|||
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.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>"; };
|
||||
2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = "<group>"; };
|
||||
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -773,7 +764,6 @@
|
|||
2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+DomainBlock.swift"; sourceTree = "<group>"; };
|
||||
2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = "<group>"; };
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; };
|
||||
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = "<group>"; };
|
||||
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
|
@ -812,11 +802,7 @@
|
|||
5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = "<group>"; };
|
||||
5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = "<group>"; };
|
||||
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = "<group>"; };
|
||||
5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlaybackService.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>"; };
|
||||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
|
||||
6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - debug.xcconfig"; 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>"; };
|
||||
77EE917BC055E6621C0452B6 /* Pods-ShareActionExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
|
@ -989,7 +975,6 @@
|
|||
DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = "<group>"; };
|
||||
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
|
||||
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = "<group>"; };
|
||||
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
|
||||
DB4B777F26CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = "<group>"; };
|
||||
DB4B778226CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
DB4B778326CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Intents.stringsdict; sourceTree = "<group>"; };
|
||||
|
@ -1196,8 +1181,6 @@
|
|||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
|
||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewContainer.swift; sourceTree = "<group>"; };
|
||||
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; };
|
||||
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = "<group>"; };
|
||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
|
||||
DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -1239,6 +1222,9 @@
|
|||
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = "<group>"; };
|
||||
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = "<group>"; };
|
||||
DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewController.swift; sourceTree = "<group>"; };
|
||||
DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; };
|
||||
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
|
||||
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
|
||||
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -1658,9 +1644,7 @@
|
|||
DB9A489B26036E19008B817C /* MastodonAttachmentService */,
|
||||
DBBC24BD26A5441A00398BB9 /* ThemeService */,
|
||||
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
||||
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||
5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */,
|
||||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
||||
2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */,
|
||||
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
|
||||
|
@ -1681,7 +1665,6 @@
|
|||
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
|
||||
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
|
||||
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
|
||||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */,
|
||||
DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */,
|
||||
DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */,
|
||||
|
@ -1727,7 +1710,6 @@
|
|||
children = (
|
||||
2DA504672601ADBA008F4E6C /* Decoration */,
|
||||
2D42FF8325C82245004A627A /* Button */,
|
||||
DB9D6C1325E4F97A0051B173 /* Container */,
|
||||
DBA9B90325F1D4420012E7B6 /* Control */,
|
||||
2D152A8A25C295B8009AA50C /* Content */,
|
||||
DB1D187125EF5BBD003F1F23 /* TableView */,
|
||||
|
@ -2350,6 +2332,8 @@
|
|||
DB6180DE263919350018D199 /* MediaPreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBB45B5727B39FCC002DC5A7 /* Video */,
|
||||
DB6180F026391CAB0018D199 /* Image */,
|
||||
DB6180E1263919780018D199 /* Paging */,
|
||||
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */,
|
||||
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */,
|
||||
|
@ -2360,7 +2344,6 @@
|
|||
DB6180E1263919780018D199 /* Paging */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180F026391CAB0018D199 /* Image */,
|
||||
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */,
|
||||
);
|
||||
path = Paging;
|
||||
|
@ -2384,6 +2367,7 @@
|
|||
DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */,
|
||||
DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */,
|
||||
DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */,
|
||||
DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */,
|
||||
);
|
||||
path = MediaPreview;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2842,23 +2826,9 @@
|
|||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB9D6C1325E4F97A0051B173 /* Container */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
|
||||
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */,
|
||||
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */,
|
||||
);
|
||||
path = Container;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB9D6C2025E502C60051B173 /* ViewModel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
|
||||
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
|
||||
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */,
|
||||
DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */,
|
||||
);
|
||||
path = ViewModel;
|
||||
|
@ -2929,6 +2899,15 @@
|
|||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBB45B5727B39FCC002DC5A7 /* Video */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */,
|
||||
DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */,
|
||||
);
|
||||
path = Video;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBB525132611EBB1002F1F29 /* Segmented */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3787,7 +3766,6 @@
|
|||
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
|
||||
DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */,
|
||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
||||
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */,
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
||||
|
@ -3799,7 +3777,6 @@
|
|||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||
DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */,
|
||||
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */,
|
||||
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */,
|
||||
0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */,
|
||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
|
||||
|
@ -3934,9 +3911,7 @@
|
|||
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
|
||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
||||
DBBC24C126A5443100398BB9 /* SystemTheme.swift in Sources */,
|
||||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
|
||||
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
|
||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
|
@ -3974,7 +3949,6 @@
|
|||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
||||
DB73BF43271192BB00781945 /* InstanceService.swift in Sources */,
|
||||
DB67D08427312970006A36CF /* APIService+Following.swift in Sources */,
|
||||
DB025B78278D606A002F581E /* StatusItem.swift in Sources */,
|
||||
|
@ -3991,7 +3965,6 @@
|
|||
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
||||
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
|
||||
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */,
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
|
||||
0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */,
|
||||
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
|
||||
|
@ -4068,6 +4041,7 @@
|
|||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */,
|
||||
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
|
||||
DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
DB0FCB76279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift in Sources */,
|
||||
DB0FCB7027951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift in Sources */,
|
||||
|
@ -4118,7 +4092,6 @@
|
|||
DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */,
|
||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */,
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */,
|
||||
|
@ -4148,7 +4121,6 @@
|
|||
DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */,
|
||||
DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */,
|
||||
DB63F773279A87DC00455B82 /* Notification+Property.swift in Sources */,
|
||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
||||
DBCBCC0D2680B908000F5B51 /* HomeTimelinePreference.swift in Sources */,
|
||||
DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */,
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
|
||||
|
@ -4170,7 +4142,6 @@
|
|||
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */,
|
||||
DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */,
|
||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
||||
DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */,
|
||||
DB336F21278D6D960031E64B /* MastodonEmoji.swift in Sources */,
|
||||
DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */,
|
||||
|
@ -4200,6 +4171,7 @@
|
|||
DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */,
|
||||
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
|
||||
DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */,
|
||||
DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */,
|
||||
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */,
|
||||
|
@ -4211,6 +4183,7 @@
|
|||
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
|
||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
|
||||
DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */,
|
||||
DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */,
|
||||
DB0FCB8027968F70006C02E2 /* MastodonStatusThreadViewModel.swift in Sources */,
|
||||
DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */,
|
||||
|
@ -4257,7 +4230,6 @@
|
|||
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
|
||||
DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */,
|
||||
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
|
||||
DB336F3D278D80040031E64B /* FeedFetchedResultsController.swift in Sources */,
|
||||
DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */,
|
||||
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>25</integer>
|
||||
<integer>20</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -97,7 +97,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>27</integer>
|
||||
<integer>19</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -117,7 +117,7 @@
|
|||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>26</integer>
|
||||
<integer>18</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
}
|
|
@ -122,9 +122,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev
|
|||
let needsToggleMediaSensitive: Bool = try await managedObjectContext.perform {
|
||||
guard let _status = status.object(in: managedObjectContext) else { return false }
|
||||
let status = _status.reblog ?? _status
|
||||
guard status.sensitive else { return false }
|
||||
guard status.isMediaSensitiveToggled else { return true }
|
||||
return false
|
||||
return status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive
|
||||
}
|
||||
|
||||
guard !needsToggleMediaSensitive else {
|
||||
|
@ -407,5 +405,29 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
|
|||
)
|
||||
} // end Task
|
||||
}
|
||||
|
||||
func tableViewCell(
|
||||
_ cell: UITableViewCell,
|
||||
statusView: StatusView,
|
||||
mediaGridContainerView: MediaGridContainerView,
|
||||
mediaSensitiveButtonDidPressed button: UIButton
|
||||
) {
|
||||
Task {
|
||||
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
|
||||
guard let item = await item(from: source) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
guard case let .status(status) = item else {
|
||||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
try await DataSourceFacade.responseToToggleMediaSensitiveAction(
|
||||
dependency: self,
|
||||
status: status
|
||||
)
|
||||
} // end Task
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -163,3 +163,21 @@ extension MediaPreviewImageViewController {
|
|||
case share
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MediaPreviewTransitionViewController
|
||||
extension MediaPreviewImageViewController: MediaPreviewTransitionViewController {
|
||||
var mediaPreviewTransitionContext: MediaPreviewTransitionContext? {
|
||||
let imageView = previewImageView.imageView
|
||||
let _snapshot: UIView? = imageView.snapshotView(afterScreenUpdates: false)
|
||||
|
||||
guard let snapshot = _snapshot else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return MediaPreviewTransitionContext(
|
||||
transitionView: imageView,
|
||||
snapshot: snapshot,
|
||||
snapshotTransitioning: snapshot
|
||||
)
|
||||
}
|
||||
}
|
|
@ -105,7 +105,7 @@ extension MediaPreviewViewController {
|
|||
.sink { [weak self] index in
|
||||
guard let self = self else { return }
|
||||
switch self.viewModel.transitionItem.source {
|
||||
case .attachment(_):
|
||||
case .attachment:
|
||||
break
|
||||
case .attachments(let mediaGridContainerView):
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
|
@ -117,6 +117,24 @@ extension MediaPreviewViewController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.$currentPage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] index in
|
||||
guard let self = self else { return }
|
||||
switch self.viewModel.item {
|
||||
case .attachment(let previewContext):
|
||||
let needsHideCloseButton: Bool = {
|
||||
guard index < previewContext.attachments.count else { return false }
|
||||
let attachment = previewContext.attachments[index]
|
||||
return attachment.kind == .video // not hide buttno for audio
|
||||
}()
|
||||
self.closeButtonBackground.isHidden = needsHideCloseButton
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -148,6 +166,10 @@ extension MediaPreviewViewController: MediaPreviewingViewController {
|
|||
return dismissible
|
||||
}
|
||||
|
||||
if let _ = pagingViewController.currentViewController as? MediaPreviewVideoViewController {
|
||||
return true
|
||||
}
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible false", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -38,17 +38,42 @@ final class MediaPreviewViewModel: NSObject {
|
|||
case .attachment(let previewContext):
|
||||
currentPage = previewContext.initialIndex
|
||||
for (i, attachment) in previewContext.attachments.enumerated() {
|
||||
let viewController = MediaPreviewImageViewController()
|
||||
let viewModel = MediaPreviewImageViewModel(
|
||||
context: context,
|
||||
item: .remote(.init(
|
||||
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
|
||||
thumbnail: previewContext.thumbnail(at: i),
|
||||
altText: attachment.altDescription
|
||||
))
|
||||
)
|
||||
viewController.viewModel = viewModel
|
||||
viewControllers.append(viewController)
|
||||
switch attachment.kind {
|
||||
case .image:
|
||||
let viewController = MediaPreviewImageViewController()
|
||||
let viewModel = MediaPreviewImageViewModel(
|
||||
context: context,
|
||||
item: .remote(.init(
|
||||
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
|
||||
thumbnail: previewContext.thumbnail(at: i),
|
||||
altText: attachment.altDescription
|
||||
))
|
||||
)
|
||||
viewController.viewModel = viewModel
|
||||
viewControllers.append(viewController)
|
||||
case .gifv:
|
||||
let viewController = MediaPreviewVideoViewController()
|
||||
let viewModel = MediaPreviewVideoViewModel(
|
||||
context: context,
|
||||
item: .gif(.init(
|
||||
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
|
||||
previewURL: attachment.previewURL.flatMap { URL(string: $0) }
|
||||
))
|
||||
)
|
||||
viewController.viewModel = viewModel
|
||||
viewControllers.append(viewController)
|
||||
case .video, .audio:
|
||||
let viewController = MediaPreviewVideoViewController()
|
||||
let viewModel = MediaPreviewVideoViewModel(
|
||||
context: context,
|
||||
item: .video(.init(
|
||||
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
|
||||
previewURL: attachment.previewURL.flatMap { URL(string: $0) }
|
||||
))
|
||||
)
|
||||
viewController.viewModel = viewModel
|
||||
viewControllers.append(viewController)
|
||||
} // end switch attachment.kind { … }
|
||||
} // end for … in …
|
||||
case .profileAvatar(let previewContext):
|
||||
let viewController = MediaPreviewImageViewController()
|
||||
|
@ -75,68 +100,13 @@ final class MediaPreviewViewModel: NSObject {
|
|||
viewController.viewModel = viewModel
|
||||
viewControllers.append(viewController)
|
||||
} // end switch
|
||||
// let status = managedObjectContext.object(with: meta.statusObjectID) as! Status
|
||||
// for (entity, image) in zip(status.attachments, meta.preloadThumbnailImages) {
|
||||
// let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil }
|
||||
// switch entity.kind {
|
||||
// case .image:
|
||||
// guard let url = URL(string: entity.assetURL ?? "") else { continue }
|
||||
// let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail, altText: entity.altDescription)
|
||||
// let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
// let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
// mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
// viewControllers.append(mediaPreviewImageViewController)
|
||||
// default:
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
self.viewControllers = viewControllers
|
||||
self.currentPage = currentPage
|
||||
self.transitionItem = transitionItem
|
||||
super.init()
|
||||
}
|
||||
|
||||
// init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) {
|
||||
// self.context = context
|
||||
// self.item = .profileBanner(meta)
|
||||
// var viewControllers: [UIViewController] = []
|
||||
// let managedObjectContext = self.context.managedObjectContext
|
||||
// managedObjectContext.performAndWait {
|
||||
// let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser
|
||||
// let avatarURL = account.headerImageURLWithFallback(domain: account.domain)
|
||||
// let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil)
|
||||
// let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
// let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
// mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
// viewControllers.append(mediaPreviewImageViewController)
|
||||
// }
|
||||
// self.viewControllers = viewControllers
|
||||
// self.currentPage = CurrentValueSubject(0)
|
||||
// self.transitionItem = pushTransitionItem
|
||||
// super.init()
|
||||
// }
|
||||
//
|
||||
// init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) {
|
||||
// self.context = context
|
||||
// self.item = .profileAvatar(meta)
|
||||
// var viewControllers: [UIViewController] = []
|
||||
// let managedObjectContext = self.context.managedObjectContext
|
||||
// managedObjectContext.performAndWait {
|
||||
// let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser
|
||||
// let avatarURL = account.avatarImageURLWithFallback(domain: account.domain)
|
||||
// let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil)
|
||||
// let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
// let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
// mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
// viewControllers.append(mediaPreviewImageViewController)
|
||||
// }
|
||||
// self.viewControllers = viewControllers
|
||||
// self.currentPage = CurrentValueSubject(0)
|
||||
// self.transitionItem = pushTransitionItem
|
||||
// super.init()
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewViewModel {
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
//
|
||||
// MediaPreviewVideoViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-2-9.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import func AVFoundation.AVMakeRect
|
||||
|
||||
final class MediaPreviewVideoViewController: UIViewController {
|
||||
|
||||
let logger = Logger(subsystem: "MediaPreviewVideoViewController", category: "ViewController")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: MediaPreviewVideoViewModel!
|
||||
|
||||
let playerViewController = AVPlayerViewController()
|
||||
// var pictureInPictureController: AVPictureInPictureController?
|
||||
|
||||
let previewImageView = UIImageView()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
playerViewController.player?.pause()
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewVideoViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
addChild(playerViewController)
|
||||
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(playerViewController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
playerViewController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
playerViewController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
playerViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
playerViewController.view.heightAnchor.constraint(equalTo: view.heightAnchor),
|
||||
])
|
||||
playerViewController.didMove(toParent: self)
|
||||
|
||||
if let contentOverlayView = playerViewController.contentOverlayView {
|
||||
previewImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentOverlayView.addSubview(previewImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
previewImageView.topAnchor.constraint(equalTo: contentOverlayView.topAnchor),
|
||||
previewImageView.leadingAnchor.constraint(equalTo: contentOverlayView.leadingAnchor),
|
||||
previewImageView.trailingAnchor.constraint(equalTo: contentOverlayView.trailingAnchor),
|
||||
previewImageView.bottomAnchor.constraint(equalTo: contentOverlayView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
playerViewController.delegate = self
|
||||
playerViewController.view.backgroundColor = .clear
|
||||
playerViewController.player = viewModel.player
|
||||
playerViewController.allowsPictureInPicturePlayback = true
|
||||
|
||||
switch viewModel.item {
|
||||
case .video:
|
||||
break
|
||||
case .gif:
|
||||
playerViewController.showsPlaybackControls = false
|
||||
}
|
||||
|
||||
viewModel.player?.play()
|
||||
viewModel.playbackState = .playing
|
||||
|
||||
if let previewURL = viewModel.item.previewURL {
|
||||
previewImageView.contentMode = .scaleAspectFit
|
||||
previewImageView.af.setImage(
|
||||
withURL: previewURL,
|
||||
placeholderImage: .placeholder(color: .systemFill)
|
||||
)
|
||||
|
||||
playerViewController.publisher(for: \.isReadyForDisplay)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReadyForDisplay in
|
||||
guard let self = self else { return }
|
||||
self.previewImageView.isHidden = isReadyForDisplay
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ShareActivityProvider
|
||||
//extension MediaPreviewVideoViewController: ShareActivityProvider {
|
||||
// var activities: [Any] {
|
||||
// return []
|
||||
// }
|
||||
//
|
||||
// var applicationActivities: [UIActivity] {
|
||||
// switch viewModel.item {
|
||||
// case .gif(let mediaContext):
|
||||
// guard let url = mediaContext.assetURL else { return [] }
|
||||
// return [
|
||||
// SavePhotoActivity(context: viewModel.context, url: url, resourceType: .video)
|
||||
// ]
|
||||
// default:
|
||||
// return []
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
extension MediaPreviewVideoViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - MediaPreviewTransitionViewController
|
||||
extension MediaPreviewVideoViewController: MediaPreviewTransitionViewController {
|
||||
var mediaPreviewTransitionContext: MediaPreviewTransitionContext? {
|
||||
guard let playerView = playerViewController.view else { return nil }
|
||||
let _currentFrame: UIImage? = {
|
||||
guard let player = playerViewController.player else { return nil }
|
||||
guard let asset = player.currentItem?.asset else { return nil }
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
||||
do {
|
||||
let cgImage = try assetImageGenerator.copyCGImage(at: player.currentTime(), actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
} catch {
|
||||
return previewImageView.image
|
||||
}
|
||||
}()
|
||||
let _snapshot: UIView? = {
|
||||
guard let currentFrame = _currentFrame else { return nil }
|
||||
let size = AVMakeRect(aspectRatio: currentFrame.size, insideRect: view.frame).size
|
||||
let imageView = UIImageView(frame: CGRect(origin: .zero, size: size))
|
||||
imageView.image = currentFrame
|
||||
return imageView
|
||||
}()
|
||||
guard let snapshot = _snapshot else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return MediaPreviewTransitionContext(
|
||||
transitionView: playerView,
|
||||
snapshot: snapshot,
|
||||
snapshotTransitioning: snapshot
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// MediaPreviewVideoViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-2-9.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import AlamofireImage
|
||||
|
||||
final class MediaPreviewVideoViewModel {
|
||||
|
||||
let logger = Logger(subsystem: "MediaPreviewVideoViewModel", category: "ViewModel")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let item: Item
|
||||
|
||||
// output
|
||||
public private(set) var player: AVPlayer?
|
||||
private var playerLooper: AVPlayerLooper?
|
||||
@Published var playbackState = PlaybackState.unknown
|
||||
|
||||
init(context: AppContext, item: Item) {
|
||||
self.context = context
|
||||
self.item = item
|
||||
// end init
|
||||
|
||||
switch item {
|
||||
case .video(let mediaContext):
|
||||
guard let assertURL = mediaContext.assetURL else { return }
|
||||
let playerItem = AVPlayerItem(url: assertURL)
|
||||
let _player = AVPlayer(playerItem: playerItem)
|
||||
self.player = _player
|
||||
|
||||
case .gif(let mediaContext):
|
||||
guard let assertURL = mediaContext.assetURL else { return }
|
||||
let playerItem = AVPlayerItem(url: assertURL)
|
||||
let _player = AVQueuePlayer(playerItem: playerItem)
|
||||
_player.isMuted = true
|
||||
self.player = _player
|
||||
if let templateItem = _player.items().first {
|
||||
let _playerLooper = AVPlayerLooper(player: _player, templateItem: templateItem)
|
||||
self.playerLooper = _playerLooper
|
||||
}
|
||||
}
|
||||
|
||||
guard let player = player else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
// setup player state observer
|
||||
$playbackState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): player state: \(status.description)")
|
||||
|
||||
switch status {
|
||||
case .unknown, .buffering, .readyToPlay:
|
||||
break
|
||||
case .playing:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
case .paused, .stopped, .failed:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
player.publisher(for: \.status, options: [.initial, .new])
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
case .failed:
|
||||
self.playbackState = .failed
|
||||
case .readyToPlay:
|
||||
self.playbackState = .readyToPlay
|
||||
case .unknown:
|
||||
self.playbackState = .unknown
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||
.sink { [weak self] notification in
|
||||
guard let self = self else { return }
|
||||
guard let playerItem = notification.object as? AVPlayerItem,
|
||||
let urlAsset = playerItem.asset as? AVURLAsset
|
||||
else { return }
|
||||
print(urlAsset.url)
|
||||
guard urlAsset.url == item.assetURL else { return }
|
||||
self.playbackState = .stopped
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewVideoViewModel {
|
||||
|
||||
enum Item {
|
||||
case video(RemoteVideoContext)
|
||||
case gif(RemoteGIFContext)
|
||||
|
||||
var previewURL: URL? {
|
||||
switch self {
|
||||
case .video(let mediaContext): return mediaContext.previewURL
|
||||
case .gif(let mediaContext): return mediaContext.previewURL
|
||||
}
|
||||
}
|
||||
|
||||
var assetURL: URL? {
|
||||
switch self {
|
||||
case .video(let mediaContext): return mediaContext.assetURL
|
||||
case .gif(let mediaContext): return mediaContext.assetURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteVideoContext {
|
||||
let assetURL: URL?
|
||||
let previewURL: URL?
|
||||
// let thumbnail: UIImage?
|
||||
}
|
||||
|
||||
struct RemoteGIFContext {
|
||||
let assetURL: URL?
|
||||
let previewURL: URL?
|
||||
}
|
||||
|
||||
}
|
|
@ -1,131 +0,0 @@
|
|||
//
|
||||
// AudioViewContainer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
final class AudioContainerView: UIView {
|
||||
static let cornerRadius: CGFloat = 22
|
||||
|
||||
let container: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 11
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layer.cornerRadius = AudioContainerView.cornerRadius
|
||||
stackView.clipsToBounds = true
|
||||
stackView.backgroundColor = Asset.Colors.brandBlue.color
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let playButtonBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 16
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = Asset.Colors.brandBlue.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let playButton: UIButton = {
|
||||
let button = HighlightDimmableButton(type: .custom)
|
||||
let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
|
||||
let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected)
|
||||
|
||||
button.tintColor = .white
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.isEnabled = true
|
||||
return button
|
||||
}()
|
||||
|
||||
let slider: UISlider = {
|
||||
let slider = UISlider()
|
||||
slider.isContinuous = true
|
||||
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
slider.minimumTrackTintColor = Asset.Colors.Slider.track.color
|
||||
slider.maximumTrackTintColor = Asset.Colors.Slider.track.color
|
||||
if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) {
|
||||
slider.setThumbImage(image, for: .normal)
|
||||
}
|
||||
return slider
|
||||
}()
|
||||
|
||||
let timeLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
label.textColor = .white
|
||||
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioContainerView {
|
||||
private func _init() {
|
||||
addSubview(container)
|
||||
NSLayoutConstraint.activate([
|
||||
container.topAnchor.constraint(equalTo: topAnchor),
|
||||
container.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
// checkmark
|
||||
playButtonBackgroundView.addSubview(playButton)
|
||||
container.addArrangedSubview(playButtonBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
|
||||
playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
|
||||
playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
])
|
||||
|
||||
container.addArrangedSubview(slider)
|
||||
|
||||
container.addArrangedSubview(timeLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct AudioContainerView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
UIViewPreview(width: 375) {
|
||||
AudioContainerView()
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 100))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,497 +0,0 @@
|
|||
//
|
||||
// MosaicImageViewContainer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-2-23.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
|
||||
protocol MosaicImageViewContainerPresentable: AnyObject {
|
||||
var mosaicImageViewContainer: MosaicImageViewContainer { get }
|
||||
var isRevealing: Bool { get }
|
||||
}
|
||||
|
||||
protocol MosaicImageViewContainerDelegate: AnyObject {
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
}
|
||||
|
||||
final class MosaicImageViewContainer: UIView {
|
||||
|
||||
weak var delegate: MosaicImageViewContainerDelegate?
|
||||
|
||||
let container = UIStackView()
|
||||
private(set) lazy var imageViews: [UIImageView] = {
|
||||
(0..<4).map { _ -> UIImageView in
|
||||
let imageView = UIImageView()
|
||||
imageView.isUserInteractionEnabled = true
|
||||
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:)))
|
||||
imageView.addGestureRecognizer(tapGesture)
|
||||
imageView.isAccessibilityElement = true
|
||||
imageView.backgroundColor = .systemFill
|
||||
return imageView
|
||||
}
|
||||
}()
|
||||
let blurhashOverlayImageViews: [UIImageView] = {
|
||||
(0..<4).map { _ in UIImageView() }
|
||||
}()
|
||||
|
||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||
contentWarningOverlayView.configure(style: .media)
|
||||
return contentWarningOverlayView
|
||||
}()
|
||||
|
||||
private var containerHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
private func _init() {
|
||||
// accessibility
|
||||
accessibilityIgnoresInvertColors = true
|
||||
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.axis = .horizontal
|
||||
container.distribution = .fillEqually
|
||||
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
|
||||
])
|
||||
|
||||
contentWarningOverlayView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
func resetImageTask() {
|
||||
imageViews.forEach { imageView in
|
||||
imageView.af.cancelImageRequest()
|
||||
imageView.image = nil
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
resetImageTask()
|
||||
|
||||
container.arrangedSubviews.forEach { subview in
|
||||
container.removeArrangedSubview(subview)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
container.subviews.forEach { subview in
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
imageViews.forEach { imageView in
|
||||
imageView.constraints.forEach { imageView.removeConstraint($0) }
|
||||
imageView.removeFromSuperview()
|
||||
imageView.layer.maskedCorners = [
|
||||
.layerMinXMinYCorner, .layerMaxXMinYCorner,
|
||||
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
|
||||
]
|
||||
imageView.image = nil
|
||||
}
|
||||
blurhashOverlayImageViews.forEach { imageView in
|
||||
imageView.constraints.forEach { imageView.removeConstraint($0) }
|
||||
imageView.removeFromSuperview()
|
||||
imageView.layer.maskedCorners = [
|
||||
.layerMinXMinYCorner, .layerMaxXMinYCorner,
|
||||
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
|
||||
]
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
contentWarningOverlayView.removeFromSuperview()
|
||||
contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect
|
||||
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
|
||||
contentWarningOverlayView.isUserInteractionEnabled = true
|
||||
|
||||
container.spacing = UIView.separatorLineHeight(of: self) * 2 // 2px
|
||||
}
|
||||
|
||||
struct ConfigurableMosaic {
|
||||
let imageView: UIImageView
|
||||
let blurhashOverlayImageView: UIImageView
|
||||
let imageViewSize: CGSize
|
||||
}
|
||||
|
||||
func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic {
|
||||
reset()
|
||||
|
||||
let contentView = UIView()
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addArrangedSubview(contentView)
|
||||
|
||||
let imageViewSize: CGSize = {
|
||||
let rect = AVMakeRect(
|
||||
aspectRatio: aspectRatio,
|
||||
insideRect: CGRect(origin: .zero, size: maxSize)
|
||||
).integral
|
||||
return rect.size
|
||||
}()
|
||||
let imageViewFrame = CGRect(origin: .zero, size: imageViewSize)
|
||||
|
||||
let imageView = imageViews[0]
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
imageView.widthAnchor.constraint(equalToConstant: imageViewFrame.width).priority(.required - 1),
|
||||
])
|
||||
containerHeightLayoutConstraint.constant = imageViewFrame.height
|
||||
containerHeightLayoutConstraint.isActive = true
|
||||
|
||||
let blurhashOverlayImageView = blurhashOverlayImageViews[0]
|
||||
blurhashOverlayImageView.layer.masksToBounds = true
|
||||
blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
blurhashOverlayImageView.layer.cornerCurve = .continuous
|
||||
blurhashOverlayImageView.contentMode = .scaleAspectFill
|
||||
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(blurhashOverlayImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
||||
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
||||
])
|
||||
|
||||
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
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 ConfigurableMosaic(
|
||||
imageView: imageView,
|
||||
blurhashOverlayImageView: blurhashOverlayImageView,
|
||||
imageViewSize: imageViewSize
|
||||
)
|
||||
}
|
||||
|
||||
func setupImageViews(count: Int, maxSize: CGSize) -> [ConfigurableMosaic] {
|
||||
reset()
|
||||
let count = min(4, max(0, count))
|
||||
guard count > 1 else {
|
||||
return []
|
||||
}
|
||||
|
||||
let maxHeight = maxSize.height
|
||||
let spacing: CGFloat = 1
|
||||
|
||||
containerHeightLayoutConstraint.constant = maxHeight
|
||||
containerHeightLayoutConstraint.isActive = true
|
||||
|
||||
let contentLeftStackView = UIStackView()
|
||||
let contentRightStackView = UIStackView()
|
||||
[contentLeftStackView, contentRightStackView].forEach { stackView in
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = spacing
|
||||
}
|
||||
container.addArrangedSubview(contentLeftStackView)
|
||||
container.addArrangedSubview(contentRightStackView)
|
||||
|
||||
let imageViews: [UIImageView] = (0..<count).map { i in self.imageViews[i] }
|
||||
let blurhashOverlayImageViews: [UIImageView] = (0..<count).map { i in self.blurhashOverlayImageViews[i] }
|
||||
|
||||
imageViews.forEach { imageView in
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
}
|
||||
blurhashOverlayImageViews.forEach { imageView in
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
}
|
||||
if count == 2 {
|
||||
contentLeftStackView.addArrangedSubview(imageViews[0])
|
||||
contentRightStackView.addArrangedSubview(imageViews[1])
|
||||
switch UIApplication.shared.userInterfaceLayoutDirection {
|
||||
case .rightToLeft:
|
||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
|
||||
default:
|
||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
}
|
||||
|
||||
} else if count == 3 {
|
||||
contentLeftStackView.addArrangedSubview(imageViews[0])
|
||||
contentRightStackView.addArrangedSubview(imageViews[1])
|
||||
contentRightStackView.addArrangedSubview(imageViews[2])
|
||||
switch UIApplication.shared.userInterfaceLayoutDirection {
|
||||
case .rightToLeft:
|
||||
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
default:
|
||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
}
|
||||
} else if count == 4 {
|
||||
contentLeftStackView.addArrangedSubview(imageViews[0])
|
||||
contentRightStackView.addArrangedSubview(imageViews[1])
|
||||
contentLeftStackView.addArrangedSubview(imageViews[2])
|
||||
contentRightStackView.addArrangedSubview(imageViews[3])
|
||||
switch UIApplication.shared.userInterfaceLayoutDirection {
|
||||
case .rightToLeft:
|
||||
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
default:
|
||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
|
||||
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||
}
|
||||
}
|
||||
|
||||
for (imageView, blurhashOverlayImageView) in zip(imageViews, blurhashOverlayImageViews) {
|
||||
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(blurhashOverlayImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
||||
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
var mosaics: [ConfigurableMosaic] = []
|
||||
for (i, (imageView, blurhashOverlayImageView)) in zip(imageViews, blurhashOverlayImageViews).enumerated() {
|
||||
let imageViewSize: CGSize = {
|
||||
switch (i, count) {
|
||||
case (_, 4):
|
||||
return CGSize(width: maxSize.width * 0.5 - spacing, height: maxSize.height * 0.5 - spacing)
|
||||
case (i, 3):
|
||||
let width = maxSize.width * 0.5 - spacing
|
||||
if i == 0 {
|
||||
return CGSize(width: width, height: maxSize.height)
|
||||
} else {
|
||||
return CGSize(width: width, height: maxSize.height * 0.5 - spacing)
|
||||
}
|
||||
case (_, 2):
|
||||
let width = maxSize.width * 0.5 - spacing
|
||||
return CGSize(width: width, height: maxSize.height)
|
||||
default:
|
||||
assertionFailure()
|
||||
return maxSize
|
||||
}
|
||||
}()
|
||||
imageView.frame.size = imageViewSize
|
||||
let mosaic = ConfigurableMosaic(
|
||||
imageView: imageView,
|
||||
blurhashOverlayImageView: blurhashOverlayImageView,
|
||||
imageViewSize: imageViewSize
|
||||
)
|
||||
mosaics.append(mosaic)
|
||||
}
|
||||
return mosaics
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// FIXME: refactor blurhash image and preview image
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
func setImageViews(alpha: CGFloat) {
|
||||
// blurhashOverlayImageViews.forEach { $0.alpha = alpha }
|
||||
imageViews.forEach { $0.alpha = alpha }
|
||||
}
|
||||
|
||||
func setImageView(alpha: CGFloat, index: Int) {
|
||||
// if index < blurhashOverlayImageViews.count {
|
||||
// blurhashOverlayImageViews[index].alpha = alpha
|
||||
// }
|
||||
if index < imageViews.count {
|
||||
imageViews[index].alpha = alpha
|
||||
}
|
||||
}
|
||||
|
||||
func thumbnail(at index: Int) -> UIImage? {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return nil }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
guard index < tuples.count else { return nil }
|
||||
let tuple = tuples[index]
|
||||
return tuple.1.image ?? tuple.0.image
|
||||
}
|
||||
|
||||
func thumbnails() -> [UIImage?] {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return [] }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in
|
||||
return imageView.image ?? blurhashOverlayImageView.image
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||
}
|
||||
|
||||
@objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
guard let imageView = sender.view as? UIImageView else { return }
|
||||
guard let index = imageViews.firstIndex(of: imageView) else { return }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index)
|
||||
delegate?.mosaicImageViewContainer(self, didTapImageView: imageView, atIndex: index)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG && canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
struct MosaicImageView_Previews: PreviewProvider {
|
||||
|
||||
static var images: [UIImage] {
|
||||
return ["bradley-dunn", "mrdongok", "lucas-ludwig", "markus-spiske"]
|
||||
.map { UIImage(named: $0)! }
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UIViewPreview(width: 375) {
|
||||
let view = MosaicImageViewContainer()
|
||||
let image = images[3]
|
||||
let mosaic = view.setupImageView(
|
||||
aspectRatio: image.size,
|
||||
maxSize: CGSize(width: 375, height: 400)
|
||||
)
|
||||
mosaic.imageView.image = image
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 400))
|
||||
.previewDisplayName("Portrait - one image")
|
||||
UIViewPreview(width: 375) {
|
||||
let view = MosaicImageViewContainer()
|
||||
let image = images[1]
|
||||
let mosaic = view.setupImageView(
|
||||
aspectRatio: image.size,
|
||||
maxSize: CGSize(width: 375, height: 400)
|
||||
)
|
||||
mosaic.imageView.layer.masksToBounds = true
|
||||
mosaic.imageView.layer.cornerRadius = 8
|
||||
mosaic.imageView.contentMode = .scaleAspectFill
|
||||
mosaic.imageView.image = image
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 400))
|
||||
.previewDisplayName("Landscape - one image")
|
||||
UIViewPreview(width: 375) {
|
||||
let view = MosaicImageViewContainer()
|
||||
let images = self.images.prefix(2)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
|
||||
for (i, mosaic) in mosaics.enumerated() {
|
||||
mosaic.imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 200))
|
||||
.previewDisplayName("two image")
|
||||
UIViewPreview(width: 375) {
|
||||
let view = MosaicImageViewContainer()
|
||||
let images = self.images.prefix(3)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
|
||||
for (i, mosaic) in mosaics.enumerated() {
|
||||
mosaic.imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 200))
|
||||
.previewDisplayName("three image")
|
||||
UIViewPreview(width: 375) {
|
||||
let view = MosaicImageViewContainer()
|
||||
let images = self.images.prefix(4)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
|
||||
for (i, mosaic) in mosaics.enumerated() {
|
||||
mosaic.imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 200))
|
||||
.previewDisplayName("four image")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
|
@ -1,125 +0,0 @@
|
|||
//
|
||||
// PlayerContainerView+MediaTypeIndicotorView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
extension PlayerContainerView {
|
||||
|
||||
final class MediaTypeIndicatorView: 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.MediaTypeIndicatorView {
|
||||
|
||||
private func _init() {
|
||||
backgroundColor = Asset.Colors.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.MediaTypeIndicatorView.roundedFont(weight: .heavy, fontSize: fontSize)
|
||||
label.text = "GIF"
|
||||
case .video:
|
||||
label.text = " "
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerContainerViewMediaTypeIndicatorView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UIViewPreview(width: 47) {
|
||||
let view = PlayerContainerView.MediaTypeIndicatorView()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
//
|
||||
// PlayerContainerView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/10.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import AVKit
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
protocol PlayerContainerViewDelegate: AnyObject {
|
||||
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
}
|
||||
|
||||
final class PlayerContainerView: UIView {
|
||||
static let cornerRadius: CGFloat = ContentWarningOverlayView.cornerRadius
|
||||
|
||||
private let container = UIView()
|
||||
private let touchBlockingView = TouchBlockingView()
|
||||
private var containerHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||
contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius)
|
||||
return contentWarningOverlayView
|
||||
}()
|
||||
|
||||
let playerViewController = AVPlayerViewController()
|
||||
|
||||
let blurhashOverlayImageView = UIImageView()
|
||||
let mediaTypeIndicatorView = MediaTypeIndicatorView()
|
||||
|
||||
weak var delegate: PlayerContainerViewDelegate?
|
||||
|
||||
private var isReadyForDisplayObservation: NSKeyValueObservation?
|
||||
let isReadyForDisplay = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerContainerView {
|
||||
private func _init() {
|
||||
// accessibility
|
||||
accessibilityIgnoresInvertColors = true
|
||||
|
||||
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
|
||||
|
||||
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
playerViewController.contentOverlayView!.addSubview(blurhashOverlayImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
blurhashOverlayImageView.topAnchor.constraint(equalTo: playerViewController.contentOverlayView!.topAnchor),
|
||||
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.leadingAnchor),
|
||||
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor),
|
||||
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
|
||||
])
|
||||
|
||||
// mediaType
|
||||
mediaTypeIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
playerViewController.contentOverlayView!.addSubview(mediaTypeIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
mediaTypeIndicatorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
|
||||
mediaTypeIndicatorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor),
|
||||
mediaTypeIndicatorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.height).priority(.required - 1),
|
||||
mediaTypeIndicatorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.width).priority(.required - 1),
|
||||
])
|
||||
|
||||
isReadyForDisplayObservation = playerViewController.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] playerViewController, _ in
|
||||
guard let self = self else { return }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isReadyForDisplay: %s", (#file as NSString).lastPathComponent, #line, #function, playerViewController.isReadyForDisplay.description)
|
||||
self.isReadyForDisplay.value = playerViewController.isReadyForDisplay
|
||||
}
|
||||
|
||||
contentWarningOverlayView.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentWarningOverlayViewDelegate
|
||||
extension PlayerContainerView: ContentWarningOverlayViewDelegate {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
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()
|
||||
|
||||
blurhashOverlayImageView.image = nil
|
||||
|
||||
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)
|
||||
).integral
|
||||
|
||||
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: rect.width).priority(.required - 1),
|
||||
])
|
||||
containerHeightLayoutConstraint.constant = rect.height
|
||||
containerHeightLayoutConstraint.isActive = true
|
||||
|
||||
playerViewController.view.frame.size = rect.size
|
||||
|
||||
contentWarningOverlayView.removeFromSuperview()
|
||||
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentWarningOverlayView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: touchBlockingView.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor)
|
||||
])
|
||||
|
||||
bringSubviewToFront(mediaTypeIndicatorView)
|
||||
|
||||
return playerViewController
|
||||
}
|
||||
|
||||
func setMediaKind(kind: VideoPlayerViewModel.Kind) {
|
||||
mediaTypeIndicatorView.setMediaKind(kind: kind)
|
||||
}
|
||||
|
||||
func setMediaIndicator(isHidden: Bool) {
|
||||
mediaTypeIndicatorView.alpha = isHidden ? 0 : 1
|
||||
}
|
||||
|
||||
}
|
|
@ -9,9 +9,10 @@ import UIKit
|
|||
import Combine
|
||||
import CoreDataStack
|
||||
import MastodonUI
|
||||
import AlamofireImage
|
||||
|
||||
extension MediaView {
|
||||
public static func configuration(status: Status) -> AnyPublisher<[MediaView.Configuration], Never> {
|
||||
public static func configuration(status: Status) -> [MediaView.Configuration] {
|
||||
func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo {
|
||||
MediaView.Configuration.VideoInfo(
|
||||
aspectRadio: attachment.size,
|
||||
|
@ -22,59 +23,72 @@ extension MediaView {
|
|||
}
|
||||
|
||||
let status = status.reblog ?? status
|
||||
return status.publisher(for: \.attachments)
|
||||
.map { attachments -> [MediaView.Configuration] in
|
||||
return attachments.map { attachment -> MediaView.Configuration in
|
||||
let configuration: MediaView.Configuration = {
|
||||
switch attachment.kind {
|
||||
case .image:
|
||||
let info = MediaView.Configuration.ImageInfo(
|
||||
aspectRadio: attachment.size,
|
||||
assetURL: attachment.assetURL
|
||||
)
|
||||
return .init(
|
||||
info: .image(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .video:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .video(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .gifv:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .gif(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .audio:
|
||||
// TODO:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .video(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
} // end switch
|
||||
}()
|
||||
let attachments = status.attachments
|
||||
let configurations = attachments.map { attachment -> MediaView.Configuration in
|
||||
let configuration: MediaView.Configuration = {
|
||||
switch attachment.kind {
|
||||
case .image:
|
||||
let info = MediaView.Configuration.ImageInfo(
|
||||
aspectRadio: attachment.size,
|
||||
assetURL: attachment.assetURL
|
||||
)
|
||||
return .init(
|
||||
info: .image(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .video:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .video(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .gifv:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .gif(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
case .audio:
|
||||
let info = videoInfo(from: attachment)
|
||||
return .init(
|
||||
info: .video(info: info),
|
||||
blurhash: attachment.blurhash
|
||||
)
|
||||
} // end switch
|
||||
}()
|
||||
|
||||
if let assetURL = configuration.assetURL,
|
||||
let blurhash = configuration.blurhash
|
||||
{
|
||||
AppContext.shared.blurhashImageCacheService.image(
|
||||
blurhash: blurhash,
|
||||
size: configuration.aspectRadio,
|
||||
url: assetURL
|
||||
)
|
||||
.assign(to: \.blurhashImage, on: configuration)
|
||||
.store(in: &configuration.blurhashImageDisposeBag)
|
||||
if let previewURL = configuration.previewURL,
|
||||
let url = URL(string: previewURL)
|
||||
{
|
||||
let placeholder = UIImage.placeholder(color: .systemGray6)
|
||||
let request = URLRequest(url: url)
|
||||
ImageDownloader.default.download(request) { response in
|
||||
switch response.result {
|
||||
case .success(let image):
|
||||
configuration.previewImage = image
|
||||
case .failure(let error):
|
||||
configuration.previewImage = placeholder
|
||||
}
|
||||
|
||||
configuration.isReveal = status.sensitive ? status.isMediaSensitiveToggled : true
|
||||
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
if let assetURL = configuration.assetURL,
|
||||
let blurhash = configuration.blurhash
|
||||
{
|
||||
AppContext.shared.blurhashImageCacheService.image(
|
||||
blurhash: blurhash,
|
||||
size: configuration.aspectRadio,
|
||||
url: assetURL
|
||||
)
|
||||
.assign(to: \.blurhashImage, on: configuration)
|
||||
.store(in: &configuration.blurhashImageDisposeBag)
|
||||
}
|
||||
|
||||
configuration.isReveal = status.sensitive ? status.isMediaSensitiveToggled : true
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
return configurations
|
||||
}
|
||||
}
|
||||
|
|
|
@ -272,9 +272,10 @@ extension StatusView {
|
|||
|
||||
viewModel.isMediaSensitive = status.sensitive && !status.attachments.isEmpty // some servers set media sensitive even empty attachments
|
||||
|
||||
MediaView.configuration(status: status)
|
||||
.assign(to: \.mediaViewConfigurations, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
let configurations = MediaView.configuration(status: status)
|
||||
if viewModel.mediaViewConfigurations != configurations {
|
||||
viewModel.mediaViewConfigurations = configurations
|
||||
}
|
||||
|
||||
status.publisher(for: \.isMediaSensitiveToggled)
|
||||
.assign(to: \.isMediaSensitiveToggled, on: viewModel)
|
||||
|
|
|
@ -33,6 +33,7 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate {
|
|||
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action)
|
||||
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView)
|
||||
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView)
|
||||
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
|
||||
// sourcery:end
|
||||
}
|
||||
|
||||
|
@ -80,5 +81,9 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell {
|
|||
func statusView(_ statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) {
|
||||
delegate?.tableViewCell(self, statusView: statusView, spoilerBannerViewDidPressed: bannerView)
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) {
|
||||
delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaSensitiveButtonDidPressed: button)
|
||||
}
|
||||
// sourcery:end
|
||||
}
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
//
|
||||
// AudioContainerViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/9.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class AudioContainerViewModel {
|
||||
|
||||
// static func configure(
|
||||
// cell: StatusCell,
|
||||
// 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 { [weak audioService] _ in
|
||||
// guard let audioService = audioService else { return }
|
||||
// if audioAttachment === audioService.attachment {
|
||||
// if audioService.isPlaying() {
|
||||
// audioService.pause()
|
||||
// } else {
|
||||
// audioService.resume()
|
||||
// }
|
||||
// if audioService.currentTimeSubject.value == 0 {
|
||||
// audioService.playAudio(audioAttachment: audioAttachment)
|
||||
// }
|
||||
// } else {
|
||||
// audioService.playAudio(audioAttachment: audioAttachment)
|
||||
// }
|
||||
// }
|
||||
// .store(in: &cell.disposeBag)
|
||||
// audioView.slider.maximumValue = Float(duration)
|
||||
// audioView.slider.publisher(for: .valueChanged)
|
||||
// .sink { [weak audioService] slider in
|
||||
// guard let audioService = audioService else { return }
|
||||
// let slider = slider as! UISlider
|
||||
// let time = TimeInterval(slider.value)
|
||||
// audioService.seekToTime(time: time)
|
||||
// }
|
||||
// .store(in: &cell.disposeBag)
|
||||
// observePlayer(cell: cell, audioAttachment: audioAttachment, audioService: audioService)
|
||||
// if audioAttachment != audioService.attachment {
|
||||
// configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// static func observePlayer(
|
||||
// cell: StatusCell,
|
||||
// audioAttachment: Attachment,
|
||||
// audioService: AudioPlaybackService
|
||||
// ) {
|
||||
// let audioView = cell.statusView.audioView
|
||||
// var lastCurrentTimeSubject: TimeInterval?
|
||||
// audioService.currentTimeSubject
|
||||
// .throttle(for: 0.008, scheduler: DispatchQueue.main, latest: true)
|
||||
// .compactMap { [weak audioService] time -> TimeInterval? in
|
||||
// defer {
|
||||
// lastCurrentTimeSubject = time
|
||||
// }
|
||||
// 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 !audioView.slider.isTracking else { return nil }
|
||||
// return TimeInterval(time)
|
||||
// }
|
||||
// .sink(receiveValue: { time in
|
||||
// audioView.timeLabel.text = time.asString(style: .positional)
|
||||
// audioView.slider.setValue(Float(time), animated: true)
|
||||
// })
|
||||
// .store(in: &cell.disposeBag)
|
||||
// audioService.playbackState
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink(receiveValue: { playbackState in
|
||||
// if audioAttachment === audioService.attachment {
|
||||
// configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState)
|
||||
// } else {
|
||||
// configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
// }
|
||||
// })
|
||||
// .store(in: &cell.disposeBag)
|
||||
// }
|
||||
|
||||
static func configureAudioView(
|
||||
audioView: AudioContainerView,
|
||||
audioAttachment: MastodonAttachment,
|
||||
playbackState: PlaybackState
|
||||
) {
|
||||
fatalError()
|
||||
// switch playbackState {
|
||||
// case .stopped:
|
||||
// audioView.playButton.isSelected = false
|
||||
// audioView.slider.isUserInteractionEnabled = false
|
||||
// audioView.slider.setValue(0, animated: false)
|
||||
// case .paused:
|
||||
// audioView.playButton.isSelected = false
|
||||
// audioView.slider.isUserInteractionEnabled = true
|
||||
// case .playing, .readyToPlay:
|
||||
// audioView.playButton.isSelected = true
|
||||
// audioView.slider.isUserInteractionEnabled = true
|
||||
// default:
|
||||
// assertionFailure()
|
||||
// }
|
||||
// guard let duration = audioAttachment.meta?.original?.duration else { return }
|
||||
// audioView.timeLabel.text = duration.asString(style: .positional)
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
//
|
||||
// MosaicImageViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-2-23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
|
||||
//struct MosaicImageViewModel {
|
||||
//
|
||||
// let metas: [MosaicMeta]
|
||||
//
|
||||
// init(mediaAttachments: [Attachment]) {
|
||||
// var metas: [MosaicMeta] = []
|
||||
// for element in mediaAttachments where element.type == .image {
|
||||
// guard let meta = element.meta,
|
||||
// let width = meta.original?.width,
|
||||
// let height = meta.original?.height,
|
||||
// let url = URL(string: element.url) else {
|
||||
// continue
|
||||
// }
|
||||
// let mosaicMeta = MosaicMeta(
|
||||
// previewURL: element.previewURL.flatMap { URL(string: $0) },
|
||||
// url: url,
|
||||
// size: CGSize(width: width, height: height),
|
||||
// blurhash: element.blurhash,
|
||||
// altText: element.descriptionString
|
||||
// )
|
||||
// metas.append(mosaicMeta)
|
||||
// }
|
||||
// self.metas = metas
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//
|
||||
//struct MosaicMeta {
|
||||
// static let edgeMaxLength: CGFloat = 20
|
||||
//
|
||||
// let previewURL: URL?
|
||||
// let url: URL
|
||||
// let size: CGSize
|
||||
// let blurhash: String?
|
||||
// let altText: String?
|
||||
//
|
||||
// func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
|
||||
// guard let blurhash = blurhash else {
|
||||
// return Just(nil).eraseToAnyPublisher()
|
||||
// }
|
||||
// return AppContext.shared.blurhashImageCacheService.image(blurhash: blurhash, size: size, url: url)
|
||||
// }
|
||||
//
|
||||
//}
|
|
@ -1,193 +0,0 @@
|
|||
//
|
||||
// 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.app.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)
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
player.publisher(for: \.status, options: [.initial, .new])
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
case .failed:
|
||||
self.playbackState.value = .failed
|
||||
case .readyToPlay:
|
||||
self.playbackState.value = .readyToPlay
|
||||
case .unknown:
|
||||
self.playbackState.value = .unknown
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
timeControlStatus
|
||||
.sink { [weak self] timeControlStatus in
|
||||
guard let self = self else { return }
|
||||
|
||||
// emit playing event
|
||||
if timeControlStatus == .playing {
|
||||
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
|
||||
}
|
||||
|
||||
switch timeControlStatus {
|
||||
case .paused:
|
||||
self.playbackState.value = .paused
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
self.playbackState.value = .buffering
|
||||
case .playing:
|
||||
self.playbackState.value = .playing
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
self.playbackState.value = .unknown
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
let sessionName = videoKind == .gif ? "GIF" : "Video"
|
||||
playbackState
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] status in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description)
|
||||
guard let self = self else { return }
|
||||
|
||||
// only update audio session for video
|
||||
guard self.videoKind == .video else { return }
|
||||
switch status {
|
||||
case .unknown, .buffering, .readyToPlay:
|
||||
break
|
||||
case .playing:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
case .paused, .stopped, .failed:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -78,7 +78,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
return imageView
|
||||
}()
|
||||
transitionItem.targetFrame = transitionTargetFrame
|
||||
transitionItem.imageView = transitionImageView
|
||||
transitionItem.transitionView = transitionImageView
|
||||
transitionContext.containerView.addSubview(transitionImageView)
|
||||
|
||||
toVC.closeButtonBackground.alpha = 0
|
||||
|
@ -109,120 +109,164 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
return animator
|
||||
}
|
||||
|
||||
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewController.currentIndex else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// assert view hierarchy not change
|
||||
let toVC = transitionItem.previewableViewController
|
||||
let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index)
|
||||
|
||||
let imageView = mediaPreviewImageViewController.previewImageView.imageView
|
||||
let _snapshot: UIView? = {
|
||||
transitionItem.snapshotRaw = imageView
|
||||
let snapshot = imageView.snapshotView(afterScreenUpdates: false)
|
||||
snapshot?.clipsToBounds = true
|
||||
snapshot?.contentMode = .scaleAspectFill
|
||||
return snapshot
|
||||
}()
|
||||
guard let snapshot = _snapshot else {
|
||||
transitionContext.completeTransition(false)
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let transitionMaskView = UIView(frame: transitionContext.containerView.bounds)
|
||||
transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
transitionContext.containerView.addSubview(transitionMaskView)
|
||||
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = transitionMaskView.bounds
|
||||
let maskLayerFromPath = UIBezierPath(rect: maskLayer.bounds).cgPath
|
||||
maskLayer.path = maskLayerFromPath
|
||||
transitionMaskView.layer.mask = maskLayer
|
||||
|
||||
transitionMaskView.addSubview(snapshot)
|
||||
snapshot.center = transitionMaskView.center
|
||||
fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground)
|
||||
|
||||
transitionItem.imageView = imageView
|
||||
transitionItem.snapshotTransitioning = snapshot
|
||||
transitionItem.initialFrame = snapshot.frame
|
||||
transitionItem.targetFrame = targetFrame
|
||||
|
||||
// disable interaction
|
||||
fromVC.pagingViewController.isUserInteractionEnabled = false
|
||||
|
||||
@discardableResult
|
||||
private func popTransition(
|
||||
using transitionContext: UIViewControllerContextTransitioning,
|
||||
curve: UIView.AnimationCurve = .easeInOut
|
||||
) -> UIViewPropertyAnimator {
|
||||
let animator = popInteractiveTransitionAnimator
|
||||
|
||||
self.transitionItem.snapshotRaw?.alpha = 0.0
|
||||
|
||||
var needsMaskWithAnimation = true
|
||||
let maskLayerToRect: CGRect? = {
|
||||
guard case .attachments = transitionItem.source else { return nil }
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||
|
||||
// crop rect top edge
|
||||
var rect = transitionMaskView.frame
|
||||
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
|
||||
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
|
||||
rect.origin.y = toViewFrameInWindow.minY
|
||||
} else {
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
||||
}
|
||||
|
||||
if rect.minY < snapshot.frame.minY {
|
||||
needsMaskWithAnimation = false
|
||||
}
|
||||
|
||||
return rect
|
||||
}()
|
||||
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||
let maskLayerToFinalRect: CGRect? = {
|
||||
guard case .attachments = transitionItem.source else { return nil }
|
||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||
// clip tabBar when bar visible
|
||||
guard let tabBarController = toVC.tabBarController,
|
||||
!tabBarController.tabBar.isHidden,
|
||||
let tabBarSuperView = tabBarController.tabBar.superview
|
||||
else { return rect }
|
||||
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||
guard offset > 0 else { return rect }
|
||||
rect.size.height -= offset
|
||||
return rect
|
||||
}()
|
||||
let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||
|
||||
if !needsMaskWithAnimation, let maskLayerToPath = maskLayerToPath {
|
||||
maskLayer.path = maskLayerToPath
|
||||
animator.addCompletion { position in
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
|
||||
animator.addAnimations {
|
||||
if let targetFrame = targetFrame {
|
||||
self.transitionItem.snapshotTransitioning?.frame = targetFrame
|
||||
} else {
|
||||
fromView.alpha = 0
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let index = fromVC.pagingViewController.currentIndex,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewTransitionViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewTransitionViewController,
|
||||
let mediaPreviewTransitionContext = mediaPreviewTransitionViewController.mediaPreviewTransitionContext
|
||||
else {
|
||||
animator.addAnimations {
|
||||
self.transitionItem.source.updateAppearance(position: .end, index: nil)
|
||||
}
|
||||
self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 }
|
||||
return animator
|
||||
}
|
||||
|
||||
// update close button
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
|
||||
fromVC.closeButtonBackground.alpha = 0
|
||||
fromVC.visualEffectView.effect = nil
|
||||
if let maskLayerToFinalPath = maskLayerToFinalPath {
|
||||
maskLayer.path = maskLayerToFinalPath
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
|
||||
fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// update view controller
|
||||
fromVC.pagingViewController.isUserInteractionEnabled = false
|
||||
animator.addCompletion { position in
|
||||
fromVC.pagingViewController.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
// update background
|
||||
let blurEffect = fromVC.visualEffectView.effect
|
||||
animator.addAnimations {
|
||||
fromVC.visualEffectView.effect = nil
|
||||
if UIAccessibility.isReduceTransparencyEnabled {
|
||||
fromVC.visualEffectView.alpha = 0
|
||||
}
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
fromVC.visualEffectView.effect = position == .end ? nil : blurEffect
|
||||
if UIAccessibility.isReduceTransparencyEnabled {
|
||||
fromVC.visualEffectView.alpha = position == .end ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// update transition item source
|
||||
animator.addCompletion { position in
|
||||
if position == .end {
|
||||
// reset appearance
|
||||
self.transitionItem.source.updateAppearance(position: position, index: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// update transitioning snapshot
|
||||
let transitionMaskView = UIView(frame: transitionContext.containerView.bounds)
|
||||
transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
transitionContext.containerView.addSubview(transitionMaskView)
|
||||
transitionItem.interactiveTransitionMaskView = transitionMaskView
|
||||
|
||||
animator.addCompletion { position in
|
||||
transitionMaskView.removeFromSuperview()
|
||||
}
|
||||
|
||||
let transitionMaskViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
transitionMaskViewTapGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.transitionMaskViewTapGestureRecognizerHandler(_:)))
|
||||
transitionMaskView.addGestureRecognizer(transitionMaskViewTapGestureRecognizer)
|
||||
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = transitionMaskView.bounds
|
||||
maskLayer.path = UIBezierPath(rect: maskLayer.bounds).cgPath
|
||||
transitionMaskView.layer.mask = maskLayer
|
||||
transitionItem.interactiveTransitionMaskLayer = maskLayer
|
||||
|
||||
// attach transitioning snapshot
|
||||
mediaPreviewTransitionContext.snapshot.center = transitionMaskView.center
|
||||
mediaPreviewTransitionContext.snapshot.contentMode = .scaleAspectFill
|
||||
mediaPreviewTransitionContext.snapshot.clipsToBounds = true
|
||||
transitionMaskView.addSubview(mediaPreviewTransitionContext.snapshot)
|
||||
fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground)
|
||||
|
||||
transitionItem.transitionView = mediaPreviewTransitionContext.transitionView
|
||||
transitionItem.snapshotTransitioning = mediaPreviewTransitionContext.snapshot
|
||||
transitionItem.initialFrame = mediaPreviewTransitionContext.snapshot.frame
|
||||
|
||||
// assert view hierarchy not change
|
||||
let toVC = transitionItem.previewableViewController
|
||||
let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index)
|
||||
transitionItem.targetFrame = targetFrame
|
||||
|
||||
animator.addAnimations {
|
||||
self.transitionItem.snapshotTransitioning?.layer.cornerRadius = self.transitionItem.sourceImageViewCornerRadius ?? 0
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
self.transitionItem.snapshotTransitioning?.layer.cornerRadius = position == .end ? 0 : (self.transitionItem.sourceImageViewCornerRadius ?? 0)
|
||||
}
|
||||
|
||||
if !isInteractive {
|
||||
animator.addAnimations {
|
||||
if let targetFrame = targetFrame {
|
||||
self.transitionItem.snapshotTransitioning?.frame = targetFrame
|
||||
} else {
|
||||
fromView.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
// calculate transition mask
|
||||
let maskLayerToRect: CGRect? = {
|
||||
guard case .attachments = transitionItem.source else { return nil }
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||
|
||||
// crop rect top edge
|
||||
var rect = transitionMaskView.frame
|
||||
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
|
||||
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
|
||||
rect.origin.y = toViewFrameInWindow.minY
|
||||
} else {
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
||||
}
|
||||
|
||||
return rect
|
||||
}()
|
||||
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||
let maskLayerToFinalRect: CGRect? = {
|
||||
guard case .attachments = transitionItem.source else { return nil }
|
||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||
// clip tabBar when bar visible
|
||||
guard let tabBarController = toVC.tabBarController,
|
||||
!tabBarController.tabBar.isHidden,
|
||||
let tabBarSuperView = tabBarController.tabBar.superview
|
||||
else { return rect }
|
||||
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||
guard offset > 0 else { return rect }
|
||||
rect.size.height -= offset
|
||||
return rect
|
||||
}()
|
||||
let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||
|
||||
if let maskLayerToPath = maskLayerToPath {
|
||||
maskLayer.path = maskLayerToPath
|
||||
}
|
||||
}
|
||||
|
||||
mediaPreviewTransitionContext.transitionView.isHidden = true
|
||||
animator.addCompletion { position in
|
||||
self.transitionItem.transitionView?.isHidden = position == .end
|
||||
self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0
|
||||
self.transitionItem.snapshotTransitioning?.removeFromSuperview()
|
||||
self.transitionItem.source.updateAppearance(position: position, index: nil)
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
|
||||
return animator
|
||||
|
@ -248,100 +292,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
}
|
||||
|
||||
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let _ = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewController.currentIndex else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// assert view hierarchy not change
|
||||
let toVC = transitionItem.previewableViewController
|
||||
let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index)
|
||||
|
||||
let imageView = mediaPreviewImageViewController.previewImageView.imageView
|
||||
let _snapshot: UIView? = {
|
||||
transitionItem.snapshotRaw = imageView
|
||||
let snapshot = imageView.snapshotView(afterScreenUpdates: false)
|
||||
snapshot?.clipsToBounds = true
|
||||
snapshot?.contentMode = .scaleAspectFill
|
||||
return snapshot
|
||||
}()
|
||||
guard let snapshot = _snapshot else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
let transitionMaskView = UIView(frame: transitionContext.containerView.bounds)
|
||||
transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
transitionContext.containerView.addSubview(transitionMaskView)
|
||||
transitionItem.interactiveTransitionMaskView = transitionMaskView
|
||||
|
||||
let transitionMaskViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
transitionMaskViewTapGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.transitionMaskViewTapGestureRecognizerHandler(_:)))
|
||||
transitionMaskView.addGestureRecognizer(transitionMaskViewTapGestureRecognizer)
|
||||
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = transitionMaskView.bounds
|
||||
maskLayer.path = UIBezierPath(rect: maskLayer.bounds).cgPath
|
||||
transitionMaskView.layer.mask = maskLayer
|
||||
transitionItem.interactiveTransitionMaskLayer = maskLayer
|
||||
|
||||
transitionMaskView.addSubview(snapshot)
|
||||
snapshot.center = transitionMaskView.center
|
||||
fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground)
|
||||
|
||||
transitionItem.imageView = imageView
|
||||
transitionItem.snapshotTransitioning = snapshot
|
||||
transitionItem.initialFrame = snapshot.frame
|
||||
transitionItem.targetFrame = targetFrame ?? snapshot.frame
|
||||
|
||||
// disable interaction
|
||||
fromVC.pagingViewController.isUserInteractionEnabled = false
|
||||
|
||||
let animator = popInteractiveTransitionAnimator
|
||||
|
||||
let blurEffect = fromVC.visualEffectView.effect
|
||||
self.transitionItem.snapshotRaw?.alpha = 0.0
|
||||
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
|
||||
fromVC.closeButtonBackground.alpha = 0
|
||||
}
|
||||
|
||||
animator.addAnimations {
|
||||
switch self.transitionItem.source {
|
||||
case .profileBanner:
|
||||
self.transitionItem.snapshotTransitioning?.alpha = 0.4
|
||||
default:
|
||||
break
|
||||
}
|
||||
fromVC.visualEffectView.effect = nil
|
||||
self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 }
|
||||
if UIAccessibility.isReduceTransparencyEnabled {
|
||||
fromVC.visualEffectView.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
animator.addCompletion { position in
|
||||
fromVC.pagingViewController.isUserInteractionEnabled = true
|
||||
fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1
|
||||
self.transitionItem.imageView?.isHidden = position == .end
|
||||
self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0
|
||||
self.transitionItem.snapshotTransitioning?.removeFromSuperview()
|
||||
if position == .end {
|
||||
// reset appearance
|
||||
self.transitionItem.source.updateAppearance(position: position, index: nil)
|
||||
}
|
||||
fromVC.visualEffectView.effect = position == .end ? nil : blurEffect
|
||||
transitionMaskView.removeFromSuperview()
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
|
||||
fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1
|
||||
}
|
||||
if UIAccessibility.isReduceTransparencyEnabled {
|
||||
fromVC.visualEffectView.alpha = position == .end ? 0 : 1
|
||||
}
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
popTransition(using: transitionContext)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -399,7 +350,9 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
}
|
||||
|
||||
private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector {
|
||||
guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else {
|
||||
guard let currentFrame = item?.transitionView?.frame,
|
||||
let targetFrame = item?.targetFrame
|
||||
else {
|
||||
return CGVector.zero
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ class MediaPreviewTransitionItem: Identifiable {
|
|||
var previewableViewController: MediaPreviewableViewController
|
||||
|
||||
// source
|
||||
// value maybe invalid when preview paging
|
||||
var image: UIImage?
|
||||
var aspectRatio: CGSize?
|
||||
var initialFrame: CGRect? = nil
|
||||
|
@ -27,14 +26,18 @@ class MediaPreviewTransitionItem: Identifiable {
|
|||
var targetFrame: CGRect? = nil
|
||||
|
||||
// transitioning
|
||||
var imageView: UIImageView?
|
||||
var transitionView: UIView?
|
||||
var snapshotRaw: UIView?
|
||||
var snapshotTransitioning: UIView?
|
||||
var touchOffset: CGVector = CGVector.zero
|
||||
var interactiveTransitionMaskView: UIView?
|
||||
var interactiveTransitionMaskLayer: CAShapeLayer?
|
||||
|
||||
init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) {
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
source: Source,
|
||||
previewableViewController: MediaPreviewableViewController
|
||||
) {
|
||||
self.id = id
|
||||
self.source = source
|
||||
self.previewableViewController = previewableViewController
|
||||
|
@ -56,7 +59,7 @@ extension MediaPreviewTransitionItem {
|
|||
mediaView.alpha = alpha
|
||||
case .attachments(let mediaGridContainerView):
|
||||
if let index = index {
|
||||
mediaGridContainerView.setAlpha(0, index: index)
|
||||
mediaGridContainerView.setAlpha(alpha, index: index)
|
||||
} else {
|
||||
mediaGridContainerView.setAlpha(alpha)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// MediaPreviewTransitionViewController.swift
|
||||
// TwidereX
|
||||
//
|
||||
// Created by MainasuK on 2021-12-8.
|
||||
// Copyright © 2021 Twidere. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol MediaPreviewTransitionViewController: UIViewController {
|
||||
var mediaPreviewTransitionContext: MediaPreviewTransitionContext? { get }
|
||||
}
|
||||
|
||||
|
||||
struct MediaPreviewTransitionContext {
|
||||
let transitionView: UIView
|
||||
let snapshot: UIView
|
||||
let snapshotTransitioning: UIView
|
||||
}
|
|
@ -1,150 +0,0 @@
|
|||
//
|
||||
// AudioPlayer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
final class AudioPlaybackService: NSObject {
|
||||
|
||||
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.audio-playback-service.appWillPlayAudio")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var player = AVPlayer()
|
||||
var timeObserver: Any?
|
||||
var statusObserver: Any?
|
||||
var attachment: MastodonAttachment?
|
||||
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
addObserver()
|
||||
|
||||
playbackState
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { status in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description)
|
||||
switch status {
|
||||
case .unknown, .buffering, .readyToPlay:
|
||||
break
|
||||
case .playing:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
case .paused, .stopped, .failed:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlaybackService {
|
||||
func playAudio(audioAttachment: MastodonAttachment) {
|
||||
guard let assetURL = audioAttachment.assetURL,
|
||||
let url = URL(string: assetURL) else
|
||||
{ return }
|
||||
|
||||
notifyWillPlayAudioNotification()
|
||||
if audioAttachment == attachment {
|
||||
if self.playbackState.value == .stopped {
|
||||
self.seekToTime(time: .zero)
|
||||
}
|
||||
player.play()
|
||||
self.playbackState.value = .playing
|
||||
return
|
||||
}
|
||||
player.pause()
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
attachment = audioAttachment
|
||||
player.play()
|
||||
playbackState.value = .playing
|
||||
}
|
||||
|
||||
func addObserver() {
|
||||
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 }
|
||||
self.currentTimeSubject.value = time.seconds
|
||||
})
|
||||
player.publisher(for: \.status, options: [.initial, .new])
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
case .failed:
|
||||
self.playbackState.value = .failed
|
||||
case .readyToPlay:
|
||||
self.playbackState.value = .readyToPlay
|
||||
case .unknown:
|
||||
self.playbackState.value = .unknown
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.player.seek(to: .zero)
|
||||
self.playbackState.value = .stopped
|
||||
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 playbackState.value == .readyToPlay || playbackState.value == .playing
|
||||
}
|
||||
func resume() {
|
||||
notifyWillPlayAudioNotification()
|
||||
player.play()
|
||||
playbackState.value = .playing
|
||||
}
|
||||
|
||||
func pause() {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
//
|
||||
// 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: "org.joinmastodon.app.VideoPlaybackService.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoPlaybackService {
|
||||
func dequeueVideoPlayerViewModel(for media: MastodonAttachment) -> VideoPlayerViewModel? {
|
||||
// Core Data entity not thread-safe. Save attribute before enter working queue
|
||||
guard let assetURL = media.assetURL,
|
||||
let url = URL(string: assetURL),
|
||||
media.kind == .gifv || media.kind == .video
|
||||
else { return nil }
|
||||
|
||||
let previewImageURL = media.previewURL.flatMap { URL(string: $0) }
|
||||
let videoKind: VideoPlayerViewModel.Kind = media.kind == .gifv ? .gif : .video
|
||||
|
||||
var _viewModel: VideoPlayerViewModel?
|
||||
workingQueue.sync {
|
||||
if let viewModel = viewPlayerViewModelDict[url] {
|
||||
_viewModel = viewModel
|
||||
} else {
|
||||
let viewModel = VideoPlayerViewModel(
|
||||
previewImageURL: previewImageURL,
|
||||
videoURL: url,
|
||||
videoSize: media.size,
|
||||
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 status: Status) {
|
||||
// TODO:
|
||||
// guard let videoAttachment = status.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 disappear 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,8 +25,6 @@ class AppContext: ObservableObject {
|
|||
let apiService: APIService
|
||||
let authenticationService: AuthenticationService
|
||||
let emojiService: EmojiService
|
||||
let audioPlaybackService = AudioPlaybackService()
|
||||
let videoPlaybackService = VideoPlaybackService()
|
||||
let statusPublishService = StatusPublishService()
|
||||
let notificationService: NotificationService
|
||||
let settingService: SettingService
|
||||
|
|
|
@ -24,6 +24,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
// configure appearance
|
||||
ThemeService.shared.apply(theme: ThemeService.shared.currentTheme.value)
|
||||
|
||||
// configure AudioSession
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||
|
||||
// Update app version info. See: `Settings.bundle`
|
||||
UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion")
|
||||
UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle")
|
||||
|
|
|
@ -113,7 +113,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
AppContext.shared.audioPlaybackService.pauseIfNeed()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
//
|
||||
// AudioViewContainer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
//public final class AudioContainerView: UIView {
|
||||
// static let cornerRadius: CGFloat = 22
|
||||
//
|
||||
// let container: UIStackView = {
|
||||
// let stackView = UIStackView()
|
||||
// stackView.axis = .horizontal
|
||||
// stackView.distribution = .fill
|
||||
// stackView.alignment = .center
|
||||
// stackView.spacing = 11
|
||||
// stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||
// stackView.isLayoutMarginsRelativeArrangement = true
|
||||
// stackView.layer.cornerRadius = AudioContainerView.cornerRadius
|
||||
// stackView.clipsToBounds = true
|
||||
// stackView.backgroundColor = Asset.Colors.brandBlue.color
|
||||
// stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// return stackView
|
||||
// }()
|
||||
//
|
||||
// let playButtonBackgroundView: UIView = {
|
||||
// let view = UIView()
|
||||
// view.layer.cornerRadius = 16
|
||||
// view.clipsToBounds = true
|
||||
// view.backgroundColor = Asset.Colors.brandBlue.color
|
||||
// view.translatesAutoresizingMaskIntoConstraints = false
|
||||
// return view
|
||||
// }()
|
||||
//
|
||||
// let playButton: UIButton = {
|
||||
// let button = HighlightDimmableButton(type: .custom)
|
||||
// let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
// button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
//
|
||||
// let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
// button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected)
|
||||
//
|
||||
// button.tintColor = .white
|
||||
// button.translatesAutoresizingMaskIntoConstraints = false
|
||||
// button.isEnabled = true
|
||||
// return button
|
||||
// }()
|
||||
//
|
||||
// let slider: UISlider = {
|
||||
// let slider = UISlider()
|
||||
// slider.isContinuous = true
|
||||
// slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
// slider.minimumTrackTintColor = Asset.Colors.Slider.track.color
|
||||
// slider.maximumTrackTintColor = Asset.Colors.Slider.track.color
|
||||
// if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) {
|
||||
// slider.setThumbImage(image, for: .normal)
|
||||
// }
|
||||
// return slider
|
||||
// }()
|
||||
//
|
||||
// let timeLabel: UILabel = {
|
||||
// let label = UILabel()
|
||||
// label.translatesAutoresizingMaskIntoConstraints = false
|
||||
// label.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
// label.textColor = .white
|
||||
// label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
|
||||
// return label
|
||||
// }()
|
||||
//
|
||||
// override init(frame: CGRect) {
|
||||
// super.init(frame: frame)
|
||||
// _init()
|
||||
// }
|
||||
//
|
||||
// required init?(coder: NSCoder) {
|
||||
// super.init(coder: coder)
|
||||
// _init()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension AudioContainerView {
|
||||
// private func _init() {
|
||||
// addSubview(container)
|
||||
// NSLayoutConstraint.activate([
|
||||
// container.topAnchor.constraint(equalTo: topAnchor),
|
||||
// container.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
// trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
// bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
// ])
|
||||
//
|
||||
// // checkmark
|
||||
// playButtonBackgroundView.addSubview(playButton)
|
||||
// container.addArrangedSubview(playButtonBackgroundView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
|
||||
// playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
|
||||
// playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
// playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
// ])
|
||||
//
|
||||
// container.addArrangedSubview(slider)
|
||||
//
|
||||
// container.addArrangedSubview(timeLabel)
|
||||
// NSLayoutConstraint.activate([
|
||||
// timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1),
|
||||
// ])
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension AudioContainerView {
|
||||
// public struct Configuration: Hashable {
|
||||
//
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//#if canImport(SwiftUI) && DEBUG
|
||||
//import SwiftUI
|
||||
//
|
||||
//struct AudioContainerView_Previews: PreviewProvider {
|
||||
//
|
||||
// static var previews: some View {
|
||||
// UIViewPreview(width: 375) {
|
||||
// AudioContainerView()
|
||||
// }
|
||||
// .previewLayout(.fixed(width: 375, height: 100))
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//#endif
|
||||
//
|
|
@ -12,47 +12,18 @@ extension MediaGridContainerView {
|
|||
public class ViewModel {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
||||
@Published public var isSensitiveToggleButtonDisplay: Bool = false
|
||||
@Published public var isContentWarningOverlayDisplay: Bool? = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaGridContainerView.ViewModel {
|
||||
|
||||
func resetContentWarningOverlay() {
|
||||
isContentWarningOverlayDisplay = nil
|
||||
}
|
||||
|
||||
func bind(view: MediaGridContainerView) {
|
||||
$isSensitiveToggleButtonDisplay
|
||||
.sink { isDisplay in
|
||||
view.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
$isContentWarningOverlayDisplay
|
||||
.sink { isDisplay in
|
||||
assert(Thread.isMainThread)
|
||||
guard let isDisplay = isDisplay else { return }
|
||||
let withAnimation = self.isContentWarningOverlayDisplay != nil
|
||||
view.configureOverlayDisplay(isDisplay: isDisplay, animated: withAnimation)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaGridContainerView {
|
||||
func configureOverlayDisplay(isDisplay: Bool, animated: Bool) {
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) {
|
||||
self.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0
|
||||
}
|
||||
} else {
|
||||
contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0
|
||||
}
|
||||
|
||||
contentWarningOverlayView.isUserInteractionEnabled = isDisplay
|
||||
contentWarningOverlayView.tapGestureRecognizer.isEnabled = isDisplay
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,11 +12,12 @@ import func AVFoundation.AVMakeRect
|
|||
|
||||
public protocol MediaGridContainerViewDelegate: AnyObject {
|
||||
func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int)
|
||||
func mediaGridContainerView(_ container: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func mediaGridContainerView(_ container: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
|
||||
}
|
||||
|
||||
public final class MediaGridContainerView: UIView {
|
||||
|
||||
static let sensitiveToggleButtonSize = CGSize(width: 34, height: 34)
|
||||
public static let maxCount = 9
|
||||
|
||||
let logger = Logger(subsystem: "MediaGridContainerView", category: "UI")
|
||||
|
@ -51,25 +52,19 @@ public final class MediaGridContainerView: UIView {
|
|||
let sensitiveToggleButtonBlurVisualEffectView: UIVisualEffectView = {
|
||||
let visualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
|
||||
visualEffectView.layer.masksToBounds = true
|
||||
visualEffectView.layer.cornerRadius = 6
|
||||
visualEffectView.layer.cornerRadius = MediaGridContainerView.sensitiveToggleButtonSize.width / 2
|
||||
visualEffectView.layer.cornerCurve = .continuous
|
||||
return visualEffectView
|
||||
}()
|
||||
let sensitiveToggleButtonVibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
|
||||
let sensitiveToggleButton: HitTestExpandedButton = {
|
||||
let button = HitTestExpandedButton(type: .system)
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
|
||||
button.imageView?.contentMode = .scaleAspectFit
|
||||
button.setImage(UIImage(systemName: "eye.slash.fill"), for: .normal)
|
||||
return button
|
||||
}()
|
||||
|
||||
public let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||
let overlay = ContentWarningOverlayView()
|
||||
overlay.layer.masksToBounds = true
|
||||
overlay.layer.cornerRadius = MediaView.cornerRadius
|
||||
overlay.layer.cornerCurve = .continuous
|
||||
return overlay
|
||||
}()
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
|
@ -85,7 +80,6 @@ public final class MediaGridContainerView: UIView {
|
|||
extension MediaGridContainerView {
|
||||
private func _init() {
|
||||
sensitiveToggleButton.addTarget(self, action: #selector(MediaGridContainerView.sensitiveToggleButtonDidPressed(_:)), for: .touchUpInside)
|
||||
contentWarningOverlayView.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,7 +93,7 @@ extension MediaGridContainerView {
|
|||
|
||||
@objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView)
|
||||
delegate?.mediaGridContainerView(self, mediaSensitiveButtonDidPressed: sender)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,9 +108,6 @@ extension MediaGridContainerView {
|
|||
layoutSensitiveToggleButton()
|
||||
bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView)
|
||||
|
||||
layoutContentOverlayView(on: mediaView)
|
||||
bringSubviewToFront(contentWarningOverlayView)
|
||||
|
||||
return mediaView
|
||||
}
|
||||
|
||||
|
@ -129,9 +120,6 @@ extension MediaGridContainerView {
|
|||
layoutSensitiveToggleButton()
|
||||
bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView)
|
||||
|
||||
layoutContentOverlayView(on: self)
|
||||
bringSubviewToFront(contentWarningOverlayView)
|
||||
|
||||
return mediaViews
|
||||
}
|
||||
|
||||
|
@ -156,8 +144,8 @@ extension MediaGridContainerView {
|
|||
sensitiveToggleButtonBlurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(sensitiveToggleButtonBlurVisualEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
sensitiveToggleButtonBlurVisualEffectView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
||||
sensitiveToggleButtonBlurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||
sensitiveToggleButtonBlurVisualEffectView.topAnchor.constraint(equalTo: topAnchor, constant: 16),
|
||||
trailingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.trailingAnchor, constant: 16),
|
||||
])
|
||||
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -172,21 +160,12 @@ extension MediaGridContainerView {
|
|||
sensitiveToggleButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.contentView.addSubview(sensitiveToggleButton)
|
||||
NSLayoutConstraint.activate([
|
||||
sensitiveToggleButton.topAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.topAnchor, constant: 4),
|
||||
sensitiveToggleButton.leadingAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.leadingAnchor, constant: 4),
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.contentView.trailingAnchor.constraint(equalTo: sensitiveToggleButton.trailingAnchor, constant: 4),
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.contentView.bottomAnchor.constraint(equalTo: sensitiveToggleButton.bottomAnchor, constant: 4),
|
||||
])
|
||||
}
|
||||
|
||||
private func layoutContentOverlayView(on view: UIView) {
|
||||
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentWarningOverlayView) // should add to container
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningOverlayView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
contentWarningOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
sensitiveToggleButton.topAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.topAnchor),
|
||||
sensitiveToggleButton.leadingAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.leadingAnchor),
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.contentView.trailingAnchor.constraint(equalTo: sensitiveToggleButton.trailingAnchor),
|
||||
sensitiveToggleButtonVibrancyVisualEffectView.contentView.bottomAnchor.constraint(equalTo: sensitiveToggleButton.bottomAnchor),
|
||||
sensitiveToggleButton.widthAnchor.constraint(equalToConstant: MediaGridContainerView.sensitiveToggleButtonSize.width).priority(.required - 1),
|
||||
sensitiveToggleButton.heightAnchor.constraint(equalToConstant: MediaGridContainerView.sensitiveToggleButtonSize.height).priority(.required - 1),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -328,10 +307,3 @@ extension MediaGridContainerView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentWarningOverlayViewDelegate
|
||||
extension MediaGridContainerView: ContentWarningOverlayViewDelegate {
|
||||
public func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,10 +14,13 @@ import Photos
|
|||
extension MediaView {
|
||||
public class Configuration: Hashable {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
public let info: Info
|
||||
public let blurhash: String?
|
||||
|
||||
@Published public var isReveal = true
|
||||
@Published public var previewImage: UIImage?
|
||||
@Published public var blurhashImage: UIImage?
|
||||
public var blurhashImageDisposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
@ -37,6 +40,17 @@ extension MediaView {
|
|||
}
|
||||
}
|
||||
|
||||
public var previewURL: String? {
|
||||
switch info {
|
||||
case .image(let info):
|
||||
return info.assetURL
|
||||
case .gif(let info):
|
||||
return info.previewURL
|
||||
case .video(let info):
|
||||
return info.previewURL
|
||||
}
|
||||
}
|
||||
|
||||
public var assetURL: String? {
|
||||
switch info {
|
||||
case .image(let info):
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import AVKit
|
||||
import UIKit
|
||||
import Combine
|
||||
import AlamofireImage
|
||||
|
||||
public final class MediaView: UIView {
|
||||
|
||||
|
@ -46,9 +47,17 @@ public final class MediaView: UIView {
|
|||
let playerViewController = AVPlayerViewController()
|
||||
playerViewController.view.layer.masksToBounds = true
|
||||
playerViewController.view.isUserInteractionEnabled = false
|
||||
playerViewController.videoGravity = .resizeAspectFill
|
||||
playerViewController.updatesNowPlayingInfoCenter = false
|
||||
return playerViewController
|
||||
}()
|
||||
private var playerLooper: AVPlayerLooper?
|
||||
private(set) lazy var playbackImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.image = UIImage(systemName: "play.circle.fill")
|
||||
imageView.tintColor = .white
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private(set) lazy var indicatorBlurEffectView: UIVisualEffectView = {
|
||||
let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
|
||||
|
@ -60,12 +69,12 @@ public final class MediaView: UIView {
|
|||
private(set) lazy var indicatorVibrancyEffectView = UIVisualEffectView(
|
||||
effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))
|
||||
)
|
||||
// private(set) lazy var playerIndicatorLabel: UILabel = {
|
||||
// let label = UILabel()
|
||||
// label.font = .preferredFont(forTextStyle: .caption1)
|
||||
// label.textColor = .secondaryLabel
|
||||
// return label
|
||||
// }()
|
||||
private(set) lazy var playerIndicatorLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .caption1)
|
||||
label.textColor = .secondaryLabel
|
||||
return label
|
||||
}()
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -83,11 +92,11 @@ extension MediaView {
|
|||
|
||||
@MainActor
|
||||
public func thumbnail() async -> UIImage? {
|
||||
return imageView.image
|
||||
return imageView.image ?? configuration?.previewImage
|
||||
}
|
||||
|
||||
public func thumbnail() -> UIImage? {
|
||||
return imageView.image
|
||||
return imageView.image ?? configuration?.previewImage
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -104,40 +113,21 @@ extension MediaView {
|
|||
|
||||
switch configuration.info {
|
||||
case .image(let info):
|
||||
configure(image: info)
|
||||
layoutImage()
|
||||
bindImage(configuration: configuration, info: info)
|
||||
case .gif(let info):
|
||||
configure(gif: info)
|
||||
layoutGIF()
|
||||
bindGIF(configuration: configuration, info: info)
|
||||
case .video(let info):
|
||||
configure(video: info)
|
||||
layoutVideo()
|
||||
bindVideo(configuration: configuration, info: info)
|
||||
}
|
||||
|
||||
if let blurhash = configuration.blurhash {
|
||||
configure(blurhash: blurhash)
|
||||
|
||||
configuration.$blurhashImage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.image, on: blurhashImageView)
|
||||
.store(in: &_disposeBag)
|
||||
|
||||
blurhashImageView.alpha = configuration.isReveal ? 0 : 1
|
||||
}
|
||||
|
||||
configuration.$isReveal
|
||||
.dropFirst()
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReveal in
|
||||
guard let self = self else { return }
|
||||
let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
self.blurhashImageView.alpha = isReveal ? 0 : 1
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
.store(in: &_disposeBag)
|
||||
layoutBlurhash()
|
||||
bindBlurhash(configuration: configuration)
|
||||
}
|
||||
|
||||
private func configure(image info: Configuration.ImageInfo) {
|
||||
private func layoutImage() {
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -146,20 +136,24 @@ extension MediaView {
|
|||
imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
let placeholder = UIImage.placeholder(color: .systemGray6)
|
||||
guard let urlString = info.assetURL,
|
||||
let url = URL(string: urlString) else {
|
||||
imageView.image = placeholder
|
||||
return
|
||||
}
|
||||
imageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: placeholder
|
||||
)
|
||||
}
|
||||
|
||||
private func configure(gif info: Configuration.VideoInfo) {
|
||||
private func bindImage(configuration: Configuration, info: Configuration.ImageInfo) {
|
||||
Publishers.CombineLatest3(
|
||||
configuration.$isReveal,
|
||||
configuration.$previewImage,
|
||||
configuration.$blurhashImage
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReveal, previewImage, blurhashImage in
|
||||
guard let self = self else { return }
|
||||
let image = isReveal ? previewImage : blurhashImage
|
||||
self.imageView.image = image
|
||||
}
|
||||
.store(in: &configuration.disposeBag)
|
||||
}
|
||||
|
||||
private func layoutGIF() {
|
||||
// use view controller as View here
|
||||
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(playerViewController.view)
|
||||
|
@ -170,18 +164,11 @@ extension MediaView {
|
|||
playerViewController.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
assert(playerViewController.contentOverlayView != nil)
|
||||
if let contentOverlayView = playerViewController.contentOverlayView {
|
||||
indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentOverlayView.addSubview(indicatorBlurEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentOverlayView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11),
|
||||
contentOverlayView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8),
|
||||
])
|
||||
setupIndicatorViewHierarchy()
|
||||
}
|
||||
// playerIndicatorLabel.attributedText = NSAttributedString(AttributedString("GIF"))
|
||||
setupIndicatorViewHierarchy()
|
||||
playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF")
|
||||
}
|
||||
|
||||
private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) {
|
||||
guard let player = setupGIFPlayer(info: info) else { return }
|
||||
setupPlayerLooper(player: player)
|
||||
playerViewController.player = player
|
||||
|
@ -191,20 +178,33 @@ extension MediaView {
|
|||
player.play()
|
||||
}
|
||||
|
||||
private func configure(video info: Configuration.VideoInfo) {
|
||||
private func layoutVideo() {
|
||||
layoutImage()
|
||||
|
||||
playbackImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(playbackImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
playbackImageView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
playbackImageView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||
playbackImageView.widthAnchor.constraint(equalToConstant: 88).priority(.required - 1),
|
||||
playbackImageView.heightAnchor.constraint(equalToConstant: 88).priority(.required - 1),
|
||||
])
|
||||
}
|
||||
|
||||
private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) {
|
||||
let imageInfo = Configuration.ImageInfo(
|
||||
aspectRadio: info.aspectRadio,
|
||||
assetURL: info.previewURL
|
||||
)
|
||||
configure(image: imageInfo)
|
||||
bindImage(configuration: configuration, info: imageInfo)
|
||||
|
||||
indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.addSubview(indicatorBlurEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11),
|
||||
imageView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8),
|
||||
])
|
||||
setupIndicatorViewHierarchy()
|
||||
// indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// imageView.addSubview(indicatorBlurEffectView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// imageView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11),
|
||||
// imageView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8),
|
||||
// ])
|
||||
// setupIndicatorViewHierarchy()
|
||||
|
||||
// playerIndicatorLabel.attributedText = {
|
||||
// let imageAttachment = NSTextAttachment(image: UIImage(systemName: "play.fill")!)
|
||||
|
@ -221,10 +221,9 @@ extension MediaView {
|
|||
// attributedString.foregroundColor = .secondaryLabel
|
||||
// return NSAttributedString(attributedString)
|
||||
// }()
|
||||
|
||||
}
|
||||
|
||||
private func configure(blurhash: String) {
|
||||
private func layoutBlurhash() {
|
||||
blurhashImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(blurhashImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -233,8 +232,28 @@ extension MediaView {
|
|||
blurhashImageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
blurhashImageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
blurhashImageView.backgroundColor = .systemGray
|
||||
private func bindBlurhash(configuration: Configuration) {
|
||||
configuration.$blurhashImage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.image, on: blurhashImageView)
|
||||
.store(in: &_disposeBag)
|
||||
blurhashImageView.alpha = configuration.isReveal ? 0 : 1
|
||||
|
||||
configuration.$isReveal
|
||||
.dropFirst()
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReveal in
|
||||
guard let self = self else { return }
|
||||
let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
self.blurhashImageView.alpha = isReveal ? 0 : 1
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
.store(in: &_disposeBag)
|
||||
}
|
||||
|
||||
public func prepareForReuse() {
|
||||
|
@ -305,29 +324,39 @@ extension MediaView {
|
|||
}
|
||||
|
||||
private func setupIndicatorViewHierarchy() {
|
||||
// let blurEffectView = indicatorBlurEffectView
|
||||
// let vibrancyEffectView = indicatorVibrancyEffectView
|
||||
//
|
||||
// if vibrancyEffectView.superview == nil {
|
||||
// vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// blurEffectView.contentView.addSubview(vibrancyEffectView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// vibrancyEffectView.topAnchor.constraint(equalTo: blurEffectView.contentView.topAnchor),
|
||||
// vibrancyEffectView.leadingAnchor.constraint(equalTo: blurEffectView.contentView.leadingAnchor),
|
||||
// vibrancyEffectView.trailingAnchor.constraint(equalTo: blurEffectView.contentView.trailingAnchor),
|
||||
// vibrancyEffectView.bottomAnchor.constraint(equalTo: blurEffectView.contentView.bottomAnchor),
|
||||
// ])
|
||||
// }
|
||||
//
|
||||
// if playerIndicatorLabel.superview == nil {
|
||||
// playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
// vibrancyEffectView.contentView.addSubview(playerIndicatorLabel)
|
||||
// NSLayoutConstraint.activate([
|
||||
// playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor),
|
||||
// playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3),
|
||||
// vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3),
|
||||
// playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor),
|
||||
// ])
|
||||
// }
|
||||
let blurEffectView = indicatorBlurEffectView
|
||||
let vibrancyEffectView = indicatorVibrancyEffectView
|
||||
|
||||
assert(playerViewController.contentOverlayView != nil)
|
||||
if let contentOverlayView = playerViewController.contentOverlayView {
|
||||
blurEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentOverlayView.addSubview(indicatorBlurEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentOverlayView.trailingAnchor.constraint(equalTo: blurEffectView.trailingAnchor, constant: 16),
|
||||
contentOverlayView.bottomAnchor.constraint(equalTo: blurEffectView.bottomAnchor, constant: 8),
|
||||
])
|
||||
}
|
||||
|
||||
if vibrancyEffectView.superview == nil {
|
||||
vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
blurEffectView.contentView.addSubview(vibrancyEffectView)
|
||||
NSLayoutConstraint.activate([
|
||||
vibrancyEffectView.topAnchor.constraint(equalTo: blurEffectView.contentView.topAnchor),
|
||||
vibrancyEffectView.leadingAnchor.constraint(equalTo: blurEffectView.contentView.leadingAnchor),
|
||||
vibrancyEffectView.trailingAnchor.constraint(equalTo: blurEffectView.contentView.trailingAnchor),
|
||||
vibrancyEffectView.bottomAnchor.constraint(equalTo: blurEffectView.contentView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
if playerIndicatorLabel.superview == nil {
|
||||
playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
vibrancyEffectView.contentView.addSubview(playerIndicatorLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor),
|
||||
playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3),
|
||||
vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3),
|
||||
playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -388,6 +388,10 @@ extension NotificationView: StatusViewDelegate {
|
|||
assertionFailure()
|
||||
}
|
||||
|
||||
public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) {
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - MastodonMenuDelegate
|
||||
|
|
|
@ -53,6 +53,9 @@ extension StatusView {
|
|||
// Media
|
||||
@Published public var mediaViewConfigurations: [MediaView.Configuration] = []
|
||||
|
||||
// Audio
|
||||
@Published public var audioConfigurations: [MediaView.Configuration] = []
|
||||
|
||||
// Poll
|
||||
@Published public var pollItems: [PollItem] = []
|
||||
@Published public var isVotable: Bool = false
|
||||
|
@ -121,9 +124,9 @@ extension StatusView {
|
|||
isMediaSensitive = false
|
||||
isMediaSensitiveToggled = false
|
||||
|
||||
isSensitive = false
|
||||
isContentReveal = false
|
||||
isMediaReveal = false
|
||||
// isSensitive = false
|
||||
// isContentReveal = false
|
||||
// isMediaReveal = false
|
||||
}
|
||||
|
||||
init() {
|
||||
|
@ -154,7 +157,8 @@ extension StatusView {
|
|||
$isMediaSensitive,
|
||||
$isMediaSensitiveToggled
|
||||
)
|
||||
.map { $0 ? $1 : true }
|
||||
.map { $1 ? !$0 : $0 }
|
||||
.map { !$0 }
|
||||
.assign(to: &$isMediaReveal)
|
||||
}
|
||||
}
|
||||
|
@ -375,6 +379,8 @@ extension StatusView.ViewModel {
|
|||
guard let self = self else { return }
|
||||
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media")
|
||||
|
||||
statusView.mediaGridContainerView.prepareForReuse()
|
||||
|
||||
let maxSize = CGSize(
|
||||
width: statusView.contentMaxLayoutWidth,
|
||||
height: 9999 // fulfill the width
|
||||
|
@ -419,18 +425,11 @@ extension StatusView.ViewModel {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// FIXME:
|
||||
statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = false
|
||||
// $isMediaReveal
|
||||
// .sink { isMediaReveal in
|
||||
// statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = isMediaReveal
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
// $isMediaSensitiveSwitchable
|
||||
// .sink { isMediaSensitiveSwitchable in
|
||||
// statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaSensitiveSwitchable
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
$isMediaReveal
|
||||
.sink { isMediaReveal in
|
||||
statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaReveal
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func bindPoll(statusView: StatusView) {
|
||||
|
|
|
@ -24,6 +24,7 @@ public protocol StatusViewDelegate: AnyObject {
|
|||
func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action)
|
||||
func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView)
|
||||
func statusView(_ statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView)
|
||||
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
|
||||
// func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
// func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
}
|
||||
|
@ -212,7 +213,6 @@ public final class StatusView: UIView {
|
|||
viewModel.prepareForReuse()
|
||||
|
||||
avatarButton.avatarImageView.cancelTask()
|
||||
mediaGridContainerView.prepareForReuse()
|
||||
if var snapshot = pollTableViewDiffableDataSource?.snapshot() {
|
||||
snapshot.deleteAllItems()
|
||||
if #available(iOS 15.0, *) {
|
||||
|
@ -430,14 +430,18 @@ extension StatusView.Style {
|
|||
// authorPrimaryMetaContainer: H - [ authorNameLabel | (padding) | menuButton ]
|
||||
let authorPrimaryMetaContainer = UIStackView()
|
||||
authorPrimaryMetaContainer.axis = .horizontal
|
||||
authorPrimaryMetaContainer.spacing = 10
|
||||
authorMetaContainer.addArrangedSubview(authorPrimaryMetaContainer)
|
||||
|
||||
// authorNameLabel
|
||||
authorPrimaryMetaContainer.addArrangedSubview(statusView.authorNameLabel)
|
||||
statusView.authorNameLabel.setContentHuggingPriority(.required - 10, for: .horizontal)
|
||||
statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal)
|
||||
authorPrimaryMetaContainer.addArrangedSubview(UIView())
|
||||
// menuButton
|
||||
authorPrimaryMetaContainer.addArrangedSubview(statusView.menuButton)
|
||||
statusView.menuButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
statusView.menuButton.setContentHuggingPriority(.required - 2, for: .horizontal)
|
||||
statusView.menuButton.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
|
||||
|
||||
// authorSecondaryMetaContainer: H - [ authorUsername | usernameTrialingDotLabel | dateLabel | (padding) ]
|
||||
let authorSecondaryMetaContainer = UIStackView()
|
||||
|
@ -728,8 +732,8 @@ extension StatusView: MediaGridContainerViewDelegate {
|
|||
delegate?.statusView(self, mediaGridContainerView: container, mediaView: mediaView, didSelectMediaViewAt: index)
|
||||
}
|
||||
|
||||
public func mediaGridContainerView(_ container: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
fatalError()
|
||||
public func mediaGridContainerView(_ container: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) {
|
||||
delegate?.statusView(self, mediaGridContainerView: container, mediaSensitiveButtonDidPressed: button)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue