Compare commits

...

6 Commits

Author SHA1 Message Date
William Casarin 247f313b54 Merge branch 'video-controls' 2024-03-20 10:19:41 +00:00
Daniel D’Aquino 79fef51f68 Improve Video visibility tracking and automatic play/pause
The DamusAVPlayerView (along with other classes) play or pause based on
their Y position relative to the user's viewport. This is ideal for a
vertical feed of notes.

However, this does not work well on a horizontal carousel, such as when
viewing videos on the full-screen carousel and swiping left/right.

This commit adds a new tracking method based on onAppear/onDisappear
triggers from a lazy stack (which only loads when it is visible), and
applied it to videos shown on a full screen carousel, so that videos
pause when we swipe away from the video.

Incidentally, this also fixes an issue I was seeing where a full screen
video would disappear as soon as I rotated the phone to landscape mode.

Testing
--------

Device: iPhone 13 Mini
iOS: 17.3.1
Damus: This version
Coverage:
1. Scroll down a feed full of videos and make sure videos still autoplay when passing through them. PASS
2. Check videos on the feed are muted by default. PASS
3. Check mute button on the video still works. PASS
4. Check clicking on the video brings it to a full-screen carousel view. PASS
5. Check that videos play unmuted by default on full-screen carousel view. PASS
6. Check that all playback controls work on the full-screen carousel view. PASS
7. Check that clicking outside the video shows/hides the carousel overlays. PASS
8. Check that a summary of the note shows up. PASS
9. Check that clicking on that note takes the user to the thread view. PASS
10. Check that changing phone orientation between portrait and landscape on both full-screen carousel AND full-screen video modes will work as expected. PASS
11. Check close button on full-screen carousel works. PASS
12. Check that swiping the video away exits full-screen carousel. PASS
13. Check that full-screen carousel works with images. PASS
14. Check that a carousel with multiple images/videos can be swiped left and right. PASS
15. Check that swiping away from a video on a full-screen carousel will pause it. PASS
16. Check that clicking on an unmuted video on the feed won't cause double-audio issues. PASS
17. Check that full-screen carousel view looks good on both dark and light modes. PASS

Closes: https://github.com/damus-io/damus/issues/1530
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-6-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino 181d894df0 Improve SwiftUI previews around full-screen carousel
This is a minor SwiftUI preview improvement

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-5-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino 250efd9755 Add event text to full-screen Carousel view
The full screen carousel looks quite empty. When viewing videos, it
looks like the video is being played full screen, when it is really not.

Alas, SwiftUI/UIKit does not provide an API for programmatically
bringing a video player full screen. The closest we can do is show the
system native playback controls.

This can cause confusion to users, and is not the best UX. To get around
these limitations and improve UX, event information and content is added to the full
screen carousel overlay, so that:

- Users can see a piece of the post while they are browsing images and videos
- Users can more clearly tell when the video is being displayed on the full screen carousel or on full screen
- Users have a way to directly go to the thread view within the full screen carousel

Changelog-Added: Add event content preview to the full screen carousel
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-4-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino 671b0b67ce Add playback controls to videos
This commit includes several UX changes to give users better control
over video playback. It also, by design, work arounds a SwiftUI quirk*

Here are the changes to the UX:

1. Videos on the feed only have a mute/unmute button
2. When the user clicks on the video, they are taken to a full screen carousel view (similar to when you click on an image)
3. The full-screen carousel view shows all video playback controls (through a specific SwiftUI hack)
4. If the carousel has multiple videos/images, the user can swipe between them normally as expected

Other UI changes that were made:

- The full screen carousel now uses dark mode (black background, white close button)

* The SwiftUI quirk is that when video views are placed within a TabView with ".page" tab view style, the tabview consumes most of the user gestures, making the video playback controls unusable.

Changelog-Changed: Improve UX around video playback
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-3-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino 98eddf1337 Small tweak to resolve build error
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-2-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
13 changed files with 394 additions and 134 deletions

