diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 4c9a352f..b0a32f18 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -77,7 +77,7 @@ 4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2229DDDB8100516EAC /* IconLabel.swift */; }; 4C1A9A2529DDDF2600516EAC /* ZapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2429DDDF2600516EAC /* ZapSettingsView.swift */; }; 4C1A9A2729DDE31900516EAC /* TranslationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */; }; - 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */; }; + 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */; }; 4C1D4FB12A7958E60024F453 /* VersionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1D4FB02A7958E60024F453 /* VersionInfo.swift */; }; 4C1D4FB42A7967990024F453 /* build-git-hash.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4C1D4FB32A7967990024F453 /* build-git-hash.txt */; }; 4C216F32286E388800040376 /* DMChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F31286E388800040376 /* DMChatView.swift */; }; @@ -386,8 +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 /* DamusAVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */; }; - 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */; }; + 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */; }; 50A16FFF2AA76A0900DFEC1F /* DamusVideoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */; }; 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; }; 50A60D142A28BEEE00186190 /* RelayLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A60D132A28BEEE00186190 /* RelayLog.swift */; }; @@ -784,10 +783,9 @@ D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F32A6732B7001F4053 /* GradientFollowButton.swift */; }; D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; }; D73E5ED72C6A97F4007EB227 /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; }; - D73E5ED82C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */; }; - D73E5ED92C6A97F4007EB227 /* DamusVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */; }; + D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */; }; + D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */; }; D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */; }; - D73E5EDB2C6A97F4007EB227 /* DamusAVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */; }; D73E5EDC2C6A97F4007EB227 /* ReactionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */; }; D73E5EDD2C6A97F4007EB227 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */; }; D73E5EDE2C6A97F4007EB227 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */; }; @@ -1147,6 +1145,8 @@ D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = D7EDED302B1290B80018B19C /* MarkdownUI */; }; D7EDED332B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */; }; D7EDED342B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */; }; + D7EFBA372CC322F300F45588 /* DamusVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */; }; + D7EFBA382CC322F300F45588 /* DamusVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */; }; D7FB10A72B0C371A00FA8D42 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2B10272A7B0F5C008AA43E /* Log.swift */; }; D7FB14222BE5970000398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */; }; D7FB14252BE5A9A800398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */; }; @@ -1388,7 +1388,7 @@ 4C1A9A2229DDDB8100516EAC /* IconLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconLabel.swift; sourceTree = ""; }; 4C1A9A2429DDDF2600516EAC /* ZapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapSettingsView.swift; sourceTree = ""; }; 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSettingsView.swift; sourceTree = ""; }; - 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayer.swift; sourceTree = ""; }; + 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayerView.swift; sourceTree = ""; }; 4C1D4FB02A7958E60024F453 /* VersionInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VersionInfo.swift; sourceTree = ""; }; 4C1D4FB32A7967990024F453 /* build-git-hash.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "build-git-hash.txt"; sourceTree = SOURCE_ROOT; }; 4C216F31286E388800040376 /* DMChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMChatView.swift; sourceTree = ""; }; @@ -1834,8 +1834,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 /* DamusAVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAVPlayerView.swift; sourceTree = ""; }; - 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayerViewModel.swift; sourceTree = ""; }; + 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayer.swift; sourceTree = ""; }; 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoCoordinator.swift; sourceTree = ""; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = ""; }; 50A60D132A28BEEE00186190 /* RelayLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayLog.swift; sourceTree = ""; }; @@ -1983,6 +1982,7 @@ D7EDED202B117DCA0018B19C /* SequenceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceUtils.swift; sourceTree = ""; }; D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = ""; }; D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusUserDefaults.swift; sourceTree = ""; }; + D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoControlsView.swift; sourceTree = ""; }; D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstAidSettingsView.swift; sourceTree = ""; }; @@ -2298,10 +2298,10 @@ 4C1A9A2829DDF53B00516EAC /* Video */ = { isa = PBXGroup; children = ( - 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */, - 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */, + 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */, + 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */, 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */, - 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */, + D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */, ); path = Video; sourceTree = ""; @@ -4010,7 +4010,7 @@ 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */, - 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */, + 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */, 4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */, 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */, BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */, @@ -4045,7 +4045,7 @@ 4C3EA64F28FF59F200C48A62 /* tal.c in Sources */, 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */, 4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */, - 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */, + 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayer.swift in Sources */, 4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */, D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */, D724D8272B64B40B00ABE789 /* DamusPurpleAccountView.swift in Sources */, @@ -4076,6 +4076,7 @@ 4CC14FF92A741939007AEB17 /* Referenced.swift in Sources */, 4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */, 4CE1399429F0669900AC6A0B /* BigButton.swift in Sources */, + D7EFBA372CC322F300F45588 /* DamusVideoControlsView.swift in Sources */, 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */, 6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */, 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */, @@ -4134,7 +4135,6 @@ 4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */, 4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */, 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */, - 50A16FFB2AA6C06600DFEC1F /* DamusAVPlayerView.swift in Sources */, 4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */, 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */, 7527271E2A93FF0100214108 /* Block.swift in Sources */, @@ -4460,10 +4460,9 @@ D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */, D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */, D73E5ED72C6A97F4007EB227 /* MutinyButton.swift in Sources */, - D73E5ED82C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */, - D73E5ED92C6A97F4007EB227 /* DamusVideoPlayerViewModel.swift in Sources */, + D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */, + D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */, D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */, - D73E5EDB2C6A97F4007EB227 /* DamusAVPlayerView.swift in Sources */, D73E5EDC2C6A97F4007EB227 /* ReactionsSettingsView.swift in Sources */, D73E5EDD2C6A97F4007EB227 /* NotificationSettingsView.swift in Sources */, D73E5EDE2C6A97F4007EB227 /* AppearanceSettingsView.swift in Sources */, @@ -4602,6 +4601,7 @@ D73E5F572C6A97F5007EB227 /* PostButton.swift in Sources */, D73E5F582C6A97F5007EB227 /* MediaPicker.swift in Sources */, D73E5F592C6A97F5007EB227 /* TextViewWrapper.swift in Sources */, + D7EFBA382CC322F300F45588 /* DamusVideoControlsView.swift in Sources */, D73E5F5A2C6A97F5007EB227 /* MainTabView.swift in Sources */, D73E5F5B2C6A97F5007EB227 /* PubkeyView.swift in Sources */, D73E5F5C2C6A97F5007EB227 /* ReplyView.swift in Sources */, diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index 0d2fbb7e..49c71993 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -169,12 +169,6 @@ struct ImageCarousel: View { Color.clear } } - .onAppear { - if self.model.image_fill == nil, let size = state.video.size_for_url(url) { - let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight) - self.model.image_fill = fill - } - } } func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View { @@ -186,7 +180,7 @@ struct ImageCarousel: View { model.open_sheet = true } case .video(let url): - DamusVideoPlayer(url: url, video_size: $model.video_size, coordinator: state.video, style: .preview(on_tap: { model.open_sheet = true })) + DamusVideoPlayerView(url: url, coordinator: state.video, style: .preview(on_tap: { model.open_sheet = true })) .onChange(of: model.video_size) { size in guard let size else { return } diff --git a/damus/Util/Log.swift b/damus/Util/Log.swift index 830f9e65..2b56c345 100644 --- a/damus/Util/Log.swift +++ b/damus/Util/Log.swift @@ -17,6 +17,7 @@ enum LogCategory: String { case push_notifications case damus_purple case image_uploading + case video_coordination } /// Damus structured logger diff --git a/damus/Views/Images/FullScreenCarouselView.swift b/damus/Views/Images/FullScreenCarouselView.swift index cdfe8961..fef74c1a 100644 --- a/damus/Views/Images/FullScreenCarouselView.swift +++ b/damus/Views/Images/FullScreenCarouselView.swift @@ -8,7 +8,7 @@ import SwiftUI struct FullScreenCarouselView: View { - let video_coordinator: DamusVideoCoordinator + @ObservedObject var video_coordinator: DamusVideoCoordinator let urls: [MediaUrl] @Environment(\.presentationMode) var presentationMode @@ -59,11 +59,16 @@ struct FullScreenCarouselView: View { ForEach(urls.indices, id: \.self) { index in VStack { if case .video = urls[safe: index] { - ImageContainerView(video_coordinator: video_coordinator, url: urls[index], settings: settings, imageDict: $imageDict) - .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { - presentationMode.wrappedValue.dismiss() - })) - .ignoresSafeArea() + ImageContainerView( + video_coordinator: video_coordinator, + url: urls[index], + settings: settings, + imageDict: $imageDict + ) + .modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: { + presentationMode.wrappedValue.dismiss() + })) + .ignoresSafeArea() } else { ZoomableScrollView { @@ -92,11 +97,17 @@ struct FullScreenCarouselView: View { GeometryReader { geo in VStack { if showMenu { - HStack { - NavDismissBarView(navDismissBarContainer: .fullScreenCarousel) - .foregroundColor(.white) + Button(action: { + presentationMode.wrappedValue.dismiss() + }, label: { + Image(systemName: "xmark") + .frame(width: 30, height: 30) + }) + .buttonStyle(PlayerCircleButtonStyle()) + Spacer() + if let url = urls[safe: selectedIndex], let image = imageDict[url.url] { @@ -105,24 +116,30 @@ struct FullScreenCarouselView: View { comment: "Label for the preview of the image being picture"), image: Image(uiImage: image))) { Image(systemName: "ellipsis") - .foregroundColor(.white) - .frame(width: 33, height: 33) - .background(.damusBlack) - .clipShape(Circle()) + .frame(width: 30, height: 30) } - .padding(20) + .buttonStyle(PlayerCircleButtonStyle()) } } + .padding() Spacer() - if urls.count > 1 { - PageControlView(currentPage: $selectedIndex, numberOfPages: urls.count) - .frame(maxWidth: 0, maxHeight: 0) - .padding(.top, 5) + VStack { + if urls.count > 1 { + PageControlView(currentPage: $selectedIndex, numberOfPages: urls.count) + .frame(maxWidth: 0, maxHeight: 0) + .padding(.top, 5) + } + + if let focused_video = video_coordinator.focused_video { + DamusVideoControlsView(video: focused_video) + } + + self.content?() } - - self.content?() + .padding(.top, 5) + .background(Color.black.opacity(0.7)) } } .animation(.easeInOut, value: showMenu) diff --git a/damus/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift index aca6442e..f13dc6ce 100644 --- a/damus/Views/Images/ImageContainerView.swift +++ b/damus/Views/Images/ImageContainerView.swift @@ -18,6 +18,13 @@ struct ImageContainerView: View { @State private var image: UIImage? @State private var showShareSheet = false + init(video_coordinator: DamusVideoCoordinator, url: MediaUrl, settings: UserSettingsStore, imageDict: Binding<[URL: UIImage]>) { + self.video_coordinator = video_coordinator + self.url = url + self.settings = settings + self._imageDict = imageDict + } + private struct ImageHandler: ImageModifier { @Binding var handler: UIImage? @Binding var imageDict: [URL: UIImage] @@ -51,7 +58,7 @@ struct ImageContainerView: View { case .image(let url): Img(url: url) case .video(let url): - DamusVideoPlayer(url: url, video_size: .constant(nil), coordinator: video_coordinator, style: .full, visibility_tracking_method: .generic) + DamusVideoPlayerView(url: url, coordinator: video_coordinator, style: .no_controls(on_tap: nil)) } } } diff --git a/damus/Views/Video/DamusAVPlayerView.swift b/damus/Views/Video/DamusAVPlayerView.swift deleted file mode 100644 index a4036172..00000000 --- a/damus/Views/Video/DamusAVPlayerView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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/DamusVideoControlsView.swift b/damus/Views/Video/DamusVideoControlsView.swift new file mode 100644 index 00000000..44c2bb9a --- /dev/null +++ b/damus/Views/Video/DamusVideoControlsView.swift @@ -0,0 +1,106 @@ +// +// DamusVideoControlsView.swift +// damus +// +// Created by Daniel D’Aquino on 2024-10-18. +// + +import SwiftUI +import AVFoundation + +/// A view with playback video controls, made to work seamlessly with `DamusVideoPlayer` +struct DamusVideoControlsView: View { + @ObservedObject var video: DamusVideoPlayer + + var body: some View { + VStack { + HStack { + Text(video_timestamp_indicator) + .bold() + .foregroundStyle(.white) + + Spacer() + + Button(action: { + video.is_muted.toggle() + }, label: { + if video.is_muted { + Image(systemName: "speaker.slash") + .frame(width: 30, height: 30) + } + else { + Image(systemName: "speaker.wave.2.fill") + .frame(width: 30, height: 30) + } + }) + .buttonStyle(PlayerCircleButtonStyle()) + } + HStack { + Button(action: { + video.is_playing.toggle() + }, label: { + if video.is_playing { + Image(systemName: "pause.fill") + .frame(width: 30, height: 30) + } + else { + Image(systemName: "play.fill") + .frame(width: 30, height: 30) + } + }) + .buttonStyle(PlayerCircleButtonStyle()) + if let video_duration = video.duration, video_duration > 0 { + Slider(value: $video.current_time, in: 0...video_duration, onEditingChanged: { editing in + video.is_editing_current_time = editing + }) + .tint(.white) + } + else { + Spacer() + } + } + } + .padding(10) + } + + var video_timestamp_indicator: String { + guard let video_duration = video.duration else { + return "\(formatTimeInterval(video.current_time))" + } + return "\(formatTimeInterval(video.current_time)) / \(formatTimeInterval(video_duration))" + } + + func formatTimeInterval(_ interval: TimeInterval) -> String { + if interval.isNaN { + return "--:--" + } + let formatter = DateComponentsFormatter() + formatter.allowedUnits = interval >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second] + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = [.pad] + + guard let formattedString = formatter.string(from: interval) else { + return "" + } + return formattedString + } +} + +struct PlayerCircleButtonStyle: ButtonStyle { + let padding: CGFloat + + init(padding: CGFloat = 8.0) { + self.padding = padding + } + + func makeBody(configuration: Self.Configuration) -> some View { + return configuration.label + .padding(padding) + .foregroundColor(Color.white) + .background { + Circle() + .fill(Color.black.opacity(0.5)) + } + .scaleEffect(configuration.isPressed ? 0.95 : 1) + } +} diff --git a/damus/Views/Video/DamusVideoCoordinator.swift b/damus/Views/Video/DamusVideoCoordinator.swift index 3656cde3..d0de60df 100644 --- a/damus/Views/Video/DamusVideoCoordinator.swift +++ b/damus/Views/Video/DamusVideoCoordinator.swift @@ -7,61 +7,125 @@ import Combine import Foundation +import SwiftUICore +import AVFoundation -struct VideoMetadata { - let has_audio: Bool - let size: CGSize -} - -/// DamusVideoCoordinator is responsible for coordinating the various video players in the damus app. +/// DamusVideoCoordinator is responsible for coordinating the various video players throughout the app, and providing a nicely orchestrated experience. /// The goals of this object are to: -/// - ensure some video playing states (such as mute state) are consistent across different video player view instances of the same video +/// - ensure some video playing states (such as mute state and current time) are consistent across different video player view instances of the same video /// - ensure only one video is playing at a time /// - Provide global video playback controls to control the currently playing video /// -/// This is used as a singleton object (one per DamusState), which gets passed around to video players, which can then interact with the coordinator to ensure an app-wide coherent experience +/// This is used as a singleton object (one global object per `DamusState`), which gets passed around to video players, which can then interact with the coordinator to ensure an app-wide coherent experience /// -/// A good analogy here is that video players and their models/states are like individual car drivers, and this coordinator is like a traffic control person that ensures cars don't crash each other. +/// A good analogy here is that video players and their models/states are like individual cars and their drivers, and this coordinator is like a traffic control person + traffic lights that ensures cars don't crash each other. final class DamusVideoCoordinator: ObservableObject { - private var mute_states: [URL: Bool] = [:] - private var metadatas: [URL: VideoMetadata] = [:] + // MARK: - States + + // MARK: State and information about each video + private var players: [URL: DamusVideoPlayer] = [:] + + // MARK: Main stage requests from player views + // The stacks of video player views that have marked themselves as visible on the user screen. + // + // Because our visibility tracker cannot tell if a player is obscured by a view in front of it, + // we need to implement two stacks representing the different view layers: + // - Normal layer: For timelines, threads, etc + // - Full screen layer: For full screen views + + private var normal_layer_main_stage_requests: [MainStageRequest] = [] + private var full_screen_layer_stage_requests: [MainStageRequest] = [] // MARK: Coordinator state // Members representing the state of the coordinator itself private var full_screen_mode: Bool = false { didSet { + self.select_focused_video() } } - @Published var focused_model_id: UUID? - - func toggle_should_mute_video(url: URL) { - let state = mute_states[url] ?? true - mute_states[url] = !state - - objectWillChange.send() + /// The video currently in focus + /// This can only be chosen by the coordinator. To get a video in focus, use one of the instance methods that provide an interface for focus control. + @MainActor + @Published private(set) var focused_video: DamusVideoPlayer? { + didSet { + oldValue?.pause() + focused_video?.play() + Log.info("VIDEO_COORDINATOR: %s paused, playing %s", for: .video_coordination, oldValue?.url.absoluteString ?? "no video", focused_video?.url.absoluteString ?? "no video") + } } - func should_mute_video(url: URL) -> Bool { - mute_states[url] ?? true + // MARK: - Interface to set and fetch information about each different video + + + @MainActor + func get_player(for url: URL) -> DamusVideoPlayer { + if let player = self.players[url] { + return player + } + let player = DamusVideoPlayer(url: url) + self.players[url] = player + return player + } + + + // MARK: - Interface for video players to come to the foreground + // This portion provides an interface for video players to signal their visibility changes, + // and implements some coordination logic to choose which video to play and pause at a given time. + + func request_main_stage(_ request: MainStageRequest) { + Log.info("VIDEO_COORDINATOR: %s requested main stage", for: .video_coordination, request.requestor_id.uuidString) + switch request.layer_context { + case .normal_layer: + if normal_layer_main_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already + normal_layer_main_stage_requests.append(request) + case .full_screen_layer: + if full_screen_layer_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already + full_screen_layer_stage_requests.append(request) + } + self.select_focused_video() } - func set_metadata(_ metadata: VideoMetadata, url: URL) { - metadatas[url] = metadata + func give_up_main_stage(request_id: UUID) { + Log.info("VIDEO_COORDINATOR: %s gave up the main stage", for: .video_coordination, request_id.uuidString) + normal_layer_main_stage_requests.removeAll(where: { $0.requestor_id == request_id }) + full_screen_layer_stage_requests.removeAll(where: { $0.requestor_id == request_id }) + self.select_focused_video() } - func metadata(for url: URL) -> VideoMetadata? { - metadatas[url] - } - // MARK: - Additional interface to help with video coordination func set_full_screen_mode(_ is_full_screen: Bool) { full_screen_mode = is_full_screen } - func size_for_url(_ url: URL) -> CGSize? { - metadatas[url]?.size + // MARK: - Internal video coordination logic + + private func select_focused_video() { + // This function may be called during a SwiftUI view update, + // so schedule this change for the next render pass to ensure state immutability/stability within a single render pass + DispatchQueue.main.async { [weak self] in // [weak self] to safeguard in cases this object is deallocated by the time we execute this task + guard let self else { return } + // The focused video will always be the last one that was inserted — similar to a LIFO stack + // The reason is that: + // - both a LIFO stack and a FIFO queue are decent at selecting videos when scrolling on the Y axis (timeline), + // - The LIFO stack is better at selecting videos when navigating on the Z axis (e.g. opening and closing full screen covers or sheets), since those sheets operate like a stack as well + let winning_request = self.full_screen_mode ? self.full_screen_layer_stage_requests.last : self.normal_layer_main_stage_requests.last + self.focused_video = winning_request?.player + winning_request?.main_stage_granted?() + } + Log.info("VIDEO_COORDINATOR: fullscreen layer main stage request stack: %s", for: .video_coordination, full_screen_layer_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription) + Log.info("VIDEO_COORDINATOR: normal layer main stage request stack: %s", for: .video_coordination, normal_layer_main_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription) + Log.info("VIDEO_COORDINATOR: full_screen_mode: %s", for: .video_coordination, String(describing: self.full_screen_mode)) + } + + // MARK: - Helper structures + + struct MainStageRequest { + var requestor_id: UUID + var layer_context: ViewLayerContext + var player: DamusVideoPlayer + var main_stage_granted: (() -> Void)? } } diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index e5452f61..67ba4489 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -1,151 +1,248 @@ // -// VideoPlayerView.swift +// DamusVideoPlayer.swift // damus // -// Created by William Casarin on 2023-04-05. +// Created by Bryan Montz on 9/5/23. // +import AVFoundation +import AVKit +import Combine +import Foundation import SwiftUI -/// get coordinates in Global reference frame given a Local point & geometry -func globalCoordinate(localX x: CGFloat, localY y: CGFloat, - localGeometry geo: GeometryProxy) -> CGPoint { - let localPoint = CGPoint(x: x, y: y) - return geo.frame(in: .global).origin.applying( - .init(translationX: localPoint.x, y: localPoint.y) - ) -} - -struct DamusVideoPlayer: View { +/// DamusVideoPlayer has the function of wrapping `AVPlayer` and exposing a control interface that integrates seamlessly with SwiftUI views +/// +/// This is **NOT** a video player view. This is a headless video object concerned about the video and its playback. To display a video, you need `DamusVideoPlayerView` +/// This is also **NOT** a control view. Please see `DamusVideoControlsView` for that. +/// +/// **Implementation notes:** +/// - `@MainActor` is needed because `@Published` properties need to be updated on the main thread to avoid SwiftUI mutations within a single render pass +/// - `@Published` variables are the chosen interface because they integrate very seamlessly with SwiftUI views. Avoid the use of procedural functions to avoid SwiftUI state desync. +@MainActor final class DamusVideoPlayer: ObservableObject { + + // MARK: Immutable foundational instance members + + /// The URL of the video 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 + /// The underlying AVPlayer that we are wrapping. + /// This is not public because we don't want any callers of this class controlling the `AVPlayer` directly, we want them to go through our interface + /// This measure helps avoid state inconsistencies and other flakiness. DO NOT USE THIS OUTSIDE `DamusVideoPlayer` + private let player: AVPlayer - init(url: URL, video_size: Binding, coordinator: DamusVideoCoordinator, style: Style, visibility_tracking_method: VisibilityTrackingMethod = .y_scroll) { - self.url = url - let mute: Bool? - if case .full = style { - mute = false + + // MARK: SwiftUI-friendly interface + + /// Indicates whether the video has audio at all + @Published private(set) var has_audio = false + /// Whether whether this is a live video + @Published private(set) var is_live = false + /// The video size + @Published private(set) var video_size: CGSize? + /// Whether or not to mute the video + @Published var is_muted = true { + didSet { + if oldValue == is_muted { return } + player.isMuted = is_muted } - else { - mute = nil - } - _model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, coordinator: coordinator, mute: mute)) - self.visibility_tracking_method = visibility_tracking_method - self.style = style } - - var body: some View { - ZStack { - if case .full = self.style { - DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: true) + /// Whether the video is loading + @Published private(set) var is_loading = true + /// The current time of playback, in seconds + /// Usage note: If editing (such as in a slider), make sure to set `is_editing_current_time` to `true` to detach this value from the current playback + @Published var current_time: TimeInterval = .zero + /// Whether video is playing or not + @Published var is_playing = false { + didSet { + if oldValue == is_playing { return } + // When scrubbing, the playback control is temporarily decoupled, so don't play/pause our `AVPlayer` + // When scrubbing stops, the `is_editing_current_time` handler will automatically play/pause depending on `is_playing` + if is_editing_current_time { return } + if is_playing { + player.play() } - 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() - .progressViewStyle(.circular) - .tint(.white) - .scaleEffect(CGSize(width: 1.5, height: 1.5)) - } - - if case .preview = self.style { - if model.has_audio { - mute_button - } - } - if model.is_live { - live_indicator + else { + player.pause() } } - .on_visibility_change(perform: { new_visibility in - model.set_view_is_visible(new_visibility) - }, method: self.visibility_tracking_method == .generic ? .no_y_scroll_detection : .standard) } - - private var mute_icon: String { - !model.has_audio || model.is_muted ? "speaker.slash" : "speaker" - } - - private var mute_icon_color: Color { - model.has_audio ? .white : .red - } - - private var mute_button: some View { - HStack { - Spacer() - VStack { - Spacer() - - Button { - model.did_tap_mute_button() - } label: { - ZStack { - Circle() - .opacity(0.2) - .frame(width: 32, height: 32) - .foregroundColor(.black) - - Image(systemName: mute_icon) - .padding() - .foregroundColor(mute_icon_color) + /// Whether the current time is being manually edited (e.g. when user is scrubbing through the video) + /// **Implementation note:** When set to `true`, this decouples the `current_time` from the video playback observer — in a way analogous to a clutch on a standard transmission car, if you are into Automotive engineering. + @Published var is_editing_current_time = false { + didSet { + if oldValue == is_editing_current_time { return } + if !is_editing_current_time { + Task { + await self.player.seek(to: CMTime(seconds: current_time, preferredTimescale: 60)) + // Start playing video again, if we were playing before scrubbing + if self.is_playing { + self.player.play() } } } - } - } - - private var live_indicator: some View { - VStack { - HStack { - Text("LIVE", comment: "Text indicator that the video is a livestream.") - .bold() - .foregroundColor(.red) - .padding(.horizontal) - .padding(.vertical, 5) - .background( - Capsule() - .fill(Color.black.opacity(0.5)) - ) - .padding([.top, .leading]) - Spacer() + else { + // Pause playing video, if we were playing before we started scrubbing + if self.is_playing { self.player.pause() } + } + } + } + /// The duration of the video, in seconds. + var duration: TimeInterval? { + return player.currentItem?.duration.seconds + } + + // MARK: Internal instance members + + private var cancellables = Set() + private var videoSizeObserver: NSKeyValueObservation? + private var videoDurationObserver: NSKeyValueObservation? + private var videoCurrentTimeObserver: Any? + private var videoIsPlayingObserver: NSKeyValueObservation? + + + // MARK: - Initialization + + public init(url: URL) { + self.url = url + self.player = AVPlayer(playerItem: AVPlayerItem(url: url)) + self.video_size = nil + + Task { + await load() + } + + player.isMuted = is_muted + + NotificationCenter.default.addObserver( + self, + selector: #selector(did_play_to_end), + name: Notification.Name.AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) + + observeVideoSize() + observeDuration() + observeCurrentTime() + observeVideoIsPlaying() + } + + // MARK: - Observers + // Functions that allow us to observe certain variables and publish their changes for view updates + // These are all private because they are part of the internal logic + + private func observeVideoSize() { + videoSizeObserver = player.currentItem?.observe(\.presentationSize, options: [.new], changeHandler: { [weak self] (playerItem, change) in + guard let self else { return } + if let newSize = change.newValue, newSize != .zero { + DispatchQueue.main.async { + self.video_size = newSize // Update the bound value + } + } + }) + } + + private func observeDuration() { + videoDurationObserver = player.currentItem?.observe(\.duration, options: [.new], changeHandler: { [weak self] (playerItem, change) in + guard let self else { return } + if let newDuration = change.newValue, newDuration != .zero { + DispatchQueue.main.async { + self.is_live = newDuration == .indefinite + } + } + }) + } + + private func observeCurrentTime() { + videoCurrentTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in + guard let self else { return } + DispatchQueue.main.async { // Must use main thread to update @Published properties + if self.is_editing_current_time == false { + self.current_time = time.seconds + } } - 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)?) + private func observeVideoIsPlaying() { + videoIsPlayingObserver = player.observe(\.rate, changeHandler: { [weak self] (player, change) in + guard let self else { return } + guard let new_rate = change.newValue else { return } + DispatchQueue.main.async { + self.is_playing = new_rate > 0 + } + }) } - 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 + // MARK: - Other internal logic functions + + private func load() async { + has_audio = await self.video_has_audio() + is_loading = false + } + + private func video_has_audio() async -> Bool { + do { + let hasAudibleTracks = ((try await player.currentItem?.asset.loadMediaSelectionGroup(for: .audible)) != nil) + let tracks = try? await player.currentItem?.asset.load(.tracks) + let hasAudioTrack = tracks?.filter({ t in t.mediaType == .audio }).first != nil // Deal with odd cases of audio only MOV + return hasAudibleTracks || hasAudioTrack + } catch { + return false + } + } + + @objc private func did_play_to_end() { + player.seek(to: CMTime.zero) + player.play() + } + + // MARK: - Deinit + + deinit { + videoSizeObserver?.invalidate() + videoDurationObserver?.invalidate() + videoIsPlayingObserver?.invalidate() + } + + // MARK: - Convenience interface functions + + func play() { + self.is_playing = true + } + + func pause() { + self.is_playing = false } } -struct DamusVideoPlayer_Previews: PreviewProvider { - static var previews: some View { - Group { - DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), coordinator: DamusVideoCoordinator(), style: .full) - .environmentObject(OrientationTracker()) - .previewDisplayName("Full video player") - - DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), coordinator: DamusVideoCoordinator(), style: .preview(on_tap: nil)) - .environmentObject(OrientationTracker()) - .previewDisplayName("Preview video player") + +extension DamusVideoPlayer { + /// The simplest view for a `DamusVideoPlayer` object. + /// + /// Other views with more features should use this as a base. + /// + /// ## Implementation notes: + /// + /// 1. This is defined inside `DamusVideoPlayer` to allow it to access the private `AVPlayer` instance required to initialize it, which is otherwise hidden away from every other class. + /// 2. DO NOT write any `AVPlayer` control/manipulation code, the `AVPlayer` instance is owned by `DamusVideoPlayer` and only managed there to keep things sane. + struct BaseView: UIViewControllerRepresentable { + + let player: DamusVideoPlayer + let show_playback_controls: Bool + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.showsPlaybackControls = show_playback_controls + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + if uiViewController.player == nil { + uiViewController.player = player.player + } + } + + static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) { + uiViewController.player = nil } } } diff --git a/damus/Views/Video/DamusVideoPlayerView.swift b/damus/Views/Video/DamusVideoPlayerView.swift new file mode 100644 index 00000000..3485c8f2 --- /dev/null +++ b/damus/Views/Video/DamusVideoPlayerView.swift @@ -0,0 +1,199 @@ +// +// DamusVideoPlayerView.swift +// damus +// +// Created by William Casarin on 2023-04-05. +// + +import SwiftUI + +/// get coordinates in Global reference frame given a Local point & geometry +func globalCoordinate(localX x: CGFloat, localY y: CGFloat, + localGeometry geo: GeometryProxy) -> CGPoint { + let localPoint = CGPoint(x: x, y: y) + return geo.frame(in: .global).origin.applying( + .init(translationX: localPoint.x, y: localPoint.y) + ) +} + +/// A feature-rich, generic video player view that plays along well with the multi-video coordinator +struct DamusVideoPlayerView: View { + let url: URL + @ObservedObject var model: DamusVideoPlayer + let style: Style + let main_state_requestor_id: UUID = UUID() + + @State var is_visible: Bool = false { + didSet { + if self.is_visible { + // We are visible, request main stage + video_coordinator.request_main_stage( + DamusVideoCoordinator.MainStageRequest( + requestor_id: self.main_state_requestor_id, + layer_context: self.view_layer, + player: self.model, + main_stage_granted: self.main_stage_granted + ) + ) + } + else { + // We are no longer visible, give up the main stage + video_coordinator.give_up_main_stage(request_id: self.main_state_requestor_id) + } + } + } + + /// The context this video player is in. + @Environment(\.view_layer_context) var view_layer_context + /// The video coordinator in this environment + let video_coordinator: DamusVideoCoordinator + + var view_layer: ViewLayerContext { + return view_layer_context ?? .normal_layer + } + + init(url: URL, coordinator: DamusVideoCoordinator, style: Style) { + self.url = url + self.model = coordinator.get_player(for: url) + self.video_coordinator = coordinator + self.style = style + } + + init(model: DamusVideoPlayer, coordinator: DamusVideoCoordinator, style: Style) { + self.url = model.url + self.model = model + self.video_coordinator = coordinator + self.style = style + } + + var body: some View { + ZStack { + switch self.style { + case .full: + DamusVideoPlayer.BaseView(player: model, show_playback_controls: true) + case .preview(on_tap: let on_tap), .no_controls(on_tap: let on_tap): + if let on_tap { + DamusVideoPlayer.BaseView(player: model, show_playback_controls: false) + .highPriorityGesture(TapGesture().onEnded({ + on_tap() + })) + } + else { + DamusVideoPlayer.BaseView(player: model, show_playback_controls: false) + } + } + + if model.is_loading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + .scaleEffect(CGSize(width: 1.5, height: 1.5)) + } + + if case .preview = self.style { + if model.has_audio { + mute_button + } + } + if model.is_live { + live_indicator + } + } + .on_visibility_change(perform: { new_is_visible in + self.is_visible = new_is_visible + }, method: self.visibility_tracking_method) + } + + private var visibility_tracking_method: VisibilityTracker.Method { + switch self.view_layer { + case .normal_layer: + return .standard + case .full_screen_layer: + return .no_y_scroll_detection + } + } + + func main_stage_granted() { + switch self.style { + case .full, .no_controls: + self.model.is_muted = false + case .preview: + self.model.is_muted = true + } + } + + private var mute_icon: String { + !model.has_audio || model.is_muted ? "speaker.slash" : "speaker" + } + + private var mute_icon_color: Color { + model.has_audio ? .white : .red + } + + private var mute_button: some View { + HStack { + Spacer() + VStack { + ZStack { + Circle() + .opacity(0.2) + .frame(width: 32, height: 32) + .foregroundColor(.black) + + Image(systemName: mute_icon) + .padding() + .foregroundColor(mute_icon_color) + } + .highPriorityGesture(TapGesture().onEnded { + model.is_muted.toggle() + }) + Spacer() + } + } + } + + private var live_indicator: some View { + VStack { + HStack { + Text("LIVE", comment: "Text indicator that the video is a livestream.") + .bold() + .foregroundColor(.red) + .padding(.horizontal) + .padding(.vertical, 5) + .background( + Capsule() + .fill(Color.black.opacity(0.5)) + ) + .padding([.top, .leading]) + Spacer() + } + Spacer() + } + } + + // MARK: - Helper structures + + 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)?) + /// A video player without any playback controls, suitable if using custom controls elsewhere. + case no_controls(on_tap: (() -> Void)?) + } +} +struct DamusVideoPlayer_Previews: PreviewProvider { + static var previews: some View { + Group { + DamusVideoPlayerView(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, coordinator: DamusVideoCoordinator(), style: .full) + .environmentObject(OrientationTracker()) + .environmentObject(DamusVideoCoordinator()) + .previewDisplayName("Full video player") + + DamusVideoPlayerView(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, coordinator: DamusVideoCoordinator(), style: .preview(on_tap: nil)) + .environmentObject(OrientationTracker()) + .environmentObject(DamusVideoCoordinator()) + .previewDisplayName("Preview video player") + } + } +} diff --git a/damus/Views/Video/DamusVideoPlayerViewModel.swift b/damus/Views/Video/DamusVideoPlayerViewModel.swift deleted file mode 100644 index 28f019ab..00000000 --- a/damus/Views/Video/DamusVideoPlayerViewModel.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// DamusVideoPlayerViewModel.swift -// damus -// -// Created by Bryan Montz on 9/5/23. -// - -import AVFoundation -import AVKit -import Combine -import Foundation -import SwiftUI - -func video_has_audio(player: AVPlayer) async -> Bool { - do { - let hasAudibleTracks = ((try await player.currentItem?.asset.loadMediaSelectionGroup(for: .audible)) != nil) - let tracks = try? await player.currentItem?.asset.load(.tracks) - let hasAudioTrack = tracks?.filter({ t in t.mediaType == .audio }).first != nil // Deal with odd cases of audio only MOV - return hasAudibleTracks || hasAudioTrack - } catch { - return false - } -} - -@MainActor -final class DamusVideoPlayerViewModel: ObservableObject { - - private let url: URL - private let player_item: AVPlayerItem - let player: AVPlayer - fileprivate let coordinator: DamusVideoCoordinator - let player_view_controller = AVPlayerViewController() - let id = UUID() - - @Published var has_audio = false - @Published var is_live = false - @Binding var video_size: CGSize? - @Published var is_muted = true - @Published var is_loading = true - - private var cancellables = Set() - - private var videoSizeObserver: NSKeyValueObservation? - private var videoDurationObserver: NSKeyValueObservation? - - private var is_scrolled_into_view = false { - didSet { - if is_scrolled_into_view && !oldValue { - // we have just scrolled from out of view into view - coordinator.focused_model_id = id - } else if !is_scrolled_into_view && oldValue { - // we have just scrolled from in view to out of view - if coordinator.focused_model_id == id { - coordinator.focused_model_id = nil - } - } - } - } - - init(url: URL, video_size: Binding, coordinator: DamusVideoCoordinator, mute: Bool? = nil) { - self.url = url - player_item = AVPlayerItem(url: url) - player = AVPlayer(playerItem: player_item) - self.coordinator = coordinator - _video_size = video_size - - Task { - await load() - } - - is_muted = mute ?? coordinator.should_mute_video(url: url) - player.isMuted = is_muted - - NotificationCenter.default.addObserver( - self, - selector: #selector(did_play_to_end), - name: Notification.Name.AVPlayerItemDidPlayToEndTime, - object: player_item - ) - - coordinator.$focused_model_id - .sink { [weak self] model_id in - model_id == self?.id ? self?.player.play() : self?.player.pause() - } - .store(in: &cancellables) - - observeVideoSize() - observeDuration() - } - - private func observeVideoSize() { - videoSizeObserver = player.currentItem?.observe(\.presentationSize, options: [.new], changeHandler: { [weak self] (playerItem, change) in - guard let self else { return } - if let newSize = change.newValue, newSize != .zero { - DispatchQueue.main.async { - self.video_size = newSize // Update the bound value - } - } - }) - } - - private func observeDuration() { - videoDurationObserver = player.currentItem?.observe(\.duration, options: [.new], changeHandler: { [weak self] (playerItem, change) in - guard let self else { return } - if let newDuration = change.newValue, newDuration != .zero { - DispatchQueue.main.async { - self.is_live = newDuration == .indefinite - } - } - }) - } - - private func load() async { - if let meta = coordinator.metadata(for: url) { - has_audio = meta.has_audio - video_size = meta.size - } else { - has_audio = await video_has_audio(player: player) - } - - is_loading = false - } - - func did_tap_mute_button() { - is_muted.toggle() - player.isMuted = is_muted - coordinator.toggle_should_mute_video(url: url) - } - - func set_view_is_visible(_ is_visible: Bool) { - is_scrolled_into_view = is_visible - } - - func view_did_disappear() { - set_view_is_visible(false) - } - - @objc private func did_play_to_end() { - player.seek(to: CMTime.zero) - player.play() - } - - deinit { - videoSizeObserver?.invalidate() - videoDurationObserver?.invalidate() - } -}