From 98eddf13370e39936f55aaaf4d504b8d9c96248d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 18 Mar 2024 22:21:13 +0000 Subject: [PATCH 1/5] Small tweak to resolve build error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel D’Aquino Link: 20240318222048.14226-2-daniel@daquino.me Signed-off-by: William Casarin --- damus/Views/Wallet/WalletView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index 096bebdb..4dc88010 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -34,7 +34,7 @@ struct WalletView: View { Divider() - RelayView(state: damus_state, relay: nwc.relay.id, showActionButtons: .constant(false)) + RelayView(state: damus_state, relay: nwc.relay.id, showActionButtons: .constant(false), recommended: false) } .frame(maxWidth: .infinity, minHeight: 125, alignment: .top) .padding(.horizontal, 10) From 671b0b67ce2a45c57159fff8cd457541325bd5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 18 Mar 2024 22:21:21 +0000 Subject: [PATCH 2/5] Add playback controls to videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit includes several UX changes to give users better control over video playback. It also, by design, work arounds a SwiftUI quirk* Here are the changes to the UX: 1. Videos on the feed only have a mute/unmute button 2. When the user clicks on the video, they are taken to a full screen carousel view (similar to when you click on an image) 3. The full-screen carousel view shows all video playback controls (through a specific SwiftUI hack) 4. If the carousel has multiple videos/images, the user can swipe between them normally as expected Other UI changes that were made: - The full screen carousel now uses dark mode (black background, white close button) * The SwiftUI quirk is that when video views are placed within a TabView with ".page" tab view style, the tabview consumes most of the user gestures, making the video playback controls unusable. Changelog-Changed: Improve UX around video playback Signed-off-by: Daniel D’Aquino Link: 20240318222048.14226-3-daniel@daquino.me Signed-off-by: William Casarin --- damus.xcodeproj/project.pbxproj | 36 +++-- damus/Components/ImageCarousel.swift | 8 +- .../Views/Images/FullScreenCarouselView.swift | 127 ++++++++++++++++++ damus/Views/Images/ImageContainerView.swift | 17 ++- damus/Views/Images/ImageView.swift | 90 ------------- damus/Views/Images/ProfilePicImageView.swift | 19 ++- ...ayerView.swift => DamusAVPlayerView.swift} | 7 +- damus/Views/Video/DamusVideoPlayer.swift | 46 ++++++- .../Video/DamusVideoPlayerViewModel.swift | 8 +- 9 files changed, 225 insertions(+), 133 deletions(-) create mode 100644 damus/Views/Images/FullScreenCarouselView.swift delete mode 100644 damus/Views/Images/ImageView.swift rename damus/Views/Video/{AVPlayerView.swift => DamusAVPlayerView.swift} (73%) diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index de4189af..919fb273 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -377,7 +377,7 @@ 4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFD502E2A2DA45800A229DB /* MediaView.swift */; }; 4CFF8F5929C9FD1E008DB934 /* DamusPurpleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */; }; 4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */; }; - 4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */; }; + 4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */; }; 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */; }; 4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */; }; 4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; @@ -388,7 +388,7 @@ 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A62A34915F006AE6DC /* RelayModel.swift */; }; 504323A92A3495B6006AE6DC /* RelayModelCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A82A3495B6006AE6DC /* RelayModelCache.swift */; }; 5053ACA72A56DF3B00851AE3 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */; }; - 50A16FFB2AA6C06600DFEC1F /* AVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */; }; + 50A16FFB2AA6C06600DFEC1F /* DamusAVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */; }; 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */; }; 50A16FFF2AA76A0900DFEC1F /* VideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */; }; 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; }; @@ -433,8 +433,6 @@ B59CAD4D2B688D1000677E8B /* MutelistManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B533694D2B66D791008A805E /* MutelistManager.swift */; }; B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */; }; B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B4D1422B37D47600844320 /* NdbExtensions.swift */; }; - BA0F0A6F2B36207E001641B2 /* CameraMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA0F0A6E2B36207E001641B2 /* CameraMediaView.swift */; }; - BA10192F2B449556009C57DA /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA10192E2B449556009C57DA /* CameraPreview.swift */; }; B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; B5C60C212B530D5600C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; B5C60C232B532A8700C5ECA7 /* DamusDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */; }; @@ -1300,7 +1298,7 @@ 4CFD502E2A2DA45800A229DB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; 4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleView.swift; sourceTree = ""; }; 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContextMenuModifier.swift; sourceTree = ""; }; - 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; + 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCarouselView.swift; sourceTree = ""; }; 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainerView.swift; sourceTree = ""; }; 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedEvent.swift; sourceTree = ""; }; 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideEventView.swift; sourceTree = ""; }; @@ -1311,7 +1309,7 @@ 504323A62A34915F006AE6DC /* RelayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayModel.swift; sourceTree = ""; }; 504323A82A3495B6006AE6DC /* RelayModelCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayModelCache.swift; sourceTree = ""; }; 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; - 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerView.swift; sourceTree = ""; }; + 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAVPlayerView.swift; sourceTree = ""; }; 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayerViewModel.swift; sourceTree = ""; }; 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoController.swift; sourceTree = ""; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = ""; }; @@ -1355,8 +1353,6 @@ B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = ""; }; B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItemTests.swift; sourceTree = ""; usesTabs = 0; }; B5B4D1422B37D47600844320 /* NdbExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbExtensions.swift; sourceTree = ""; usesTabs = 0; }; - BA0F0A6E2B36207E001641B2 /* CameraMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraMediaView.swift; sourceTree = ""; }; - BA10192E2B449556009C57DA /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItem.swift; sourceTree = ""; usesTabs = 0; }; B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusDuration.swift; sourceTree = ""; usesTabs = 0; }; BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = ""; }; @@ -1733,7 +1729,7 @@ 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */, 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */, 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */, - 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */, + 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */, ); path = Video; sourceTree = ""; @@ -2659,7 +2655,7 @@ isa = PBXGroup; children = ( 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */, - 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */, + 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */, 6439E013296790CF0020672B /* ProfilePicImageView.swift */, 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */, 4CFD502E2A2DA45800A229DB /* MediaView.swift */, @@ -2744,14 +2740,6 @@ path = DamusNotificationService; sourceTree = ""; }; - E06336A72B7582D600A88E6B /* Assets */ = { - isa = PBXGroup; - children = ( - E06336A82B7582E000A88E6B /* img_with_location.jpeg */, - ); - path = Assets; - sourceTree = ""; - }; D7CBD1D22B8D21C100BFD889 /* Extensions */ = { isa = PBXGroup; children = ( @@ -2760,6 +2748,14 @@ path = Extensions; sourceTree = ""; }; + E06336A72B7582D600A88E6B /* Assets */ = { + isa = PBXGroup; + children = ( + E06336A82B7582E000A88E6B /* img_with_location.jpeg */, + ); + path = Assets; + sourceTree = ""; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -3178,7 +3174,7 @@ 4C363A9A28283854006E126D /* Reply.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */, - 4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */, + 4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */, 4CA927632A290EB10098A105 /* EventTop.swift in Sources */, 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */, 4CB8838B296F6E1E00DC99E7 /* NIP05Badge.swift in Sources */, @@ -3416,7 +3412,7 @@ 4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */, 4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */, 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */, - 50A16FFB2AA6C06600DFEC1F /* AVPlayerView.swift in Sources */, + 50A16FFB2AA6C06600DFEC1F /* DamusAVPlayerView.swift in Sources */, 4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */, 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */, 7527271E2A93FF0100214108 /* Block.swift in Sources */, diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index 4d23ef7a..20be57cb 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -132,7 +132,7 @@ struct ImageCarousel: View { model.open_sheet = true } case .video(let url): - DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video) + DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true })) .onChange(of: model.video_size) { size in guard let size else { return } @@ -201,7 +201,7 @@ struct ImageCarousel: View { } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .fullScreenCover(isPresented: $model.open_sheet) { - ImageView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) + FullScreenCarouselView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) } .frame(height: height) .onChange(of: model.selectedIndex) { value in @@ -296,7 +296,9 @@ public struct ImageFill { struct ImageCarousel_Previews: PreviewProvider { static var previews: some View { let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) - ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [url, url]) + let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) + ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url]) + .environmentObject(OrientationTracker()) } } diff --git a/damus/Views/Images/FullScreenCarouselView.swift b/damus/Views/Images/FullScreenCarouselView.swift new file mode 100644 index 00000000..38290bf5 --- /dev/null +++ b/damus/Views/Images/FullScreenCarouselView.swift @@ -0,0 +1,127 @@ +// +// FullScreenCarouselView.swift +// damus +// +// Created by William Casarin on 2023-03-23. +// + +import SwiftUI + +struct FullScreenCarouselView: View { + let video_controller: VideoController + let urls: [MediaUrl] + + @Environment(\.presentationMode) var presentationMode + + @State var showMenu = true + + let settings: UserSettingsStore + @Binding var selectedIndex: Int + + var tabViewIndicator: some View { + HStack(spacing: 10) { + ForEach(urls.indices, id: \.self) { index in + Capsule() + .fill(index == selectedIndex ? Color.white : Color.damusMediumGrey) + .frame(width: 7, height: 7) + .onTapGesture { + selectedIndex = index + } + } + } + .padding() + .clipShape(Capsule()) + } + + var background: some ShapeStyle { + if case .video = urls[safe: selectedIndex] { + return AnyShapeStyle(Color.black) + } + else { + return AnyShapeStyle(.regularMaterial) + } + } + + var background_color: UIColor { + return .black + } + + var body: some View { + ZStack { + Color(self.background_color) + .ignoresSafeArea() + + TabView(selection: $selectedIndex) { + ForEach(urls.indices, id: \.self) { index in + VStack { + if case .video = urls[safe: index] { + ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings) + .clipped() // SwiftUI hack from https://stackoverflow.com/a/74401288 to make playback controls show up within the TabView + .aspectRatio(contentMode: .fit) + .padding(.top, Theme.safeAreaInsets?.top) + .padding(.bottom, Theme.safeAreaInsets?.bottom) + .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { + presentationMode.wrappedValue.dismiss() + })) + .ignoresSafeArea() + } + else { + ZoomableScrollView { + ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings) + .aspectRatio(contentMode: .fit) + .padding(.top, Theme.safeAreaInsets?.top) + .padding(.bottom, Theme.safeAreaInsets?.bottom) + } + .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { + presentationMode.wrappedValue.dismiss() + })) + .ignoresSafeArea() + } + }.tag(index) + } + } + .ignoresSafeArea() + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .gesture(TapGesture(count: 2).onEnded { + // Prevents menu from hiding on double tap + }) + .gesture(TapGesture(count: 1).onEnded { + showMenu.toggle() + }) + .overlay( + GeometryReader { geo in + VStack { + if showMenu { + NavDismissBarView(showBackgroundCircle: false) + .foregroundColor(.white) + Spacer() + + if (urls.count > 1) { + tabViewIndicator + } + } + } + .animation(.easeInOut, value: showMenu) + .padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0) + } + ) + } + } +} + +fileprivate struct ImageViewPreview: View { + @State var selectedIndex: Int = 0 + let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) + let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) + + var body: some View { + FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) + .environmentObject(OrientationTracker()) + } +} + +struct ImageView_Previews: PreviewProvider { + static var previews: some View { + ImageViewPreview() + } +} diff --git a/damus/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift index 094d8753..5c697e4d 100644 --- a/damus/Views/Images/ImageContainerView.swift +++ b/damus/Views/Images/ImageContainerView.swift @@ -43,19 +43,26 @@ struct ImageContainerView: View { var body: some View { Group { switch url { - case .image(let url): - Img(url: url) - case .video(let url): - DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller) + case .image(let url): + Img(url: url) + case .video(let url): + DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full) } } } } let test_image_url = URL(string: "https://jb55.com/red-me.jpg")! +fileprivate let test_video_url = URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")! struct ImageContainerView_Previews: PreviewProvider { static var previews: some View { - ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings) + Group { + ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings) + .previewDisplayName("Image") + ImageContainerView(video_controller: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings) + .previewDisplayName("Video") + } + .environmentObject(OrientationTracker()) } } diff --git a/damus/Views/Images/ImageView.swift b/damus/Views/Images/ImageView.swift deleted file mode 100644 index 2a9ce50d..00000000 --- a/damus/Views/Images/ImageView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ImageView.swift -// damus -// -// Created by William Casarin on 2023-03-23. -// - -import SwiftUI - -struct ImageView: View { - let video_controller: VideoController - let urls: [MediaUrl] - - @Environment(\.presentationMode) var presentationMode - - @State var showMenu = true - - let settings: UserSettingsStore - @Binding var selectedIndex: Int - - var tabViewIndicator: some View { - HStack(spacing: 10) { - ForEach(urls.indices, id: \.self) { index in - Capsule() - .fill(index == selectedIndex ? Color(UIColor.label) : Color.secondary) - .frame(width: 7, height: 7) - .onTapGesture { - selectedIndex = index - } - } - } - .padding() - .background(.regularMaterial) - .clipShape(Capsule()) - } - - var body: some View { - ZStack { - Color(.systemBackground) - .ignoresSafeArea() - - TabView(selection: $selectedIndex) { - ForEach(urls.indices, id: \.self) { index in - ZoomableScrollView { - ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings) - .aspectRatio(contentMode: .fit) - .padding(.top, Theme.safeAreaInsets?.top) - .padding(.bottom, Theme.safeAreaInsets?.bottom) - } - .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { - presentationMode.wrappedValue.dismiss() - })) - .ignoresSafeArea() - .tag(index) - } - } - .ignoresSafeArea() - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) - .gesture(TapGesture(count: 2).onEnded { - // Prevents menu from hiding on double tap - }) - .gesture(TapGesture(count: 1).onEnded { - showMenu.toggle() - }) - .overlay( - GeometryReader { geo in - VStack { - if showMenu { - NavDismissBarView() - Spacer() - - if (urls.count > 1) { - tabViewIndicator - } - } - } - .animation(.easeInOut, value: showMenu) - .padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0) - } - ) - } - } -} - -struct ImageView_Previews: PreviewProvider { - static var previews: some View { - let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) - ImageView(video_controller: test_damus_state.video, urls: [url], settings: test_damus_state.settings, selectedIndex: Binding.constant(0)) - } -} diff --git a/damus/Views/Images/ProfilePicImageView.swift b/damus/Views/Images/ProfilePicImageView.swift index b900813d..d7b3f56e 100644 --- a/damus/Views/Images/ProfilePicImageView.swift +++ b/damus/Views/Images/ProfilePicImageView.swift @@ -42,16 +42,27 @@ struct ProfileImageContainerView: View { struct NavDismissBarView: View { @Environment(\.presentationMode) var presentationMode + let showBackgroundCircle: Bool + + init(showBackgroundCircle: Bool = true) { + self.showBackgroundCircle = showBackgroundCircle + } var body: some View { HStack { Button(action: { presentationMode.wrappedValue.dismiss() }, label: { - Image("close") - .frame(width: 33, height: 33) - .background(.regularMaterial) - .clipShape(Circle()) + if showBackgroundCircle { + Image("close") + .frame(width: 33, height: 33) + .background(.regularMaterial) + .clipShape(Circle()) + } + else { + Image("close") + .frame(width: 33, height: 33) + } }) Spacer() diff --git a/damus/Views/Video/AVPlayerView.swift b/damus/Views/Video/DamusAVPlayerView.swift similarity index 73% rename from damus/Views/Video/AVPlayerView.swift rename to damus/Views/Video/DamusAVPlayerView.swift index 7a69aef7..a4036172 100644 --- a/damus/Views/Video/AVPlayerView.swift +++ b/damus/Views/Video/DamusAVPlayerView.swift @@ -9,12 +9,15 @@ import Foundation import AVKit import SwiftUI -struct AVPlayerView: UIViewControllerRepresentable { +struct DamusAVPlayerView: UIViewControllerRepresentable { let player: AVPlayer + var controller: AVPlayerViewController + let show_playback_controls: Bool func makeUIViewController(context: Context) -> AVPlayerViewController { - AVPlayerViewController() + self.controller.showsPlaybackControls = show_playback_controls + return self.controller } func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index f27253de..502987e3 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -20,10 +20,19 @@ struct DamusVideoPlayer: View { let url: URL @StateObject var model: DamusVideoPlayerViewModel @EnvironmentObject private var orientationTracker: OrientationTracker + let style: Style - init(url: URL, video_size: Binding, controller: VideoController) { + init(url: URL, video_size: Binding, controller: VideoController, style: Style) { self.url = url - _model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller)) + let mute: Bool? + if case .full = style { + mute = false + } + else { + mute = nil + } + _model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute)) + self.style = style } var body: some View { @@ -31,7 +40,15 @@ struct DamusVideoPlayer: View { let localFrame = geo.frame(in: .local) let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y ZStack { - AVPlayerView(player: model.player) + if case .full = self.style { + DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: true) + } + if case .preview(let on_tap) = self.style { + DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: false) + .simultaneousGesture(TapGesture().onEnded({ + on_tap?() + })) + } if model.is_loading { ProgressView() @@ -40,8 +57,10 @@ struct DamusVideoPlayer: View { .scaleEffect(CGSize(width: 1.5, height: 1.5)) } - if model.has_audio { - mute_button + if case .preview = self.style { + if model.has_audio { + mute_button + } } if model.is_live { live_indicator @@ -115,9 +134,24 @@ struct DamusVideoPlayer: View { Spacer() } } + + enum Style { + /// A full video player with playback controls + case full + /// A style suitable for muted, auto-playing videos on a feed + case preview(on_tap: (() -> Void)?) + } } struct DamusVideoPlayer_Previews: PreviewProvider { static var previews: some View { - DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController()) + Group { + DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .full) + .environmentObject(OrientationTracker()) + .previewDisplayName("Full video player") + + DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .preview(on_tap: nil)) + .environmentObject(OrientationTracker()) + .previewDisplayName("Preview video player") + } } } diff --git a/damus/Views/Video/DamusVideoPlayerViewModel.swift b/damus/Views/Video/DamusVideoPlayerViewModel.swift index 628ef6ba..524c0b72 100644 --- a/damus/Views/Video/DamusVideoPlayerViewModel.swift +++ b/damus/Views/Video/DamusVideoPlayerViewModel.swift @@ -6,6 +6,7 @@ // import AVFoundation +import AVKit import Combine import Foundation import SwiftUI @@ -27,7 +28,8 @@ final class DamusVideoPlayerViewModel: ObservableObject { private let url: URL private let player_item: AVPlayerItem let player: AVPlayer - private let controller: VideoController + fileprivate let controller: VideoController + let player_view_controller = AVPlayerViewController() let id = UUID() @Published var has_audio = false @@ -55,7 +57,7 @@ final class DamusVideoPlayerViewModel: ObservableObject { } } - init(url: URL, video_size: Binding, controller: VideoController) { + init(url: URL, video_size: Binding, controller: VideoController, mute: Bool? = nil) { self.url = url player_item = AVPlayerItem(url: url) player = AVPlayer(playerItem: player_item) @@ -66,7 +68,7 @@ final class DamusVideoPlayerViewModel: ObservableObject { await load() } - is_muted = controller.should_mute_video(url: url) + is_muted = mute ?? controller.should_mute_video(url: url) player.isMuted = is_muted NotificationCenter.default.addObserver( From 250efd975534347478502cbd0614038b1d07d096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 18 Mar 2024 22:21:28 +0000 Subject: [PATCH 3/5] Add event text to full-screen Carousel view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full screen carousel looks quite empty. When viewing videos, it looks like the video is being played full screen, when it is really not. Alas, SwiftUI/UIKit does not provide an API for programmatically bringing a video player full screen. The closest we can do is show the system native playback controls. This can cause confusion to users, and is not the best UX. To get around these limitations and improve UX, event information and content is added to the full screen carousel overlay, so that: - Users can see a piece of the post while they are browsing images and videos - Users can more clearly tell when the video is being displayed on the full screen carousel or on full screen - Users have a way to directly go to the thread view within the full screen carousel Changelog-Added: Add event content preview to the full screen carousel Signed-off-by: Daniel D’Aquino Link: 20240318222048.14226-4-daniel@daquino.me Signed-off-by: William Casarin --- damus/Components/ImageCarousel.swift | 26 ++++++++++++-- damus/Components/TruncatedText.swift | 7 +++- damus/Views/Events/TextEvent.swift | 3 ++ .../Views/Images/FullScreenCarouselView.swift | 25 +++++++++++-- damus/Views/NoteContentView.swift | 35 ++++++++++++++++--- 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index 20be57cb..c95cbe68 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -77,13 +77,14 @@ class CarouselModel: ObservableObject { // MARK: - Image Carousel @MainActor -struct ImageCarousel: View { +struct ImageCarousel: View { var urls: [MediaUrl] let evid: NoteId let state: DamusState @ObservedObject var model: CarouselModel + let content: ((_ dismiss: @escaping (() -> Void)) -> Content)? init(state: DamusState, evid: NoteId, urls: [MediaUrl]) { self.urls = urls @@ -91,6 +92,16 @@ struct ImageCarousel: View { self.state = state let media_model = state.events.get_cache_data(evid).media_metadata_model self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill)) + self.content = nil + } + + init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) { + self.urls = urls + self.evid = evid + self.state = state + let media_model = state.events.get_cache_data(evid).media_metadata_model + self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill)) + self.content = content } var filling: Bool { @@ -201,7 +212,16 @@ struct ImageCarousel: View { } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .fullScreenCover(isPresented: $model.open_sheet) { - FullScreenCarouselView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) + if let content { + FullScreenCarouselView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) { + content({ // Dismiss closure + model.open_sheet = false + }) + } + } + else { + FullScreenCarouselView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) + } } .frame(height: height) .onChange(of: model.selectedIndex) { value in @@ -297,7 +317,7 @@ struct ImageCarousel_Previews: PreviewProvider { static var previews: some View { let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) - ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url]) + ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url]) .environmentObject(OrientationTracker()) } } diff --git a/damus/Components/TruncatedText.swift b/damus/Components/TruncatedText.swift index 62c909cb..8fca6895 100644 --- a/damus/Components/TruncatedText.swift +++ b/damus/Components/TruncatedText.swift @@ -9,7 +9,12 @@ import SwiftUI struct TruncatedText: View { let text: CompatibleText - let maxChars: Int = 280 + let maxChars: Int + + init(text: CompatibleText, maxChars: Int = 280) { + self.text = text + self.maxChars = maxChars + } var body: some View { let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars) diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift index 1570dcc0..c1bedb6d 100644 --- a/damus/Views/Events/TextEvent.swift +++ b/damus/Views/Events/TextEvent.swift @@ -19,8 +19,11 @@ struct EventViewOptions: OptionSet { static let nested = EventViewOptions(rawValue: 1 << 7) static let top_zap = EventViewOptions(rawValue: 1 << 8) static let no_mentions = EventViewOptions(rawValue: 1 << 9) + static let no_media = EventViewOptions(rawValue: 1 << 10) + static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11) static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested] + static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short] } struct TextEvent: View { diff --git a/damus/Views/Images/FullScreenCarouselView.swift b/damus/Views/Images/FullScreenCarouselView.swift index 38290bf5..7e11ff57 100644 --- a/damus/Views/Images/FullScreenCarouselView.swift +++ b/damus/Views/Images/FullScreenCarouselView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct FullScreenCarouselView: View { +struct FullScreenCarouselView: View { let video_controller: VideoController let urls: [MediaUrl] @@ -17,6 +17,25 @@ struct FullScreenCarouselView: View { let settings: UserSettingsStore @Binding var selectedIndex: Int + let content: (() -> Content)? + + init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding, @ViewBuilder content: @escaping () -> Content) { + self.video_controller = video_controller + self.urls = urls + self.showMenu = showMenu + self.settings = settings + _selectedIndex = selectedIndex + self.content = content + } + + init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding) { + self.video_controller = video_controller + self.urls = urls + self.showMenu = showMenu + self.settings = settings + _selectedIndex = selectedIndex + self.content = nil + } var tabViewIndicator: some View { HStack(spacing: 10) { @@ -99,6 +118,8 @@ struct FullScreenCarouselView: View { if (urls.count > 1) { tabViewIndicator } + + self.content?() } } .animation(.easeInOut, value: showMenu) @@ -115,7 +136,7 @@ fileprivate struct ImageViewPreview: View { let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) var body: some View { - FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) + FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) .environmentObject(OrientationTracker()) } } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index 1b664baf..491fe4ac 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -57,6 +57,10 @@ struct NoteContentView: View { return options.contains(.truncate_content) } + var truncate_very_short: Bool { + return options.contains(.truncate_content_very_short) + } + var with_padding: Bool { return options.contains(.wide) } @@ -73,7 +77,11 @@ struct NoteContentView: View { func truncatedText(content: CompatibleText) -> some View { Group { - if truncate { + if truncate_very_short { + TruncatedText(text: content, maxChars: 140) + .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size)) + } + else if truncate { TruncatedText(text: content) .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size)) } else { @@ -107,6 +115,19 @@ struct NoteContentView: View { } } + func fullscreen_preview(dismiss: @escaping () -> Void) -> some View { + VStack { + EventView(damus: damus_state, event: self.event, options: .embedded_text_only) + .padding(.top) + } + .background(.thinMaterial) + .preferredColorScheme(.dark) + .onTapGesture(perform: { + damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state))) + dismiss() + }) + } + func MainContent(artifacts: NoteArtifactsSeparated) -> some View { VStack(alignment: .leading) { if size == .selected { @@ -135,13 +156,19 @@ struct NoteContentView: View { } if artifacts.media.count > 0 { - if !damus_state.settings.media_previews && !load_media { + if (self.options.contains(.no_media)) { + EmptyView() + } else if !damus_state.settings.media_previews && !load_media { loadMediaButton(artifacts: artifacts) } else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) { - ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) + ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in + fullscreen_preview(dismiss: dismiss) + } } else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) { ZStack { - ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) + ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in + fullscreen_preview(dismiss: dismiss) + } Blur() .onTapGesture { blur_images = false From 181d894df0e5c19be8c897eddbf99f030cec5334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 18 Mar 2024 22:21:35 +0000 Subject: [PATCH 4/5] Improve SwiftUI previews around full-screen carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a minor SwiftUI preview improvement Signed-off-by: Daniel D’Aquino Link: 20240318222048.14226-5-daniel@daquino.me Signed-off-by: William Casarin --- .../Views/Images/FullScreenCarouselView.swift | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/damus/Views/Images/FullScreenCarouselView.swift b/damus/Views/Images/FullScreenCarouselView.swift index 7e11ff57..4d208c42 100644 --- a/damus/Views/Images/FullScreenCarouselView.swift +++ b/damus/Views/Images/FullScreenCarouselView.swift @@ -130,19 +130,42 @@ struct FullScreenCarouselView: View { } } -fileprivate struct ImageViewPreview: View { +fileprivate struct FullScreenCarouselPreviewView: View { @State var selectedIndex: Int = 0 let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) + let custom_content: (() -> Content)? + + init(content: (() -> Content)? = nil) { + self.custom_content = content + } var body: some View { - FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) + FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) { + self.custom_content?() + } .environmentObject(OrientationTracker()) } } -struct ImageView_Previews: PreviewProvider { +struct FullScreenCarouselView_Previews: PreviewProvider { static var previews: some View { - ImageViewPreview() + Group { + FullScreenCarouselPreviewView() + .previewDisplayName("No custom content on overlay") + + FullScreenCarouselPreviewView(content: { + HStack { + Spacer() + + Text("Some content") + .padding() + .foregroundColor(.white) + + Spacer() + }.background(.ultraThinMaterial) + }) + .previewDisplayName("Custom content on overlay") + } } } From 79fef51f68f1bf1397c3809f366dcfa80adef0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 18 Mar 2024 22:21:43 +0000 Subject: [PATCH 5/5] Improve Video visibility tracking and automatic play/pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DamusAVPlayerView (along with other classes) play or pause based on their Y position relative to the user's viewport. This is ideal for a vertical feed of notes. However, this does not work well on a horizontal carousel, such as when viewing videos on the full-screen carousel and swiping left/right. This commit adds a new tracking method based on onAppear/onDisappear triggers from a lazy stack (which only loads when it is visible), and applied it to videos shown on a full screen carousel, so that videos pause when we swipe away from the video. Incidentally, this also fixes an issue I was seeing where a full screen video would disappear as soon as I rotated the phone to landscape mode. Testing -------- Device: iPhone 13 Mini iOS: 17.3.1 Damus: This version Coverage: 1. Scroll down a feed full of videos and make sure videos still autoplay when passing through them. PASS 2. Check videos on the feed are muted by default. PASS 3. Check mute button on the video still works. PASS 4. Check clicking on the video brings it to a full-screen carousel view. PASS 5. Check that videos play unmuted by default on full-screen carousel view. PASS 6. Check that all playback controls work on the full-screen carousel view. PASS 7. Check that clicking outside the video shows/hides the carousel overlays. PASS 8. Check that a summary of the note shows up. PASS 9. Check that clicking on that note takes the user to the thread view. PASS 10. Check that changing phone orientation between portrait and landscape on both full-screen carousel AND full-screen video modes will work as expected. PASS 11. Check close button on full-screen carousel works. PASS 12. Check that swiping the video away exits full-screen carousel. PASS 13. Check that full-screen carousel works with images. PASS 14. Check that a carousel with multiple images/videos can be swiped left and right. PASS 15. Check that swiping away from a video on a full-screen carousel will pause it. PASS 16. Check that clicking on an unmuted video on the feed won't cause double-audio issues. PASS 17. Check that full-screen carousel view looks good on both dark and light modes. PASS Closes: https://github.com/damus-io/damus/issues/1530 Signed-off-by: Daniel D’Aquino Link: 20240318222048.14226-6-daniel@daquino.me Signed-off-by: William Casarin --- damus.xcodeproj/project.pbxproj | 12 +++++++ .../Views/Extensions/VisibilityTracker.swift | 36 +++++++++++++++++++ damus/Views/Images/ImageContainerView.swift | 2 +- damus/Views/Video/DamusVideoPlayer.swift | 29 ++++++++++++--- 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 damus/Views/Extensions/VisibilityTracker.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 919fb273..67cd254c 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -454,6 +454,7 @@ D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; }; D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; }; D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; }; + D71AC4CC2BA8E3480076268E /* VisibilityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; D72341192B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; }; D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; }; @@ -1373,6 +1374,7 @@ D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = ""; }; D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = ""; }; D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = ""; }; + D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = ""; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = ""; }; D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleEnvironment.swift; sourceTree = ""; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = ""; }; @@ -1978,6 +1980,7 @@ 4C75EFA227FA576C0006080F /* Views */ = { isa = PBXGroup; children = ( + D71AC4CA2BA8E3320076268E /* Extensions */, BA3759952ABCCF360018D73B /* Camera */, F71694E82A66221E001F4053 /* Onboarding */, 4C190F232A547D1700027FD5 /* NostrScript */, @@ -2706,6 +2709,14 @@ path = Detail; sourceTree = ""; }; + D71AC4CA2BA8E3320076268E /* Extensions */ = { + isa = PBXGroup; + children = ( + D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */, + ); + path = Extensions; + sourceTree = ""; + }; D72A2D032AD9C165002AFF62 /* Mocking */ = { isa = PBXGroup; children = ( @@ -3206,6 +3217,7 @@ F75BA12D29A1855400E10810 /* BookmarksManager.swift in Sources */, 4CC14FEF2A73FCCB007AEB17 /* IdType.swift in Sources */, 4C3EA67F28FFC01D00C48A62 /* InvoiceView.swift in Sources */, + D71AC4CC2BA8E3480076268E /* VisibilityTracker.swift in Sources */, 4CE8794829941DA700F758CC /* RelayFilters.swift in Sources */, 4CEE2B02280B39E800AB5EEF /* EventActionBar.swift in Sources */, 4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */, diff --git a/damus/Views/Extensions/VisibilityTracker.swift b/damus/Views/Extensions/VisibilityTracker.swift new file mode 100644 index 00000000..89950a9f --- /dev/null +++ b/damus/Views/Extensions/VisibilityTracker.swift @@ -0,0 +1,36 @@ +// +// VisibilityTracker.swift +// damus +// +// Created by Daniel D’Aquino on 2024-03-18. +// +// Based on code examples shown in this article: https://medium.com/@jackvanderpump/how-to-detect-is-an-element-is-visible-in-swiftui-9ff58ca72339 + +import Foundation +import SwiftUI + +extension View { + func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View { + self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge)) + } +} + +struct VisibilityTracker: ViewModifier { + let visibility_change_notifier: (Bool) -> Void + let edge: Alignment + + func body(content: Content) -> some View { + content + .overlay( + LazyVStack { + Color.clear + .onAppear { + visibility_change_notifier(true) + } + .onDisappear { + visibility_change_notifier(false) + } + }, + alignment: edge) + } +} diff --git a/damus/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift index 5c697e4d..153a61a0 100644 --- a/damus/Views/Images/ImageContainerView.swift +++ b/damus/Views/Images/ImageContainerView.swift @@ -46,7 +46,7 @@ struct ImageContainerView: View { case .image(let url): Img(url: url) case .video(let url): - DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full) + DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full, visibility_tracking_method: .generic) } } } diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index 502987e3..6beaa924 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -21,8 +21,10 @@ struct DamusVideoPlayer: View { @StateObject var model: DamusVideoPlayerViewModel @EnvironmentObject private var orientationTracker: OrientationTracker let style: Style + let visibility_tracking_method: VisibilityTrackingMethod + @State var isVisible: Bool = false - init(url: URL, video_size: Binding, controller: VideoController, style: Style) { + init(url: URL, video_size: Binding, controller: VideoController, style: Style, visibility_tracking_method: VisibilityTrackingMethod = .y_scroll) { self.url = url let mute: Bool? if case .full = style { @@ -32,6 +34,7 @@ struct DamusVideoPlayer: View { mute = nil } _model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute)) + self.visibility_tracking_method = visibility_tracking_method self.style = style } @@ -67,14 +70,25 @@ struct DamusVideoPlayer: View { } } .onChange(of: centerY) { _ in - update_is_visible(centerY: centerY) + if case .y_scroll = visibility_tracking_method { + update_is_visible(centerY: centerY) + } } + .on_visibility_change(perform: { new_visibility in + if case .generic = visibility_tracking_method { + model.set_view_is_visible(new_visibility) + } + }) .onAppear { - update_is_visible(centerY: centerY) + if case .y_scroll = visibility_tracking_method { + update_is_visible(centerY: centerY) + } } } .onDisappear { - model.view_did_disappear() + if case .y_scroll = visibility_tracking_method { + model.view_did_disappear() + } } } @@ -141,6 +155,13 @@ struct DamusVideoPlayer: View { /// A style suitable for muted, auto-playing videos on a feed case preview(on_tap: (() -> Void)?) } + + enum VisibilityTrackingMethod { + /// Detects visibility based on its Y position relative to viewport. Ideal for long feeds + case y_scroll + /// Detects visibility based whether the view intersects with the viewport + case generic + } } struct DamusVideoPlayer_Previews: PreviewProvider { static var previews: some View {