View File

@ -375,7 +375,7 @@
4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFD502E2A2DA45800A229DB /* MediaView.swift */; };
4CFF8F5929C9FD1E008DB934 /* DamusPurpleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */; };
4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */; };
4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */; };
4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */; };
4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */; };
4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */; };
4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; };
@ -386,7 +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 /* AVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */; };
50A16FFB2AA6C06600DFEC1F /* DamusAVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */; };
50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */; };
50A16FFF2AA76A0900DFEC1F /* VideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* VideoController.swift */; };
50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; };
@ -431,8 +431,6 @@
B59CAD4D2B688D1000677E8B /* MutelistManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B533694D2B66D791008A805E /* MutelistManager.swift */; };
B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */; };
B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B4D1422B37D47600844320 /* NdbExtensions.swift */; };
BA0F0A6F2B36207E001641B2 /* CameraMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA0F0A6E2B36207E001641B2 /* CameraMediaView.swift */; };
BA10192F2B449556009C57DA /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA10192E2B449556009C57DA /* CameraPreview.swift */; };
B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; };
B5C60C212B530D5600C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; };
B5C60C232B532A8700C5ECA7 /* DamusDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */; };
@ -454,6 +452,7 @@
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; };
D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; };
D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; };
D71AC4CC2BA8E3480076268E /* VisibilityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */; };
D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; };
D72341192B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; };
D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; };
@ -1296,7 +1295,7 @@
4CFD502E2A2DA45800A229DB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; };
4CFF8F5829C9FD1E008DB934 /* DamusPurpleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleView.swift; sourceTree = "<group>"; };
4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContextMenuModifier.swift; sourceTree = "<group>"; };
4CFF8F6629CC9E3A008DB934 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCarouselView.swift; sourceTree = "<group>"; };
4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainerView.swift; sourceTree = "<group>"; };
4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedEvent.swift; sourceTree = "<group>"; };
4CFF8F6C29CD022E008DB934 /* WideEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideEventView.swift; sourceTree = "<group>"; };
@ -1307,7 +1306,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 /* AVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerView.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>"; };
50A16FFE2AA76A0900DFEC1F /* VideoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoController.swift; sourceTree = "<group>"; };
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; };
@ -1351,8 +1350,6 @@
B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = "<group>"; };
B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItemTests.swift; sourceTree = "<group>"; usesTabs = 0; };
B5B4D1422B37D47600844320 /* NdbExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbExtensions.swift; sourceTree = "<group>"; usesTabs = 0; };
BA0F0A6E2B36207E001641B2 /* CameraMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraMediaView.swift; sourceTree = "<group>"; };
BA10192E2B449556009C57DA /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; };
B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItem.swift; sourceTree = "<group>"; usesTabs = 0; };
B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusDuration.swift; sourceTree = "<group>"; usesTabs = 0; };
BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = "<group>"; };
@ -1373,6 +1370,7 @@
D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; };
D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; };
D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; };
D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = "<group>"; };
D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; };
D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleEnvironment.swift; sourceTree = "<group>"; };
D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = "<group>"; };
@ -1728,7 +1726,7 @@
4C1A9A2929DDF54400516EAC /* DamusVideoPlayer.swift */,
50A16FFC2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift */,
50A16FFE2AA76A0900DFEC1F /* VideoController.swift */,
50A16FFA2AA6C06600DFEC1F /* AVPlayerView.swift */,
50A16FFA2AA6C06600DFEC1F /* DamusAVPlayerView.swift */,
);
path = Video;
sourceTree = "<group>";
@ -1977,6 +1975,7 @@
4C75EFA227FA576C0006080F /* Views */ = {
isa = PBXGroup;
children = (
D71AC4CA2BA8E3320076268E /* Extensions */,
BA3759952ABCCF360018D73B /* Camera */,
F71694E82A66221E001F4053 /* Onboarding */,
4C190F232A547D1700027FD5 /* NostrScript */,
@ -2653,7 +2652,7 @@
isa = PBXGroup;
children = (
4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */,
4CFF8F6629CC9E3A008DB934 /* ImageView.swift */,
4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */,
6439E013296790CF0020672B /* ProfilePicImageView.swift */,
4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */,
4CFD502E2A2DA45800A229DB /* MediaView.swift */,
@ -2704,6 +2703,14 @@
path = Detail;
sourceTree = "<group>";
};
D71AC4CA2BA8E3320076268E /* Extensions */ = {
isa = PBXGroup;
children = (
D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
D72A2D032AD9C165002AFF62 /* Mocking */ = {
isa = PBXGroup;
children = (
@ -3171,7 +3178,7 @@
4C363A9A28283854006E126D /* Reply.swift in Sources */,
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */,
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */,
4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */,
4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */,
4CA927632A290EB10098A105 /* EventTop.swift in Sources */,
4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */,
4CB8838B296F6E1E00DC99E7 /* NIP05Badge.swift in Sources */,
@ -3203,6 +3210,7 @@
F75BA12D29A1855400E10810 /* BookmarksManager.swift in Sources */,
4CC14FEF2A73FCCB007AEB17 /* IdType.swift in Sources */,
4C3EA67F28FFC01D00C48A62 /* InvoiceView.swift in Sources */,
D71AC4CC2BA8E3480076268E /* VisibilityTracker.swift in Sources */,
4CE8794829941DA700F758CC /* RelayFilters.swift in Sources */,
4CEE2B02280B39E800AB5EEF /* EventActionBar.swift in Sources */,
4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */,
@ -3409,7 +3417,7 @@
4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */,
4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */,
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */,
50A16FFB2AA6C06600DFEC1F /* AVPlayerView.swift in Sources */,
50A16FFB2AA6C06600DFEC1F /* DamusAVPlayerView.swift in Sources */,
4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */,
4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */,
7527271E2A93FF0100214108 /* Block.swift in Sources */,

View File

@ -77,13 +77,14 @@ class CarouselModel: ObservableObject {
// MARK: - Image Carousel
@MainActor
struct ImageCarousel: View {
struct ImageCarousel<Content: View>: View {
var urls: [MediaUrl]
let evid: NoteId
let state: DamusState
@ObservedObject var model: CarouselModel
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
self.urls = urls
@ -91,6 +92,16 @@ struct ImageCarousel: View {
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self.content = nil
}
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
self.urls = urls
self.evid = evid
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self.content = content
}
var filling: Bool {
@ -132,7 +143,7 @@ struct ImageCarousel: View {
model.open_sheet = true
}
case .video(let url):
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video)
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
.onChange(of: model.video_size) { size in
guard let size else { return }
@ -201,7 +212,16 @@ struct ImageCarousel: View {
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.fullScreenCover(isPresented: $model.open_sheet) {
ImageView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
if let content {
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
content({ // Dismiss closure
model.open_sheet = false
})
}
}
else {
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
}
}
.frame(height: height)
.onChange(of: model.selectedIndex) { value in
@ -296,7 +316,9 @@ public struct ImageFill {
struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View {
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [url, url])
let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!)
ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
.environmentObject(OrientationTracker())
}
}

View File

@ -9,7 +9,12 @@ import SwiftUI
struct TruncatedText: View {
let text: CompatibleText
let maxChars: Int = 280
let maxChars: Int
init(text: CompatibleText, maxChars: Int = 280) {
self.text = text
self.maxChars = maxChars
}
var body: some View {
let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars)

View File

@ -19,8 +19,11 @@ struct EventViewOptions: OptionSet {
static let nested = EventViewOptions(rawValue: 1 << 7)
static let top_zap = EventViewOptions(rawValue: 1 << 8)
static let no_mentions = EventViewOptions(rawValue: 1 << 9)
static let no_media = EventViewOptions(rawValue: 1 << 10)
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short]
}
struct TextEvent: View {

View File

@ -0,0 +1,36 @@
//
// VisibilityTracker.swift
// damus
//
// Created by Daniel DAquino on 2024-03-18.
//
// Based on code examples shown in this article: https://medium.com/@jackvanderpump/how-to-detect-is-an-element-is-visible-in-swiftui-9ff58ca72339
import Foundation
import SwiftUI
extension View {
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View {
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge))
}
}
struct VisibilityTracker: ViewModifier {
let visibility_change_notifier: (Bool) -> Void
let edge: Alignment
func body(content: Content) -> some View {
content
.overlay(
LazyVStack {
Color.clear
.onAppear {
visibility_change_notifier(true)
}
.onDisappear {
visibility_change_notifier(false)
}
},
alignment: edge)
}
}

View File

@ -0,0 +1,171 @@
//
// FullScreenCarouselView.swift
// damus
//
// Created by William Casarin on 2023-03-23.
//
import SwiftUI
struct FullScreenCarouselView<Content: View>: View {
let video_controller: VideoController
let urls: [MediaUrl]
@Environment(\.presentationMode) var presentationMode
@State var showMenu = true
let settings: UserSettingsStore
@Binding var selectedIndex: Int
let content: (() -> Content)?
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
self.video_controller = video_controller
self.urls = urls
self._showMenu = State(initialValue: showMenu)
self.settings = settings
_selectedIndex = selectedIndex
self.content = content
}
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
self.video_controller = video_controller
self.urls = urls
self._showMenu = State(initialValue: showMenu)
self.settings = settings
_selectedIndex = selectedIndex
self.content = nil
}
var tabViewIndicator: some View {
HStack(spacing: 10) {
ForEach(urls.indices, id: \.self) { index in
Capsule()
.fill(index == selectedIndex ? Color.white : Color.damusMediumGrey)
.frame(width: 7, height: 7)
.onTapGesture {
selectedIndex = index
}
}
}
.padding()
.clipShape(Capsule())
}
var background: some ShapeStyle {
if case .video = urls[safe: selectedIndex] {
return AnyShapeStyle(Color.black)
}
else {
return AnyShapeStyle(.regularMaterial)
}
}
var background_color: UIColor {
return .black
}
var body: some View {
ZStack {
Color(self.background_color)
.ignoresSafeArea()
TabView(selection: $selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
VStack {
if case .video = urls[safe: index] {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
.clipped() // SwiftUI hack from https://stackoverflow.com/a/74401288 to make playback controls show up within the TabView
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
}
else {
ZoomableScrollView {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
}
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
}
}.tag(index)
}
}
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.gesture(TapGesture(count: 2).onEnded {
// Prevents menu from hiding on double tap
})
.gesture(TapGesture(count: 1).onEnded {
showMenu.toggle()
})
.overlay(
GeometryReader { geo in
VStack {
if showMenu {
NavDismissBarView(showBackgroundCircle: false)
.foregroundColor(.white)
Spacer()
if (urls.count > 1) {
tabViewIndicator
}
self.content?()
}
}
.animation(.easeInOut, value: showMenu)
.padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0)
}
)
}
}
}
fileprivate struct FullScreenCarouselPreviewView<Content: View>: View {
@State var selectedIndex: Int = 0
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!)
let custom_content: (() -> Content)?
init(content: (() -> Content)? = nil) {
self.custom_content = content
}
var body: some View {
FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
self.custom_content?()
}
.environmentObject(OrientationTracker())
}
}
struct FullScreenCarouselView_Previews: PreviewProvider {
static var previews: some View {
Group {
FullScreenCarouselPreviewView<AnyView>()
.previewDisplayName("No custom content on overlay")
FullScreenCarouselPreviewView(content: {
HStack {
Spacer()
Text("Some content")
.padding()
.foregroundColor(.white)
Spacer()
}.background(.ultraThinMaterial)
})
.previewDisplayName("Custom content on overlay")
}
}
}

View File

@ -43,19 +43,26 @@ struct ImageContainerView: View {
var body: some View {
Group {
switch url {
case .image(let url):
Img(url: url)
case .video(let url):
DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller)
case .image(let url):
Img(url: url)
case .video(let url):
DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full, visibility_tracking_method: .generic)
}
}
}
}
let test_image_url = URL(string: "https://jb55.com/red-me.jpg")!
fileprivate let test_video_url = URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!
struct ImageContainerView_Previews: PreviewProvider {
static var previews: some View {
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
Group {
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
.previewDisplayName("Image")
ImageContainerView(video_controller: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings)
.previewDisplayName("Video")
}
.environmentObject(OrientationTracker())
}
}

