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