From 582843f54a9e911e97180c14376eaf6bbdb2de2b Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 9 Feb 2022 20:35:19 +0800 Subject: [PATCH] feat: add video player for audio/video kind media --- Mastodon.xcodeproj/project.pbxproj | 70 +-- .../xcschemes/xcschememanagement.plist | 6 +- ...dency+AVPlayerViewControllerDelegate.swift | 21 - ...Provider+StatusTableViewCellDelegate.swift | 28 +- .../Image/MediaPreviewImageView.swift | 0 .../MediaPreviewImageViewController.swift | 18 + .../Image/MediaPreviewImageViewModel.swift | 0 .../MediaPreviewViewController.swift | 24 +- .../MediaPreview/MediaPreviewViewModel.swift | 106 ++-- .../MediaPreviewVideoViewController.swift | 156 ++++++ .../Video/MediaPreviewVideoViewModel.swift | 140 +++++ .../View/Container/AudioContainerView.swift | 131 ----- .../Container/MosaicImageViewContainer.swift | 497 ------------------ ...ContainerView+MediaTypeIndicotorView.swift | 125 ----- .../View/Container/PlayerContainerView.swift | 179 ------- .../Content/MediaView+Configuration.swift | 118 +++-- .../Content/StatusView+Configuration.swift | 7 +- .../StatusTableViewCellDelegate.swift | 5 + .../ViewModel/AudioContainerViewModel.swift | 118 ----- .../ViewModel/MosaicImageViewModel.swift | 55 -- .../ViewModel/VideoPlayerViewModel.swift | 193 ------- ...wViewControllerAnimatedTransitioning.swift | 353 ++++++------- .../MediaPreviewTransitionItem.swift | 13 +- ...MediaPreviewTransitionViewController.swift | 20 + Mastodon/Service/AudioPlaybackService.swift | 150 ------ Mastodon/Service/VideoPlaybackService.swift | 141 ----- Mastodon/State/AppContext.swift | 2 - Mastodon/Supporting Files/AppDelegate.swift | 3 + Mastodon/Supporting Files/SceneDelegate.swift | 1 - .../View/Container/AudioContainerView.swift | 136 +++++ .../MediaGridContainerView+ViewModel.swift | 29 - .../Container/MediaGridContainerView.swift | 56 +- .../Content/MediaView+Configuration.swift | 14 + .../MastodonUI/View/Content/MediaView.swift | 221 ++++---- .../View/Content/NotificationView.swift | 4 + .../View/Content/StatusView+ViewModel.swift | 31 +- .../MastodonUI/View/Content/StatusView.swift | 48 +- 37 files changed, 1017 insertions(+), 2202 deletions(-) delete mode 100644 Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift rename Mastodon/Scene/MediaPreview/{Paging => }/Image/MediaPreviewImageView.swift (100%) rename Mastodon/Scene/MediaPreview/{Paging => }/Image/MediaPreviewImageViewController.swift (92%) rename Mastodon/Scene/MediaPreview/{Paging => }/Image/MediaPreviewImageViewModel.swift (100%) create mode 100644 Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift create mode 100644 Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift delete mode 100644 Mastodon/Scene/Share/View/Container/AudioContainerView.swift delete mode 100644 Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift delete mode 100644 Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift delete mode 100644 Mastodon/Scene/Share/View/Container/PlayerContainerView.swift delete mode 100644 Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift delete mode 100644 Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift delete mode 100644 Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift delete mode 100644 Mastodon/Service/AudioPlaybackService.swift delete mode 100644 Mastodon/Service/VideoPlaybackService.swift create mode 100644 MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b6ef24ba..01b8698e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; - 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; - 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; @@ -773,7 +764,6 @@ 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+DomainBlock.swift"; sourceTree = ""; }; 2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = ""; }; 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; - 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -812,11 +802,7 @@ 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; }; 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; - 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlaybackService.swift; sourceTree = ""; }; - 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; - 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; }; - 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -989,7 +975,6 @@ DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB4B777F26CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; DB4B778226CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; DB4B778326CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Intents.stringsdict; sourceTree = ""; }; @@ -1196,8 +1181,6 @@ DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; - DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewContainer.swift; sourceTree = ""; }; - DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = ""; }; @@ -1239,6 +1222,9 @@ DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = ""; }; + DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewController.swift; sourceTree = ""; }; + DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = ""; }; + DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -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 = ""; @@ -2842,23 +2826,9 @@ path = Profile; sourceTree = ""; }; - DB9D6C1325E4F97A0051B173 /* Container */ = { - isa = PBXGroup; - children = ( - DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, - 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, - 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, - ); - path = Container; - sourceTree = ""; - }; 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 = ""; }; + DBB45B5727B39FCC002DC5A7 /* Video */ = { + isa = PBXGroup; + children = ( + DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */, + DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */, + ); + path = Video; + sourceTree = ""; + }; 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 */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index a5240e44..7e9982d3 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ AppShared.xcscheme_^#shared#^_ orderHint - 25 + 20 CoreDataStack.xcscheme_^#shared#^_ @@ -97,7 +97,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 27 + 19 MastodonIntents.xcscheme_^#shared#^_ @@ -117,7 +117,7 @@ ShareActionExtension.xcscheme_^#shared#^_ orderHint - 26 + 18 SuppressBuildableAutocreation diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift deleted file mode 100644 index e52fdc05..00000000 --- a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift +++ /dev/null @@ -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 - } - -} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index a348085b..9b3b9a37 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -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 + } + } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageView.swift similarity index 100% rename from Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift rename to Mastodon/Scene/MediaPreview/Image/MediaPreviewImageView.swift diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift similarity index 92% rename from Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift rename to Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift index 27712b9a..127c4c0c 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift @@ -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 + ) + } +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift similarity index 100% rename from Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift rename to Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 543ba8d6..b8355c3a 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -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) } } @@ -147,6 +165,10 @@ extension MediaPreviewViewController: MediaPreviewingViewController { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible %s", ((#file as NSString).lastPathComponent), #line, #function, dismissible ? "true" : "false") 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 diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 2de19b26..5912e559 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -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 { diff --git a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift new file mode 100644 index 00000000..5d5dec84 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift @@ -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() + 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 + ) + } +} + diff --git a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift new file mode 100644 index 00000000..7485bdb4 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift @@ -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() + + // 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? + } + +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift deleted file mode 100644 index 0dd11d13..00000000 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ /dev/null @@ -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 - diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift deleted file mode 100644 index a6223044..00000000 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ /dev/null @@ -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.. 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 diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift deleted file mode 100644 index faa7b8f6..00000000 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ /dev/null @@ -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 - diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift deleted file mode 100644 index 2d398536..00000000 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ /dev/null @@ -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(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 - } - -} diff --git a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift index 52651d81..ad2fa398 100644 --- a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift @@ -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 - }() - - 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) + 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 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 } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift index 249c2cfc..d3a39ccd 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -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) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift index 10f28455..1c556b9c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -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 } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift deleted file mode 100644 index bec9ab12..00000000 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ /dev/null @@ -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) - } -} diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift deleted file mode 100644 index 8f53e4dd..00000000 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ /dev/null @@ -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 { -// guard let blurhash = blurhash else { -// return Just(nil).eraseToAnyPublisher() -// } -// return AppContext.shared.blurhashImageCacheService.image(blurhash: blurhash, size: size, url: url) -// } -// -//} diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift deleted file mode 100644 index 61a437e0..00000000 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ /dev/null @@ -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() - - 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() - - private var updateDate = Date() - - // output - let player: AVPlayer - private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) - - private var timeControlStatusObservation: NSKeyValueObservation? - let timeControlStatus = CurrentValueSubject(.paused) - let playbackState = CurrentValueSubject(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() - } -} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 8078ba1c..f730e0b8 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -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,122 +109,166 @@ 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 - self.transitionItem.snapshotTransitioning?.removeFromSuperview() - self.transitionItem.source.updateAppearance(position: position, index: nil) - transitionContext.completeTransition(position == .end) + 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() + } + 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) } } @@ -380,7 +331,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { popInteractiveTransitionAnimator.fractionComplete = percent transitionContext.updateInteractiveTransition(percent) updateTransitionItemPosition(of: translation) - + // Reset translation to zero sender.setTranslation(CGPoint.zero, in: transitionContext.containerView) case .ended, .cancelled: @@ -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 } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 42efde45..7d80de32 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -16,25 +16,28 @@ class MediaPreviewTransitionItem: Identifiable { var previewableViewController: MediaPreviewableViewController // source - // value maybe invalid when preview paging var image: UIImage? var aspectRatio: CGSize? var initialFrame: CGRect? = nil var sourceImageView: UIImageView? var sourceImageViewCornerRadius: CGFloat? - + // target 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) } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift new file mode 100644 index 00000000..d1809d0f --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift @@ -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 +} diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift deleted file mode 100644 index af7d574c..00000000 --- a/Mastodon/Service/AudioPlaybackService.swift +++ /dev/null @@ -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() - - var player = AVPlayer() - var timeObserver: Any? - var statusObserver: Any? - var attachment: MastodonAttachment? - - let playbackState = CurrentValueSubject(PlaybackState.unknown) - - let currentTimeSubject = CurrentValueSubject(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() - } -} diff --git a/Mastodon/Service/VideoPlaybackService.swift b/Mastodon/Service/VideoPlaybackService.swift deleted file mode 100644 index 3c5ad8a0..00000000 --- a/Mastodon/Service/VideoPlaybackService.swift +++ /dev/null @@ -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() - - 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() - } - } -} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 0b7e37d4..9de19c44 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -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 diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 6d7919c6..5989f80a 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -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") diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 1ed70276..79ce1543 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -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() } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift new file mode 100644 index 00000000..d23759a3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift @@ -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 +// diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift index c460d669..92c5972b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift @@ -12,47 +12,18 @@ extension MediaGridContainerView { public class ViewModel { var disposeBag = Set() - @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 - } -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index fd33b72d..a461dd9c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -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) } } @@ -113,9 +107,6 @@ extension MediaGridContainerView { layoutSensitiveToggleButton() bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) - - layoutContentOverlayView(on: mediaView) - bringSubviewToFront(contentWarningOverlayView) return mediaView } @@ -128,9 +119,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) - } -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index cb4d742b..6026e668 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -14,10 +14,13 @@ import Photos extension MediaView { public class Configuration: Hashable { + var disposeBag = Set() + 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() @@ -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): diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index e51330d7..68847e74 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -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 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 configure(gif info: Configuration.VideoInfo) { + 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), ]) + } + + 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 - blurhashImageView.backgroundColor = .systemGray + 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), + ]) + } } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index fd8d3c32..50dd38be 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -388,6 +388,10 @@ extension NotificationView: StatusViewDelegate { assertionFailure() } + public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { + assertionFailure() + } + } // MARK: - MastodonMenuDelegate diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index ec61db16..dc162110 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -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) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index fa0dde84..6f1eac0f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -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, *) { @@ -407,7 +407,7 @@ extension StatusView.Style { statusView.authorContainerView.alignment = category > .accessibilityLarge ? .leading : .center } .store(in: &statusView._disposeBag) - + // avatarButton let authorAvatarButtonSize = CGSize(width: 46, height: 46) statusView.avatarButton.size = authorAvatarButtonSize @@ -420,25 +420,29 @@ extension StatusView.Style { ]) statusView.avatarButton.setContentHuggingPriority(.required - 1, for: .vertical) statusView.avatarButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) - + // authrMetaContainer: V - [ authorPrimaryMetaContainer | authorSecondaryMetaContainer ] let authorMetaContainer = UIStackView() authorMetaContainer.axis = .vertical authorMetaContainer.spacing = 4 statusView.authorContainerView.addArrangedSubview(authorMetaContainer) - + // 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() authorSecondaryMetaContainer.axis = .horizontal @@ -455,22 +459,22 @@ extension StatusView.Style { statusView.dateLabel.setContentHuggingPriority(.required - 1, for: .horizontal) statusView.dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) authorSecondaryMetaContainer.addArrangedSubview(UIView()) - + // content container: V - [ contentMetaText ] statusView.contentContainer.axis = .vertical statusView.contentContainer.spacing = 12 statusView.contentContainer.distribution = .fill statusView.contentContainer.alignment = .top - + statusView.contentContainer.preservesSuperviewLayoutMargins = true statusView.contentContainer.isLayoutMarginsRelativeArrangement = true statusView.containerStackView.addArrangedSubview(statusView.contentContainer) statusView.contentContainer.setContentHuggingPriority(.required - 1, for: .vertical) statusView.contentContainer.setContentCompressionResistancePriority(.required - 1, for: .vertical) - + // status content statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView) - + statusView.spoilerOverlayView.translatesAutoresizingMaskIntoConstraints = false statusView.containerStackView.addSubview(statusView.spoilerOverlayView) NSLayoutConstraint.activate([ @@ -479,10 +483,10 @@ extension StatusView.Style { statusView.contentContainer.trailingAnchor.constraint(equalTo: statusView.spoilerOverlayView.trailingAnchor), statusView.contentContainer.bottomAnchor.constraint(equalTo: statusView.spoilerOverlayView.bottomAnchor), ]) - + // media container: V - [ mediaGridContainerView ] statusView.containerStackView.addArrangedSubview(statusView.mediaContainerView) - + statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false statusView.mediaContainerView.addSubview(statusView.mediaGridContainerView) NSLayoutConstraint.activate([ @@ -491,20 +495,20 @@ extension StatusView.Style { statusView.mediaGridContainerView.trailingAnchor.constraint(equalTo: statusView.mediaContainerView.trailingAnchor), statusView.mediaGridContainerView.bottomAnchor.constraint(equalTo: statusView.mediaContainerView.bottomAnchor), ]) - + // pollContainerView: V - [ pollTableView | pollStatusStackView ] statusView.pollContainerView.axis = .vertical statusView.pollContainerView.preservesSuperviewLayoutMargins = true statusView.pollContainerView.isLayoutMarginsRelativeArrangement = true statusView.containerStackView.addArrangedSubview(statusView.pollContainerView) - + // pollTableView statusView.pollContainerView.addArrangedSubview(statusView.pollTableView) - + // pollStatusStackView statusView.pollStatusStackView.axis = .horizontal statusView.pollContainerView.addArrangedSubview(statusView.pollStatusStackView) - + statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteCountLabel) statusView.pollStatusStackView.addArrangedSubview(statusView.pollStatusDotLabel) statusView.pollStatusStackView.addArrangedSubview(statusView.pollCountdownLabel) @@ -514,14 +518,14 @@ extension StatusView.Style { statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - + // statusVisibilityView statusView.statusVisibilityView.preservesSuperviewLayoutMargins = true statusView.containerStackView.addArrangedSubview(statusView.statusVisibilityView) - + statusView.spoilerBannerView.preservesSuperviewLayoutMargins = true statusView.containerStackView.addArrangedSubview(statusView.spoilerBannerView) - + // action toolbar statusView.actionToolbarContainer.configure(for: .inline) statusView.actionToolbarContainer.preservesSuperviewLayoutMargins = true @@ -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) } }