View File

@ -1,90 +0,0 @@
//
// ImageView.swift
// damus
//
// Created by William Casarin on 2023-03-23.
//
import SwiftUI
struct ImageView: View {
let video_controller: VideoController
let urls: [MediaUrl]
@Environment(\.presentationMode) var presentationMode
@State var showMenu = true
let settings: UserSettingsStore
@Binding var selectedIndex: Int
var tabViewIndicator: some View {
HStack(spacing: 10) {
ForEach(urls.indices, id: \.self) { index in
Capsule()
.fill(index == selectedIndex ? Color(UIColor.label) : Color.secondary)
.frame(width: 7, height: 7)
.onTapGesture {
selectedIndex = index
}
}
}
.padding()
.background(.regularMaterial)
.clipShape(Capsule())
}
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
TabView(selection: $selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
ZoomableScrollView {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
}
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
.tag(index)
}
}
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.gesture(TapGesture(count: 2).onEnded {
// Prevents menu from hiding on double tap
})
.gesture(TapGesture(count: 1).onEnded {
showMenu.toggle()
})
.overlay(
GeometryReader { geo in
VStack {
if showMenu {
NavDismissBarView()
Spacer()
if (urls.count > 1) {
tabViewIndicator
}
}
}
.animation(.easeInOut, value: showMenu)
.padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0)
}
)
}
}
}
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
ImageView(video_controller: test_damus_state.video, urls: [url], settings: test_damus_state.settings, selectedIndex: Binding.constant(0))
}
}

