damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 247f313b54296b3bb8ff02912279cc8b623c7fde
parent b31b917b70dfd045996f5b6b30ae0b38d49db981
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 20 Mar 2024 09:57:10 +0000

Merge branch 'video-controls'

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 32++++++++++++++++++++------------
Mdamus/Components/ImageCarousel.swift | 30++++++++++++++++++++++++++----
Mdamus/Components/TruncatedText.swift | 7++++++-
Mdamus/Views/Events/TextEvent.swift | 3+++
Adamus/Views/Extensions/VisibilityTracker.swift | 36++++++++++++++++++++++++++++++++++++
Adamus/Views/Images/FullScreenCarouselView.swift | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Images/ImageContainerView.swift | 17++++++++++++-----
Ddamus/Views/Images/ImageView.swift | 90-------------------------------------------------------------------------------
Mdamus/Views/Images/ProfilePicImageView.swift | 19+++++++++++++++----
Mdamus/Views/NoteContentView.swift | 35+++++++++++++++++++++++++++++++----
Ddamus/Views/Video/AVPlayerView.swift | 31-------------------------------
Adamus/Views/Video/DamusAVPlayerView.swift | 34++++++++++++++++++++++++++++++++++
Mdamus/Views/Video/DamusVideoPlayer.swift | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mdamus/Views/Video/DamusVideoPlayerViewModel.swift | 8+++++---
14 files changed, 423 insertions(+), 163 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -375,7 +375,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 */; }; @@ -386,7 +386,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 */; }; @@ -431,8 +431,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 */; }; @@ -454,6 +452,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 */; }; @@ -1296,7 +1295,7 @@ 4CFD502E2A2DA45800A229DB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; }; 4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleView.swift; sourceTree = "<group>"; }; 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContextMenuModifier.swift; sourceTree = "<group>"; }; - 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; }; + 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCarouselView.swift; sourceTree = "<group>"; }; 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainerView.swift; sourceTree = "<group>"; }; 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedEvent.swift; sourceTree = "<group>"; }; 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideEventView.swift; sourceTree = "<group>"; }; @@ -1307,7 +1306,7 @@ 504323A62A34915F006AE6DC /* RelayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayModel.swift; sourceTree = "<group>"; }; 504323A82A3495B6006AE6DC /* RelayModelCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayModelCache.swift; sourceTree = "<group>"; }; 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = "<group>"; }; - 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerView.swift; sourceTree = "<group>"; }; + 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAVPlayerView.swift; sourceTree = "<group>"; }; 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayerViewModel.swift; sourceTree = "<group>"; }; 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoController.swift; sourceTree = "<group>"; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; }; @@ -1351,8 +1350,6 @@ B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = "<group>"; }; B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItemTests.swift; sourceTree = "<group>"; usesTabs = 0; }; B5B4D1422B37D47600844320 /* NdbExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbExtensions.swift; sourceTree = "<group>"; usesTabs = 0; }; - BA0F0A6E2B36207E001641B2 /* CameraMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraMediaView.swift; sourceTree = "<group>"; }; - BA10192E2B449556009C57DA /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; }; B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItem.swift; sourceTree = "<group>"; usesTabs = 0; }; B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusDuration.swift; sourceTree = "<group>"; usesTabs = 0; }; BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = "<group>"; }; @@ -1373,6 +1370,7 @@ D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; }; D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; }; D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; }; + D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = "<group>"; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; }; D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleEnvironment.swift; sourceTree = "<group>"; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = "<group>"; }; @@ -1728,7 +1726,7 @@ 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */, 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */, 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */, - 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */, + 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */, ); path = Video; sourceTree = "<group>"; @@ -1977,6 +1975,7 @@ 4C75EFA227FA576C0006080F /* Views */ = { isa = PBXGroup; children = ( + D71AC4CA2BA8E3320076268E /* Extensions */, BA3759952ABCCF360018D73B /* Camera */, F71694E82A66221E001F4053 /* Onboarding */, 4C190F232A547D1700027FD5 /* NostrScript */, @@ -2653,7 +2652,7 @@ isa = PBXGroup; children = ( 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */, - 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */, + 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */, 6439E013296790CF0020672B /* ProfilePicImageView.swift */, 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */, 4CFD502E2A2DA45800A229DB /* MediaView.swift */, @@ -2704,6 +2703,14 @@ path = Detail; sourceTree = "<group>"; }; + D71AC4CA2BA8E3320076268E /* Extensions */ = { + isa = PBXGroup; + children = ( + D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */, + ); + path = Extensions; + sourceTree = "<group>"; + }; D72A2D032AD9C165002AFF62 /* Mocking */ = { isa = PBXGroup; children = ( @@ -3171,7 +3178,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 */, @@ -3203,6 +3210,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 */, @@ -3409,7 +3417,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 @@ -77,13 +77,14 @@ class CarouselModel: ObservableObject { // MARK: - Image Carousel @MainActor -struct ImageCarousel: View { +struct ImageCarousel<Content: View>: 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 { @@ -132,7 +143,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 +212,16 @@ 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) + if let content { + FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) { + content({ // Dismiss closure + model.open_sheet = false + }) + } + } + else { + FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) + } } .frame(height: height) .onChange(of: model.selectedIndex) { value in @@ -296,7 +316,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<AnyView>(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 @@ -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 @@ -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/Extensions/VisibilityTracker.swift 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/FullScreenCarouselView.swift b/damus/Views/Images/FullScreenCarouselView.swift @@ -0,0 +1,171 @@ +// +// FullScreenCarouselView.swift +// damus +// +// Created by William Casarin on 2023-03-23. +// + +import SwiftUI + +struct FullScreenCarouselView<Content: View>: View { + let video_controller: VideoController + let urls: [MediaUrl] + + @Environment(\.presentationMode) var presentationMode + + @State var showMenu = true + + let settings: UserSettingsStore + @Binding var selectedIndex: Int + let content: (() -> Content)? + + init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) { + self.video_controller = video_controller + self.urls = urls + self._showMenu = State(initialValue: showMenu) + self.settings = settings + _selectedIndex = selectedIndex + self.content = content + } + + init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) { + self.video_controller = video_controller + self.urls = urls + self._showMenu = State(initialValue: showMenu) + self.settings = settings + _selectedIndex = selectedIndex + self.content = nil + } + + 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 + } + + self.content?() + } + } + .animation(.easeInOut, value: showMenu) + .padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0) + } + ) + } + } +} + +fileprivate struct FullScreenCarouselPreviewView<Content: View>: 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) { + self.custom_content?() + } + .environmentObject(OrientationTracker()) + } +} + +struct FullScreenCarouselView_Previews: PreviewProvider { + static var previews: some View { + Group { + FullScreenCarouselPreviewView<AnyView>() + .previewDisplayName("No custom content on overlay") + + FullScreenCarouselPreviewView(content: { + HStack { + Spacer() + + Text("Some content") + .padding() + .foregroundColor(.white) + + Spacer() + }.background(.ultraThinMaterial) + }) + .previewDisplayName("Custom content on overlay") + } + } +} diff --git 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, visibility_tracking_method: .generic) } } } } 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 @@ -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 @@ -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/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 diff --git a/damus/Views/Video/AVPlayerView.swift b/damus/Views/Video/AVPlayerView.swift @@ -1,31 +0,0 @@ -// -// AVPlayerView.swift -// damus -// -// Created by Bryan Montz on 9/4/23. -// - -import Foundation -import AVKit -import SwiftUI - -struct AVPlayerView: UIViewControllerRepresentable { - - let player: AVPlayer - - func makeUIViewController(context: Context) -> AVPlayerViewController { - AVPlayerViewController() - } - - func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { - if uiViewController.player == nil { - uiViewController.player = player - player.play() - } - } - - static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) { - uiViewController.player?.pause() - uiViewController.player = nil - } -} diff --git a/damus/Views/Video/DamusAVPlayerView.swift b/damus/Views/Video/DamusAVPlayerView.swift @@ -0,0 +1,34 @@ +// +// AVPlayerView.swift +// damus +// +// Created by Bryan Montz on 9/4/23. +// + +import Foundation +import AVKit +import SwiftUI + +struct DamusAVPlayerView: UIViewControllerRepresentable { + + let player: AVPlayer + var controller: AVPlayerViewController + let show_playback_controls: Bool + + func makeUIViewController(context: Context) -> AVPlayerViewController { + self.controller.showsPlaybackControls = show_playback_controls + return self.controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + if uiViewController.player == nil { + uiViewController.player = player + player.play() + } + } + + static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) { + uiViewController.player?.pause() + uiViewController.player = nil + } +} diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift @@ -20,10 +20,22 @@ struct DamusVideoPlayer: View { let url: URL @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<CGSize?>, controller: VideoController) { + init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, style: Style, visibility_tracking_method: VisibilityTrackingMethod = .y_scroll) { 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.visibility_tracking_method = visibility_tracking_method + self.style = style } var body: some View { @@ -31,7 +43,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,22 +60,35 @@ 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 } } .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() + } } } @@ -115,9 +148,31 @@ 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)?) + } + + 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 { - 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 @@ -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<CGSize?>, controller: VideoController) { + init(url: URL, video_size: Binding<CGSize?>, 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(