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:
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)
+ }
+ }
+}