View File

@ -42,16 +42,27 @@ struct ProfileImageContainerView: View {
struct NavDismissBarView: View {
@Environment(\.presentationMode) var presentationMode
let showBackgroundCircle: Bool
init(showBackgroundCircle: Bool = true) {
self.showBackgroundCircle = showBackgroundCircle
}
var body: some View {
HStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Image("close")
.frame(width: 33, height: 33)
.background(.regularMaterial)
.clipShape(Circle())
if showBackgroundCircle {
Image("close")
.frame(width: 33, height: 33)
.background(.regularMaterial)
.clipShape(Circle())
}
else {
Image("close")
.frame(width: 33, height: 33)
}
})
Spacer()

View File

@ -57,6 +57,10 @@ struct NoteContentView: View {
return options.contains(.truncate_content)
}
var truncate_very_short: Bool {
return options.contains(.truncate_content_very_short)
}
var with_padding: Bool {
return options.contains(.wide)
}
@ -73,7 +77,11 @@ struct NoteContentView: View {
func truncatedText(content: CompatibleText) -> some View {
Group {
if truncate {
if truncate_very_short {
TruncatedText(text: content, maxChars: 140)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
else if truncate {
TruncatedText(text: content)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
} else {
@ -107,6 +115,19 @@ struct NoteContentView: View {
}
}
func fullscreen_preview(dismiss: @escaping () -> Void) -> some View {
VStack {
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
.padding(.top)
}
.background(.thinMaterial)
.preferredColorScheme(.dark)
.onTapGesture(perform: {
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
dismiss()
})
}
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
VStack(alignment: .leading) {
if size == .selected {
@ -135,13 +156,19 @@ struct NoteContentView: View {
}
if artifacts.media.count > 0 {
if !damus_state.settings.media_previews && !load_media {
if (self.options.contains(.no_media)) {
EmptyView()
} else if !damus_state.settings.media_previews && !load_media {
loadMediaButton(artifacts: artifacts)
} else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
} else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
Blur()
.onTapGesture {
blur_images = false

View File

@ -9,12 +9,15 @@ import Foundation
import AVKit
import SwiftUI
struct AVPlayerView: UIViewControllerRepresentable {
struct DamusAVPlayerView: UIViewControllerRepresentable {
let player: AVPlayer
var controller: AVPlayerViewController
let show_playback_controls: Bool
func makeUIViewController(context: Context) -> AVPlayerViewController {
AVPlayerViewController()
self.controller.showsPlaybackControls = show_playback_controls
return self.controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {

View File

@ -20,10 +20,22 @@ struct DamusVideoPlayer: View {
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
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController) {
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, style: Style, visibility_tracking_method: VisibilityTrackingMethod = .y_scroll) {
self.url = url
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller))
let mute: Bool?
if case .full = style {
mute = false
}
else {
mute = nil
}
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute))
self.visibility_tracking_method = visibility_tracking_method
self.style = style
}
var body: some View {
@ -31,7 +43,15 @@ struct DamusVideoPlayer: View {
let localFrame = geo.frame(in: .local)
let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
ZStack {
AVPlayerView(player: model.player)
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?()
}))
}
if model.is_loading {
ProgressView()
@ -40,22 +60,35 @@ struct DamusVideoPlayer: View {
.scaleEffect(CGSize(width: 1.5, height: 1.5))
}
if model.has_audio {
mute_button
if case .preview = self.style {
if model.has_audio {
mute_button
}
}
if model.is_live {
live_indicator
}
}
.onChange(of: centerY) { _ in
update_is_visible(centerY: centerY)
if case .y_scroll = visibility_tracking_method {
update_is_visible(centerY: centerY)
}
}
.on_visibility_change(perform: { new_visibility in
if case .generic = visibility_tracking_method {
model.set_view_is_visible(new_visibility)
}
})
.onAppear {
update_is_visible(centerY: centerY)
if case .y_scroll = visibility_tracking_method {
update_is_visible(centerY: centerY)
}
}
}
.onDisappear {
model.view_did_disappear()
if case .y_scroll = visibility_tracking_method {
model.view_did_disappear()
}
}
}
@ -115,9 +148,31 @@ struct DamusVideoPlayer: View {
Spacer()
}
}
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)?)
}
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
}
}
struct DamusVideoPlayer_Previews: PreviewProvider {
static var previews: some View {
DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController())
Group {
DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .full)
.environmentObject(OrientationTracker())
.previewDisplayName("Full video player")
DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .preview(on_tap: nil))
.environmentObject(OrientationTracker())
.previewDisplayName("Preview video player")
}
}
}

View File

@ -6,6 +6,7 @@
//
import AVFoundation
import AVKit
import Combine
import Foundation
import SwiftUI
@ -27,7 +28,8 @@ final class DamusVideoPlayerViewModel: ObservableObject {
private let url: URL
private let player_item: AVPlayerItem
let player: AVPlayer
private let controller: VideoController
fileprivate let controller: VideoController
let player_view_controller = AVPlayerViewController()
let id = UUID()
@Published var has_audio = false
@ -55,7 +57,7 @@ final class DamusVideoPlayerViewModel: ObservableObject {
}
}
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController) {
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) {
self.url = url
player_item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: player_item)
@ -66,7 +68,7 @@ final class DamusVideoPlayerViewModel: ObservableObject {
await load()
}
is_muted = controller.should_mute_video(url: url)
is_muted = mute ?? controller.should_mute_video(url: url)
player.isMuted = is_muted
NotificationCenter.default.addObserver(