damus

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

commit a6fb175b98307761f2b29058e97731621c53fd59
parent 185fba150fcaf2ba605967dbd54d4bfcb9766461
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 30 May 2023 18:02:19 -0700

Add Full-Bleed Video Player

Changelog-Added: Add new full-bleed video player

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 33+++++++++++++++++++++++++++++++++
Mdamus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 9+++++++++
Mdamus/Components/ImageCarousel.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mdamus/Util/EventCache.swift | 30++++++++++++++++++++++++++++++
Mdamus/Util/PreviewCache.swift | 10----------
Mdamus/Views/Images/ImageContainerView.swift | 19+++++++++++++++----
Mdamus/Views/Images/ImageView.swift | 9+++++----
Mdamus/Views/NoteContentView.swift | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Adamus/Views/Video/DamusVideoPlayer.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Video/VideoPlayer.swift | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 725 insertions(+), 94 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -49,6 +49,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 */; }; 4C216F32286E388800040376 /* DMChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F31286E388800040376 /* DMChatView.swift */; }; 4C216F34286F5ACD00040376 /* DMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F33286F5ACD00040376 /* DMView.swift */; }; 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */; }; @@ -207,6 +208,8 @@ 4CC7AAFA297F64AC00430951 /* EventMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAF9297F64AC00430951 /* EventMenu.swift */; }; 4CCEB7AE29B53D260078AA28 /* SearchingEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCEB7AD29B53D260078AA28 /* SearchingEventView.swift */; }; 4CCEB7B029B5415A0078AA28 /* SearchingProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCEB7AF29B5415A0078AA28 /* SearchingProfileView.swift */; }; + 4CCF9AAF2A1FDBDB00E03CFB /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */; }; + 4CCF9AB22A1FE80C00E03CFB /* GSPlayer in Frameworks */ = {isa = PBXBuildFile; productRef = 4CCF9AB12A1FE80C00E03CFB /* GSPlayer */; }; 4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */; }; 4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641A28A1641400B6928F /* EndBlock.swift */; }; 4CDA128A29E9D10C0006FA5A /* SignalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDA128929E9D10C0006FA5A /* SignalView.swift */; }; @@ -459,6 +462,7 @@ 4C1A9A2229DDDB8100516EAC /* IconLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconLabel.swift; sourceTree = "<group>"; }; 4C1A9A2429DDDF2600516EAC /* ZapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapSettingsView.swift; sourceTree = "<group>"; }; 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSettingsView.swift; sourceTree = "<group>"; }; + 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoPlayer.swift; sourceTree = "<group>"; }; 4C216F31286E388800040376 /* DMChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMChatView.swift; sourceTree = "<group>"; }; 4C216F33286F5ACD00040376 /* DMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMView.swift; sourceTree = "<group>"; }; 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDismissKeyboard.swift; sourceTree = "<group>"; }; @@ -651,6 +655,7 @@ 4CC7AAF9297F64AC00430951 /* EventMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMenu.swift; sourceTree = "<group>"; }; 4CCEB7AD29B53D260078AA28 /* SearchingEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingEventView.swift; sourceTree = "<group>"; }; 4CCEB7AF29B5415A0078AA28 /* SearchingProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingProfileView.swift; sourceTree = "<group>"; }; + 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; }; 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadModel.swift; sourceTree = "<group>"; }; 4CD7641A28A1641400B6928F /* EndBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndBlock.swift; sourceTree = "<group>"; }; 4CDA128929E9D10C0006FA5A /* SignalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalView.swift; sourceTree = "<group>"; }; @@ -771,6 +776,7 @@ buildActionMask = 2147483647; files = ( 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */, + 4CCF9AB22A1FE80C00E03CFB /* GSPlayer in Frameworks */, 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -949,6 +955,15 @@ path = Settings; sourceTree = "<group>"; }; + 4C1A9A2829DDF53B00516EAC /* Video */ = { + isa = PBXGroup; + children = ( + 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */, + 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */, + ); + path = Video; + sourceTree = "<group>"; + }; 4C30AC7029A5676F00E2BD5A /* Notifications */ = { isa = PBXGroup; children = ( @@ -975,6 +990,7 @@ 4C7D09692A0AEA0400943473 /* CodeScanner */, 4C7D095A2A098C5C00943473 /* Wallet */, 4C8D1A6D29F31E4100ACDF75 /* Buttons */, + 4C1A9A2829DDF53B00516EAC /* Video */, 4C1A9A1B29DDCF8B00516EAC /* Settings */, 4CFF8F6129CC9A80008DB934 /* Images */, 4CCEB7AC29B53D180078AA28 /* Search */, @@ -1501,6 +1517,7 @@ packageProductDependencies = ( 4C649880286E0EE300EAE2B3 /* secp256k1 */, 4C06670328FC7EC500038D2A /* Kingfisher */, + 4CCF9AB12A1FE80C00E03CFB /* GSPlayer */, ); productName = damus; productReference = 4CE6DEE327F7A08100C66700 /* damus.app */; @@ -1605,6 +1622,7 @@ packageReferences = ( 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */, 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */, + 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */, ); productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */; projectDirPath = ""; @@ -1669,6 +1687,7 @@ 4C285C8428385690008A31F1 /* CreateAccountView.swift in Sources */, 4C216F34286F5ACD00040376 /* DMView.swift in Sources */, 4C3EA64428FF558100C48A62 /* sha256.c in Sources */, + 4CCF9AAF2A1FDBDB00E03CFB /* VideoPlayer.swift in Sources */, 4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */, 4C363AA828297703006E126D /* InsertSort.swift in Sources */, 4C285C86283892E7008A31F1 /* CreateAccountModel.swift in Sources */, @@ -1814,6 +1833,7 @@ F79C7FAD29D5E9620000F946 /* EditProfilePictureControl.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, + 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, 4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */, @@ -2441,6 +2461,14 @@ revision = 40b4b38b3b1c83f7088c76189a742870e0ca06a9; }; }; + 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/wxxsw/GSPlayer"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.26; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2454,6 +2482,11 @@ package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; + 4CCF9AB12A1FE80C00E03CFB /* GSPlayer */ = { + isa = XCSwiftPackageProductDependency; + package = 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */; + productName = GSPlayer; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "pins" : [ { + "identity" : "gsplayer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/wxxsw/GSPlayer", + "state" : { + "revision" : "aa6dad7943d52f5207f7fcc2ad3e4274583443b8", + "version" : "0.2.26" + } + }, + { "identity" : "kingfisher", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift @@ -54,7 +54,7 @@ enum ImageShape { // MARK: - Image Carousel struct ImageCarousel: View { - var urls: [URL] + var urls: [MediaUrl] let evid: String @@ -69,11 +69,13 @@ struct ImageCarousel: View { @State private var firstImageHeight: CGFloat? = nil @State private var currentImageHeight: CGFloat? @State private var selectedIndex = 0 + @State private var video_size: CGSize? = nil - init(state: DamusState, evid: String, urls: [URL]) { + init(state: DamusState, evid: String, urls: [MediaUrl]) { _open_sheet = State(initialValue: false) _current_url = State(initialValue: nil) - _image_fill = State(initialValue: state.previews.lookup_image_meta(evid)) + let media_model = state.events.get_cache_data(evid).media_metadata_model + _image_fill = State(initialValue: media_model.fill) self.urls = urls self.evid = evid self.state = state @@ -102,77 +104,108 @@ struct ImageCarousel: View { } } .onAppear { - if self.image_fill == nil, - let meta = state.events.lookup_img_metadata(url: url), - let size = meta.meta.dim?.size - { + if self.image_fill == nil, let size = state.events.lookup_media_size(url: url) { let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight) self.image_fill = fill } } } - var Images: some View { + func video_model(_ url: URL) -> VideoPlayerModel { + return state.events.get_video_player_model(url: url) + } + + func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View { + Group { + switch url { + case .image(let url): + Img(geo: geo, url: url, index: index) + .onTapGesture { + open_sheet = true + } + case .video(let url): + DamusVideoPlayer(url: url, model: video_model(url), video_size: $video_size) + .onChange(of: video_size) { size in + guard let size else { return } + + let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight) + + print("video_size changed \(size)") + if self.image_fill == nil { + print("video_size firstImageHeight \(fill.height)") + firstImageHeight = fill.height + state.events.get_cache_data(evid).media_metadata_model.fill = fill + } + + self.image_fill = fill + } + } + } + } + + func Img(geo: GeometryProxy, url: URL, index: Int) -> some View { + KFAnimatedImage(url) + .callbackQueue(.dispatch(.global(qos:.background))) + .backgroundDecode(true) + .imageContext(.note, disable_animation: state.settings.disable_animation) + .image_fade(duration: 0.25) + .cancelOnDisappear(true) + .configure { view in + view.framePreloadCount = 3 + } + .imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in + state.events.get_cache_data(evid).media_metadata_model.fill = fill + // blur hash can be discarded when we have the url + // NOTE: this is the wrong place for this... we need to remove + // it when the image is loaded in memory. This may happen + // earlier than this (by the preloader, etc) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + state.events.lookup_img_metadata(url: url)?.state = .not_needed + } + image_fill = fill + if index == 0 { + firstImageHeight = fill.height + //maxHeight = firstImageHeight ?? maxHeight + } else { + //maxHeight = firstImageHeight ?? fill.height + } + } + .background { + Placeholder(url: url, geo_size: geo.size, num_urls: urls.count) + } + .aspectRatio(contentMode: filling ? .fill : .fit) + .position(x: geo.size.width / 2, y: geo.size.height / 2) + .tabItem { + Text(url.absoluteString) + } + .id(url.absoluteString) + .padding(0) + + } + + var Medias: some View { TabView(selection: $selectedIndex) { ForEach(urls.indices, id: \.self) { index in - let url = urls[index] GeometryReader { geo in - KFAnimatedImage(url) - .callbackQueue(.dispatch(.global(qos:.background))) - .backgroundDecode(true) - .imageContext(.note, disable_animation: state.settings.disable_animation) - .image_fade(duration: 0.25) - .cancelOnDisappear(true) - .configure { view in - view.framePreloadCount = 3 - } - .imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in - state.previews.cache_image_meta(evid: evid, image_fill: fill) - // blur hash can be discarded when we have the url - // NOTE: this is the wrong place for this... we need to remove - // it when the image is loaded in memory. This may happen - // earlier than this (by the preloader, etc) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - state.events.lookup_img_metadata(url: url)?.state = .not_needed - } - image_fill = fill - if index == 0 { - firstImageHeight = fill.height - //maxHeight = firstImageHeight ?? maxHeight - } else { - //maxHeight = firstImageHeight ?? fill.height - } - } - .background { - Placeholder(url: url, geo_size: geo.size, num_urls: urls.count) - } - .aspectRatio(contentMode: filling ? .fill : .fit) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .tabItem { - Text(url.absoluteString) - } - .id(url.absoluteString) - .padding(0) + Media(geo: geo, url: urls[index], index: index) } } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .fullScreenCover(isPresented: $open_sheet) { - ImageView(urls: urls, disable_animation: state.settings.disable_animation) + ImageView(cache: state.events, urls: urls, disable_animation: state.settings.disable_animation) } .frame(height: height) - .onTapGesture { - open_sheet = true - } .onChange(of: selectedIndex) { value in - selectedIndex = value - } + selectedIndex = value + } .tabViewStyle(PageTabViewStyle()) } var body: some View { VStack { - Images + Medias + .onTapGesture { } // This is our custom carousel image indicator CarouselDotsView(urls: urls, selectedIndex: $selectedIndex) @@ -181,8 +214,8 @@ struct ImageCarousel: View { } // MARK: - Custom Carousel -struct CarouselDotsView: View { - let urls: [URL] +struct CarouselDotsView<T>: View { + let urls: [T] @Binding var selectedIndex: Int var body: some View { @@ -254,7 +287,8 @@ public struct ImageFill { // MARK: - Preview Provider struct ImageCarousel_Previews: PreviewProvider { static var previews: some View { - ImageCarousel(state: test_damus_state(), evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!]) + let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) + ImageCarousel(state: test_damus_state(), evid: "evid", urls: [url, url]) } } diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift @@ -101,6 +101,10 @@ class RelativeTimeModel: ObservableObject { @Published var value: String = "" } +class MediaMetaModel: ObservableObject { + @Published var fill: ImageFill? = nil +} + class EventData { var translations_model: TranslationModel var artifacts_model: NoteArtifactsModel @@ -108,6 +112,7 @@ class EventData { var zaps_model : ZapsDataModel var relative_time: RelativeTimeModel = RelativeTimeModel() var validated: ValidationResult + var media_metadata_model: MediaMetaModel var translations: TranslateStatus { return translations_model.state @@ -126,6 +131,7 @@ class EventData { self.artifacts_model = .init(state: .not_loaded) self.zaps_model = .init(zaps) self.validated = .unknown + self.media_metadata_model = MediaMetaModel() self.preview_model = .init(state: .not_loaded) } } @@ -135,6 +141,7 @@ class EventCache { private var replies = ReplyMap() private var cancellable: AnyCancellable? private var image_metadata: [String: ImageMetadataState] = [:] + private var video_meta: [String: VideoPlayerModel] = [:] private var event_data: [String: EventData] = [:] //private var thread_latest: [String: Int64] @@ -194,6 +201,28 @@ class EventCache { return image_metadata[url.absoluteString.lowercased()] } + func lookup_media_size(url: URL) -> CGSize? { + if let img_meta = lookup_img_metadata(url: url) { + return img_meta.meta.dim?.size + } + + return get_video_player_model(url: url).size + } + + func store_video_player_model(url: URL, meta: VideoPlayerModel) { + video_meta[url.absoluteString] = meta + } + + func get_video_player_model(url: URL) -> VideoPlayerModel { + if let model = video_meta[url.absoluteString] { + return model + } + + let model = VideoPlayerModel() + video_meta[url.absoluteString] = model + return model + } + func parent_events(event: NostrEvent) -> [NostrEvent] { var parents: [NostrEvent] = [] @@ -257,6 +286,7 @@ class EventCache { private func prune() { events = [:] + video_meta = [:] event_data = [:] replies.replies = [:] } diff --git a/damus/Util/PreviewCache.swift b/damus/Util/PreviewCache.swift @@ -66,22 +66,12 @@ enum PreviewState { class PreviewCache { private var previews: [String: Preview] - private var image_meta: [String: ImageFill] func lookup(_ evid: String) -> Preview? { return previews[evid] } - func lookup_image_meta(_ evid: String) -> ImageFill? { - return image_meta[evid] - } - - func cache_image_meta(evid: String, image_fill: ImageFill) { - self.image_meta[evid] = image_fill - } - init() { self.previews = [:] - self.image_meta = [:] } } diff --git a/damus/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift @@ -10,7 +10,8 @@ import Kingfisher struct ImageContainerView: View { - let url: URL? + let cache: EventCache + let url: MediaUrl @State private var image: UIImage? @State private var showShareSheet = false @@ -26,8 +27,7 @@ struct ImageContainerView: View { } } - var body: some View { - + func Img(url: URL) -> some View { KFAnimatedImage(url) .imageContext(.note, disable_animation: disable_animation) .configure { view in @@ -40,12 +40,23 @@ struct ImageContainerView: View { ShareSheet(activityItems: [url]) } } + + var body: some View { + Group { + switch url { + case .image(let url): + Img(url: url) + case .video(let url): + DamusVideoPlayer(url: url, model: cache.get_video_player_model(url: url), video_size: .constant(nil)) + } + } + } } let test_image_url = URL(string: "https://jb55.com/red-me.jpg")! struct ImageContainerView_Previews: PreviewProvider { static var previews: some View { - ImageContainerView(url: test_image_url, disable_animation: false) + ImageContainerView(cache: test_damus_state().events, url: .image(test_image_url), disable_animation: false) } } diff --git a/damus/Views/Images/ImageView.swift b/damus/Views/Images/ImageView.swift @@ -8,8 +8,8 @@ import SwiftUI struct ImageView: View { - - let urls: [URL?] + let cache: EventCache + let urls: [MediaUrl] @Environment(\.presentationMode) var presentationMode @@ -39,7 +39,7 @@ struct ImageView: View { TabView(selection: $selectedIndex) { ForEach(urls.indices, id: \.self) { index in ZoomableScrollView { - ImageContainerView(url: urls[index], disable_animation: disable_animation) + ImageContainerView(cache: cache, url: urls[index], disable_animation: disable_animation) .aspectRatio(contentMode: .fit) .padding(.top, Theme.safeAreaInsets?.top) .padding(.bottom, Theme.safeAreaInsets?.bottom) @@ -79,6 +79,7 @@ struct ImageView: View { struct ImageView_Previews: PreviewProvider { static var previews: some View { - ImageView(urls: [URL(string: "https://jb55.com/red-me.jpg")], disable_animation: false) + let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) + ImageView(cache: test_damus_state().events, urls: [url], disable_animation: false) } } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -130,11 +130,11 @@ struct NoteContentView: View { } } - if show_images && artifacts.images.count > 0 { - ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images) - } else if !show_images && artifacts.images.count > 0 { + if show_images && artifacts.media.count > 0 { + ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) + } else if !show_images && artifacts.media.count > 0 { ZStack { - ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images) + ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) Blur() .disabled(true) } @@ -261,13 +261,24 @@ struct NoteArtifacts: Equatable { } let content: CompatibleText - let images: [URL] + let urls: [UrlType] let invoices: [Invoice] - let links: [URL] + + var media: [MediaUrl] { + return urls.compactMap { url in url.is_media } + } + + var images: [URL] { + return urls.compactMap { url in url.is_img } + } + + var links: [URL] { + return urls.compactMap { url in url.is_link } + } static func just_content(_ content: String) -> NoteArtifacts { let txt = CompatibleText(attributed: AttributedString(stringLiteral: content)) - return NoteArtifacts(content: txt, images: [], invoices: [], links: []) + return NoteArtifacts(content: txt, urls: [], invoices: []) } } @@ -304,8 +315,7 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) - func render_blocks(blocks: [Block], profiles: Profiles) -> NoteArtifacts { var invoices: [Invoice] = [] - var img_urls: [URL] = [] - var link_urls: [URL] = [] + var urls: [UrlType] = [] let one_note_ref = blocks .filter({ $0.is_note_mention }) @@ -323,12 +333,14 @@ func render_blocks(blocks: [Block], profiles: Profiles) -> NoteArtifacts { return str + mention_str(m, profiles: profiles) case .text(let txt): var trimmed = txt - if let prev = blocks[safe: ind-1], case .url(let u) = prev, is_image_url(u) { + if let prev = blocks[safe: ind-1], + case .url(let u) = prev, + classify_url(u).is_media != nil { trimmed = " " + trim_prefix(trimmed) } if let next = blocks[safe: ind+1] { - if case .url(let u) = next, is_image_url(u) { + if case .url(let u) = next, classify_url(u).is_media != nil { trimmed = trim_suffix(trimmed) } else if case .mention(let m) = next, m.type == .event, one_note_ref { trimmed = trim_suffix(trimmed) @@ -345,25 +357,112 @@ func render_blocks(blocks: [Block], profiles: Profiles) -> NoteArtifacts { invoices.append(invoice) return str case .url(let url): - // Handle Image URLs - if is_image_url(url) { - // Append Image - img_urls.append(url) + let url_type = classify_url(url) + switch url_type { + case .media: + urls.append(url_type) return str - } else { - link_urls.append(url) + case .link(let url): + urls.append(url_type) return str + url_str(url) } } } - return NoteArtifacts(content: txt, images: img_urls, invoices: invoices, links: link_urls) + return NoteArtifacts(content: txt, urls: urls, invoices: invoices) +} + +enum MediaUrl { + case image(URL) + case video(URL) + + var url: URL { + switch self { + case .image(let url): + return url + case .video(let url): + return url + } + } } -func is_image_url(_ url: URL) -> Bool { +enum UrlType { + case media(MediaUrl) + case link(URL) + + var url: URL { + switch self { + case .media(let media_url): + switch media_url { + case .image(let url): + return url + case .video(let url): + return url + } + case .link(let url): + return url + } + } + + var is_video: URL? { + switch self { + case .media(let media_url): + switch media_url { + case .image: + return nil + case .video(let url): + return url + } + case .link: + return nil + } + } + + var is_img: URL? { + switch self { + case .media(let media_url): + switch media_url { + case .image(let url): + return url + case .video: + return url + } + case .link: + return nil + } + } + + var is_link: URL? { + switch self { + case .media: + return nil + case .link(let url): + return url + } + } + + var is_media: MediaUrl? { + switch self { + case .media(let murl): + return murl + case .link: + return nil + } + } +} + +func classify_url(_ url: URL) -> UrlType { let str = url.lastPathComponent.lowercased() - let isUrl = str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") - return isUrl + + if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") { + return .media(.image(url)) + } + + if str.hasSuffix(".mp4") || str.hasSuffix(".mov") { + return .media(.video(url)) + } + + return .link(url) } func lookup_cached_preview_size(previews: PreviewCache, evid: String) -> CGFloat? { diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift @@ -0,0 +1,77 @@ +// +// VideoPlayerView.swift +// damus +// +// Created by William Casarin on 2023-04-05. +// + +import SwiftUI + +struct DamusVideoPlayer: View { + var url: URL + @ObservedObject var model: VideoPlayerModel + @Binding var video_size: CGSize? + + var mute_icon: String { + if model.has_audio == false || model.muted { + return "speaker.slash" + } else { + return "speaker" + } + } + + var mute_icon_color: Color { + switch self.model.has_audio { + case .none: + return .white + case .some(let has_audio): + return has_audio ? .white : .red + } + } + + var MuteIcon: some View { + ZStack { + Circle() + .opacity(0.2) + .frame(width: 32, height: 32) + .foregroundColor(.black) + + Image(systemName: mute_icon) + .padding() + .foregroundColor(mute_icon_color) + } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VideoPlayer(url: url, model: model) + .onAppear{ + model.start() + } + .onDisappear { + model.stop() + } + + if model.has_audio == true { + MuteIcon + .zIndex(11.0) + .onTapGesture { + self.model.muted = !self.model.muted + } + } + } + .onChange(of: model.size) { size in + guard let size else { + return + } + video_size = size + } + } +} +struct DamusVideoPlayer_Previews: PreviewProvider { + @StateObject static var model: VideoPlayerModel = VideoPlayerModel() + + static var previews: some View { + DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, model: model, video_size: .constant(nil)) + } +} diff --git a/damus/Views/Video/VideoPlayer.swift b/damus/Views/Video/VideoPlayer.swift @@ -0,0 +1,347 @@ +// +// VideoPlayer.swift +// damus +// +// Created by William Casarin on 2023-05-25. +// + +import Foundation +// +// VideoPlayer.swift +// VideoPlayer +// +// Created by Gesen on 2019/7/7. +// Copyright © 2019 Gesen. All rights reserved. +// + +import AVFoundation +import GSPlayer +import SwiftUI + +public enum VideoState { + /// From the first load to get the first frame of the video + case loading + + /// Playing now + case playing(totalDuration: Double) + + /// Pause, will be called repeatedly when the buffer progress changes + case paused(playProgress: Double, bufferProgress: Double) + + /// An error occurred and cannot continue playing + case error(NSError) +} + +enum VideoHandler { + case onBufferChanged((Double) -> Void) + case onPlayToEndTime(() -> Void) + case onReplay(() -> Void) + case onStateChanged((VideoState) -> Void) +} + +public class VideoPlayerModel: ObservableObject { + @Published var autoReplay: Bool = true + @Published var muted: Bool = true + @Published var play: Bool = true + @Published var size: CGSize? = nil + @Published var has_audio: Bool? = nil + @Published var contentMode: UIView.ContentMode = .scaleAspectFill + + var time: CMTime = CMTime() + var handlers: [VideoHandler] = [] + + init() { + } + + func stop() { + self.play = false + } + + func start() { + self.play = true + } + + func mute() { + self.muted = true + } + + func unmute() { + self.muted = false + } + + /// Whether the video will be automatically replayed until the end of the video playback. + func autoReplay(_ value: Bool) -> Self { + autoReplay = value + return self + } + + /// Whether the video is muted, only for this instance. + func mute(_ value: Bool) -> Self { + muted = value + return self + } + + /// A string defining how the video is displayed within an AVPlayerLayer bounds rect. + /// scaleAspectFill -> resizeAspectFill, scaleAspectFit -> resizeAspect, other -> resize + func contentMode(_ value: UIView.ContentMode) -> Self { + contentMode = value + return self + } + + /// Trigger a callback when the buffer progress changes, + /// the value is between 0 and 1. + func onBufferChanged(_ handler: @escaping (Double) -> Void) -> Self { + self.handlers.append(.onBufferChanged(handler)) + return self + } + + /// Playing to the end. + func onPlayToEndTime(_ handler: @escaping () -> Void) -> Self { + self.handlers.append(.onPlayToEndTime(handler)) + return self + } + + /// Replay after playing to the end. + func onReplay(_ handler: @escaping () -> Void) -> Self { + self.handlers.append(.onReplay(handler)) + return self + } + + /// Playback status changes, such as from play to pause. + func onStateChanged(_ handler: @escaping (VideoState) -> Void) -> Self { + self.handlers.append(.onStateChanged(handler)) + return self + } +} + +@available(iOS 13, *) +public struct VideoPlayer { + private(set) var url: URL + + @ObservedObject var model: VideoPlayerModel + + /// Init video player instance. + /// - Parameters: + /// - url: http/https URL + /// - play: play/pause + /// - time: current time + public init(url: URL, model: VideoPlayerModel) { + self.url = url + self._model = ObservedObject(wrappedValue: model) + } +} + +@available(iOS 13, *) +public extension VideoPlayer { + + /// Set the preload size, the default value is 1024 * 1024, unit is byte. + static var preloadByteCount: Int { + get { VideoPreloadManager.shared.preloadByteCount } + set { VideoPreloadManager.shared.preloadByteCount = newValue } + } + + /// Set the video urls to be preload queue. + /// Preloading will automatically cache a short segment of the beginning of the video + /// and decide whether to start or pause the preload based on the buffering of the currently playing video. + /// - Parameter urls: URL array + static func preload(urls: [URL]) { + VideoPreloadManager.shared.set(waiting: urls) + } + + /// Set custom http header, such as token. + static func customHTTPHeaderFields(transform: @escaping (URL) -> [String: String]?) { + VideoLoadManager.shared.customHTTPHeaderFields = transform + } + + /// Get the total size of the video cache. + static func calculateCachedSize() -> UInt { + return VideoCacheManager.calculateCachedSize() + } + + /// Clean up all caches. + static func cleanAllCache() { + try? VideoCacheManager.cleanAllCache() + } +} + +@available(iOS 13, *) +public extension VideoPlayer { + + +} + +func get_video_size(player: AVPlayer) -> CGSize? { + // TODO: make this async? + return player.currentImage?.size +} + +func video_has_audio(player: AVPlayer) async -> Bool { + let tracks = try? await player.currentItem?.asset.load(.tracks) + return tracks?.filter({ t in t.mediaType == .audio }).first != nil +} + +@available(iOS 13, *) +extension VideoPlayer: UIViewRepresentable { + + public func makeUIView(context: Context) -> VideoPlayerView { + let uiView = VideoPlayerView() + + uiView.playToEndTime = { + if self.model.autoReplay == false { + self.model.play = false + } + DispatchQueue.main.async { + for handler in model.handlers { + if case .onPlayToEndTime(let cb) = handler { + cb() + } + } + } + } + + uiView.contentMode = self.model.contentMode + + uiView.replay = { + DispatchQueue.main.async { + for handler in model.handlers { + if case .onReplay(let cb) = handler { + cb() + } + } + } + } + + uiView.stateDidChanged = { [unowned uiView] _ in + let state: VideoState = uiView.convertState() + + if case .playing = state { + context.coordinator.startObserver(uiView: uiView) + + if let player = uiView.player { + Task { + let has_audio = await video_has_audio(player: player) + let size = get_video_size(player: player) + Task { @MainActor in + if let size { + self.model.size = size + } + self.model.has_audio = has_audio + } + } + } + + } else { + context.coordinator.stopObserver(uiView: uiView) + } + + DispatchQueue.main.async { + for handler in model.handlers { + if case .onStateChanged(let cb) = handler { + cb(state) + } + } + } + } + + return uiView + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func updateUIView(_ uiView: VideoPlayerView, context: Context) { + if context.coordinator.observingURL != url { + context.coordinator.clean() + context.coordinator.observingURL = url + } + + if model.play { + uiView.play(for: url) + } else { + uiView.pause(reason: .userInteraction) + } + + uiView.isMuted = model.muted + uiView.isAutoReplay = model.autoReplay + + if let observerTime = context.coordinator.observerTime, model.time != observerTime { + uiView.seek(to: model.time, toleranceBefore: model.time, toleranceAfter: model.time, completion: { _ in }) + } + } + + public static func dismantleUIView(_ uiView: VideoPlayerView, coordinator: VideoPlayer.Coordinator) { + uiView.pause(reason: .hidden) + } + + public class Coordinator: NSObject { + var videoPlayer: VideoPlayer + var observingURL: URL? + var observer: Any? + var observerTime: CMTime? + var observerBuffer: Double? + + init(_ videoPlayer: VideoPlayer) { + self.videoPlayer = videoPlayer + } + + func startObserver(uiView: VideoPlayerView) { + guard observer == nil else { return } + + observer = uiView.addPeriodicTimeObserver(forInterval: .init(seconds: 0.25, preferredTimescale: 60)) { [weak self, unowned uiView] time in + guard let `self` = self else { return } + + self.videoPlayer.model.time = time + self.observerTime = time + + self.updateBuffer(uiView: uiView) + } + } + + func stopObserver(uiView: VideoPlayerView) { + guard let observer = observer else { return } + + uiView.removeTimeObserver(observer) + + self.observer = nil + } + + func clean() { + self.observingURL = nil + self.observer = nil + self.observerTime = nil + self.observerBuffer = nil + } + + func updateBuffer(uiView: VideoPlayerView) { + let bufferProgress = uiView.bufferProgress + guard bufferProgress != observerBuffer else { return } + + for handler in videoPlayer.model.handlers { + if case .onBufferChanged(let cb) = handler { + DispatchQueue.main.async { + cb(bufferProgress) + } + } + } + + observerBuffer = bufferProgress + } + } +} + +private extension VideoPlayerView { + + func convertState() -> VideoState { + switch state { + case .none, .loading: + return .loading + case .playing: + return .playing(totalDuration: totalDuration) + case .paused(let p, let b): + return .paused(playProgress: p, bufferProgress: b) + case .error(let error): + return .error(error) + } + } +}