feat: add video player for audio/video kind media

This commit is contained in:
CMK 2022-02-09 20:35:19 +08:00
parent 1789e6eb86
commit 582843f54a
37 changed files with 1017 additions and 2202 deletions

View File

@ -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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -388,6 +388,10 @@ extension NotificationView: StatusViewDelegate {
assertionFailure()
}
public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) {
assertionFailure()
}
}
// MARK: - MastodonMenuDelegate

View File

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

View File

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