1
0
mirror of git://jb55.com/damus synced 2024-10-06 03:33:22 +00:00

Add Full-Bleed Video Player

Changelog-Added: Add new full-bleed video player
This commit is contained in:
William Casarin 2023-05-30 18:02:19 -07:00
commit a6fb175b98
10 changed files with 725 additions and 94 deletions

View File

@ -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 */

View File

@ -1,5 +1,14 @@
{
"pins" : [
{
"identity" : "gsplayer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wxxsw/GSPlayer",
"state" : {
"revision" : "aa6dad7943d52f5207f7fcc2ad3e4274583443b8",
"version" : "0.2.26"
}
},
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",

View File

@ -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])
}
}

View File

@ -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 = [:]
}

View File

@ -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 = [:]
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
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? {

View File

@ -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))
}
}

View File

@ -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)
}
}
}