From 4d95d36a1e0013c2088ae1b0b31a83a731a1897d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 26 May 2023 10:00:29 -0700 Subject: [PATCH 1/9] Add GSPlayer + VideoPlayer --- damus.xcodeproj/project.pbxproj | 29 ++ .../xcshareddata/swiftpm/Package.resolved | 9 + damus/Views/Video/VideoPlayer.swift | 326 ++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 damus/Views/Video/VideoPlayer.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 412dfedf..350acd82 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -207,6 +207,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 */; }; @@ -651,6 +653,7 @@ 4CC7AAF9297F64AC00430951 /* EventMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMenu.swift; sourceTree = ""; }; 4CCEB7AD29B53D260078AA28 /* SearchingEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingEventView.swift; sourceTree = ""; }; 4CCEB7AF29B5415A0078AA28 /* SearchingProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingProfileView.swift; sourceTree = ""; }; + 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadModel.swift; sourceTree = ""; }; 4CD7641A28A1641400B6928F /* EndBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndBlock.swift; sourceTree = ""; }; 4CDA128929E9D10C0006FA5A /* SignalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalView.swift; sourceTree = ""; }; @@ -771,6 +774,7 @@ buildActionMask = 2147483647; files = ( 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */, + 4CCF9AB22A1FE80C00E03CFB /* GSPlayer in Frameworks */, 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -949,6 +953,14 @@ path = Settings; sourceTree = ""; }; + 4C1A9A2829DDF53B00516EAC /* Video */ = { + isa = PBXGroup; + children = ( + 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */, + ); + path = Video; + sourceTree = ""; + }; 4C30AC7029A5676F00E2BD5A /* Notifications */ = { isa = PBXGroup; children = ( @@ -975,6 +987,7 @@ 4C7D09692A0AEA0400943473 /* CodeScanner */, 4C7D095A2A098C5C00943473 /* Wallet */, 4C8D1A6D29F31E4100ACDF75 /* Buttons */, + 4C1A9A2829DDF53B00516EAC /* Video */, 4C1A9A1B29DDCF8B00516EAC /* Settings */, 4CFF8F6129CC9A80008DB934 /* Images */, 4CCEB7AC29B53D180078AA28 /* Search */, @@ -1501,6 +1514,7 @@ packageProductDependencies = ( 4C649880286E0EE300EAE2B3 /* secp256k1 */, 4C06670328FC7EC500038D2A /* Kingfisher */, + 4CCF9AB12A1FE80C00E03CFB /* GSPlayer */, ); productName = damus; productReference = 4CE6DEE327F7A08100C66700 /* damus.app */; @@ -1605,6 +1619,7 @@ packageReferences = ( 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */, 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */, + 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */, ); productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */; projectDirPath = ""; @@ -1669,6 +1684,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 */, @@ -2441,6 +2457,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 +2478,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 index f4b491d9..e9bbe6de 100644 --- a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "gsplayer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/wxxsw/GSPlayer.git", + "state" : { + "revision" : "aa6dad7943d52f5207f7fcc2ad3e4274583443b8", + "version" : "0.2.26" + } + }, { "identity" : "kingfisher", "kind" : "remoteSourceControl", diff --git a/damus/Views/Video/VideoPlayer.swift b/damus/Views/Video/VideoPlayer.swift new file mode 100644 index 00000000..c2dfabfc --- /dev/null +++ b/damus/Views/Video/VideoPlayer.swift @@ -0,0 +1,326 @@ +// +// 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 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 { + + +} + +@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) + } else { + context.coordinator.stopObserver(uiView: uiView) + } + + if model.size == nil, let size = uiView.player?.currentImage?.size { + model.size = size + } + + 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) + } + + print("intrinsic video size \(uiView.intrinsicContentSize)") + 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) + } + } +} From 85cd1bea1966275daf13b209ee1df27bc20f0061 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 26 May 2023 10:12:07 -0700 Subject: [PATCH 2/9] urls: combine url classification --- damus/Views/NoteContentView.swift | 133 ++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index 837516ab..a9e5dd1e 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -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) } -func is_image_url(_ url: URL) -> Bool { +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 + } + } +} + +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? { From 6214ab8d8fe8b4056f547d9e35cb2ff5bd05e44b Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 26 May 2023 10:13:58 -0700 Subject: [PATCH 3/9] video: add DamusVideoPlayer view --- damus.xcodeproj/project.pbxproj | 4 ++ damus/Views/Video/DamusVideoPlayer.swift | 69 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 damus/Views/Video/DamusVideoPlayer.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 350acd82..50b3342a 100644 --- 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 */; }; @@ -461,6 +462,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 = ""; }; 4C216F31286E388800040376 /* DMChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMChatView.swift; sourceTree = ""; }; 4C216F33286F5ACD00040376 /* DMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMView.swift; sourceTree = ""; }; 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDismissKeyboard.swift; sourceTree = ""; }; @@ -956,6 +958,7 @@ 4C1A9A2829DDF53B00516EAC /* Video */ = { isa = PBXGroup; children = ( + 4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */, 4CCF9AAE2A1FDBDB00E03CFB /* VideoPlayer.swift */, ); path = Video; @@ -1830,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 */, diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift new file mode 100644 index 00000000..7fab1943 --- /dev/null +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -0,0 +1,69 @@ +// +// VideoPlayerView.swift +// damus +// +// Created by William Casarin on 2023-04-05. +// + +import SwiftUI + +struct DamusVideoPlayer: View { + var url: URL + @StateObject var model: VideoPlayerModel = VideoPlayerModel() + @Binding var video_size: CGSize? + + var mute_icon: String { + if model.muted { + return "speaker.slash" + } else { + return "speaker" + } + } + + var MuteIcon: some View { + ZStack { + Circle() + .opacity(0.2) + .frame(width: 32, height: 32) + .foregroundColor(.black) + + Image(systemName: mute_icon) + .onTapGesture { + model.muted = !model.muted + } + .padding() + .foregroundColor(.white) + } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VideoPlayer(url: url, model: model) + .onAppear{ + model.start() + } + .onDisappear { + model.stop() + } + + MuteIcon + } + .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() + @State static var video_size: CGSize? = nil + + static var previews: some View { + DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, model: model, video_size: $video_size) + } +} From 80fac1903e3a02833fb0b3c910a2fe0b40273774 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 26 May 2023 10:16:14 -0700 Subject: [PATCH 4/9] carousel: switch to media carousel and include video --- damus/Components/ImageCarousel.swift | 128 +++++++++++++------- damus/Views/Images/ImageContainerView.swift | 19 ++- damus/Views/Images/ImageView.swift | 6 +- damus/Views/NoteContentView.swift | 8 +- 4 files changed, 103 insertions(+), 58 deletions(-) diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index ee146565..afad2665 100644 --- 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,8 +69,9 @@ 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)) @@ -112,47 +113,79 @@ struct ImageCarousel: View { } } - var Images: some View { - TabView(selection: $selectedIndex) { + 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, video_size: $video_size) + .onTapGesture { + print("video tap") + } + .onChange(of: video_size) { size in + guard image_fill == nil, let size else { + return + } + let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight) + image_fill = fill + state.previews.cache_image_meta(evid: evid, image_fill: fill) + + if index == 0 { + firstImageHeight = fill.height + } + } + } + } + } + + 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.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) + .tabItem { + Text(url.absoluteString) + } + .id(url.absoluteString) + .padding(0) + + } + + var Medias: some View { + TabView { 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) } } } @@ -165,14 +198,14 @@ struct ImageCarousel: View { open_sheet = true } .onChange(of: selectedIndex) { value in - selectedIndex = value - } + selectedIndex = value + } .tabViewStyle(PageTabViewStyle()) } var body: some View { VStack { - Images + Medias // 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: 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/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift index 9c35471a..5ebd55de 100644 --- a/damus/Views/Images/ImageContainerView.swift +++ b/damus/Views/Images/ImageContainerView.swift @@ -10,10 +10,11 @@ import Kingfisher struct ImageContainerView: View { - let url: URL? + let url: MediaUrl @State private var image: UIImage? @State private var showShareSheet = false + @State private var video_size: CGSize? = nil let disable_animation: Bool @@ -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, video_size: $video_size) + } + } + } } 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(url: .image(test_image_url), disable_animation: false) } } diff --git a/damus/Views/Images/ImageView.swift b/damus/Views/Images/ImageView.swift index c06d0c32..0ca00d1d 100644 --- a/damus/Views/Images/ImageView.swift +++ b/damus/Views/Images/ImageView.swift @@ -8,8 +8,7 @@ import SwiftUI struct ImageView: View { - - let urls: [URL?] + let urls: [MediaUrl] @Environment(\.presentationMode) var presentationMode @@ -79,6 +78,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(urls: [url], disable_animation: false) } } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index a9e5dd1e..cde2c789 100644 --- 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) } From a1753b2c24ec0e95b3f350a94b31d22ba8669063 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 30 May 2023 10:30:48 -0700 Subject: [PATCH 5/9] video-player: add tap gesture to prevent nav --- damus/Components/ImageCarousel.swift | 9 ++------- damus/Views/Video/DamusVideoPlayer.swift | 3 --- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index afad2665..50b974d8 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -123,9 +123,6 @@ struct ImageCarousel: View { } case .video(let url): DamusVideoPlayer(url: url, video_size: $video_size) - .onTapGesture { - print("video tap") - } .onChange(of: video_size) { size in guard image_fill == nil, let size else { return @@ -182,7 +179,7 @@ struct ImageCarousel: View { } var Medias: some View { - TabView { + TabView(selection: $selectedIndex) { ForEach(urls.indices, id: \.self) { index in GeometryReader { geo in Media(geo: geo, url: urls[index], index: index) @@ -194,9 +191,6 @@ struct ImageCarousel: View { ImageView(urls: urls, disable_animation: state.settings.disable_animation) } .frame(height: height) - .onTapGesture { - open_sheet = true - } .onChange(of: selectedIndex) { value in selectedIndex = value } @@ -206,6 +200,7 @@ struct ImageCarousel: View { var body: some View { VStack { Medias + .onTapGesture { } // This is our custom carousel image indicator CarouselDotsView(urls: urls, selectedIndex: $selectedIndex) diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index 7fab1943..f2f640ba 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -28,9 +28,6 @@ struct DamusVideoPlayer: View { .foregroundColor(.black) Image(systemName: mute_icon) - .onTapGesture { - model.muted = !model.muted - } .padding() .foregroundColor(.white) } From 88b04fde096f6bb37d42f839607a3dde74755d0d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 30 May 2023 17:50:24 -0700 Subject: [PATCH 6/9] xcode: remove .git ext from package --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e9bbe6de..9805f7cb 100644 --- a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,7 +3,7 @@ { "identity" : "gsplayer", "kind" : "remoteSourceControl", - "location" : "https://github.com/wxxsw/GSPlayer.git", + "location" : "https://github.com/wxxsw/GSPlayer", "state" : { "revision" : "aa6dad7943d52f5207f7fcc2ad3e4274583443b8", "version" : "0.2.26" From bb091d072f3070fc874a58c694e59a854d82fa91 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 30 May 2023 17:53:44 -0700 Subject: [PATCH 7/9] cache: move event-specific media metadata to EventCache --- damus/Components/ImageCarousel.swift | 34 ++++++++++++--------- damus/Util/EventCache.swift | 30 ++++++++++++++++++ damus/Util/PreviewCache.swift | 10 ------ damus/Views/Images/ImageContainerView.swift | 6 ++-- damus/Views/Images/ImageView.swift | 5 +-- damus/Views/Video/DamusVideoPlayer.swift | 5 ++- 6 files changed, 57 insertions(+), 33 deletions(-) diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index 50b974d8..446159c8 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -74,7 +74,8 @@ struct ImageCarousel: View { 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 @@ -103,16 +104,17 @@ 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 } } } + 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 { @@ -122,18 +124,20 @@ struct ImageCarousel: View { open_sheet = true } case .video(let url): - DamusVideoPlayer(url: url, video_size: $video_size) + DamusVideoPlayer(url: url, model: video_model(url), video_size: $video_size) .onChange(of: video_size) { size in - guard image_fill == nil, let size else { - return - } - let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight) - image_fill = fill - state.previews.cache_image_meta(evid: evid, image_fill: fill) + guard let size else { return } - if index == 0 { + 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 } } } @@ -150,7 +154,7 @@ struct ImageCarousel: View { view.framePreloadCount = 3 } .imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in - state.previews.cache_image_meta(evid: evid, image_fill: fill) + 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 @@ -188,7 +192,7 @@ struct ImageCarousel: View { } .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) .onChange(of: selectedIndex) { value in diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index ebed6880..58bde49d 100644 --- 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 index 92d55dc3..b54b8bcd 100644 --- 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 index 5ebd55de..cd4499a7 100644 --- a/damus/Views/Images/ImageContainerView.swift +++ b/damus/Views/Images/ImageContainerView.swift @@ -10,11 +10,11 @@ import Kingfisher struct ImageContainerView: View { + let cache: EventCache let url: MediaUrl @State private var image: UIImage? @State private var showShareSheet = false - @State private var video_size: CGSize? = nil let disable_animation: Bool @@ -47,7 +47,7 @@ struct ImageContainerView: View { case .image(let url): Img(url: url) case .video(let url): - DamusVideoPlayer(url: url, video_size: $video_size) + DamusVideoPlayer(url: url, model: cache.get_video_player_model(url: url), video_size: .constant(nil)) } } } @@ -57,6 +57,6 @@ let test_image_url = URL(string: "https://jb55.com/red-me.jpg")! struct ImageContainerView_Previews: PreviewProvider { static var previews: some View { - ImageContainerView(url: .image(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 index 0ca00d1d..a51133c7 100644 --- a/damus/Views/Images/ImageView.swift +++ b/damus/Views/Images/ImageView.swift @@ -8,6 +8,7 @@ import SwiftUI struct ImageView: View { + let cache: EventCache let urls: [MediaUrl] @Environment(\.presentationMode) var presentationMode @@ -38,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 +80,6 @@ struct ImageView: View { struct ImageView_Previews: PreviewProvider { static var previews: some View { let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) - ImageView(urls: [url], disable_animation: false) + ImageView(cache: test_damus_state().events, urls: [url], disable_animation: false) } } diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index f2f640ba..f50abe17 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -9,7 +9,7 @@ import SwiftUI struct DamusVideoPlayer: View { var url: URL - @StateObject var model: VideoPlayerModel = VideoPlayerModel() + @ObservedObject var model: VideoPlayerModel @Binding var video_size: CGSize? var mute_icon: String { @@ -58,9 +58,8 @@ struct DamusVideoPlayer: View { } struct DamusVideoPlayer_Previews: PreviewProvider { @StateObject static var model: VideoPlayerModel = VideoPlayerModel() - @State static var video_size: CGSize? = nil static var previews: some View { - DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, model: model, video_size: $video_size) + DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, model: model, video_size: .constant(nil)) } } From 9e359650bfd98f61c21f252933bab36fe76c103d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 30 May 2023 17:57:32 -0700 Subject: [PATCH 8/9] carousel: fix image positioning --- damus/Components/ImageCarousel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift index 446159c8..4c411bb1 100644 --- a/damus/Components/ImageCarousel.swift +++ b/damus/Components/ImageCarousel.swift @@ -174,6 +174,7 @@ struct ImageCarousel: View { 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) } From 554c091d5723cdb92b937e0a90a638bd558eb6e3 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 30 May 2023 17:58:16 -0700 Subject: [PATCH 9/9] video-player: hide mute button when we have no audio --- damus/Views/Video/DamusVideoPlayer.swift | 24 +++++++++++++----- damus/Views/Video/VideoPlayer.swift | 31 ++++++++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift index f50abe17..d168a2f7 100644 --- a/damus/Views/Video/DamusVideoPlayer.swift +++ b/damus/Views/Video/DamusVideoPlayer.swift @@ -13,13 +13,22 @@ struct DamusVideoPlayer: View { @Binding var video_size: CGSize? var mute_icon: String { - if model.muted { + 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() @@ -29,7 +38,7 @@ struct DamusVideoPlayer: View { Image(systemName: mute_icon) .padding() - .foregroundColor(.white) + .foregroundColor(mute_icon_color) } } @@ -43,10 +52,13 @@ struct DamusVideoPlayer: View { model.stop() } - MuteIcon - } - .onTapGesture { - self.model.muted = !self.model.muted + 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 { diff --git a/damus/Views/Video/VideoPlayer.swift b/damus/Views/Video/VideoPlayer.swift index c2dfabfc..3be7086b 100644 --- a/damus/Views/Video/VideoPlayer.swift +++ b/damus/Views/Video/VideoPlayer.swift @@ -44,7 +44,9 @@ public class VideoPlayerModel: ObservableObject { @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] = [] @@ -168,6 +170,16 @@ 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 { @@ -204,14 +216,24 @@ extension VideoPlayer: UIViewRepresentable { 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) } - if model.size == nil, let size = uiView.player?.currentImage?.size { - model.size = size - } - DispatchQueue.main.async { for handler in model.handlers { if case .onStateChanged(let cb) = handler { @@ -240,7 +262,6 @@ extension VideoPlayer: UIViewRepresentable { uiView.pause(reason: .userInteraction) } - print("intrinsic video size \(uiView.intrinsicContentSize)") uiView.isMuted = model.muted uiView.isAutoReplay = model.autoReplay