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>
This commit is contained in:
Daniel D’Aquino
2024-03-18 22:21:21 +00:00
committed by William Casarin
parent 98eddf1337
commit 671b0b67ce
9 changed files with 225 additions and 133 deletions

View File

@@ -132,7 +132,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 +201,7 @@ 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)
FullScreenCarouselView(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
}
.frame(height: height)
.onChange(of: model.selectedIndex) { value in
@@ -296,7 +296,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(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
.environmentObject(OrientationTracker())
}
}

View File

@@ -0,0 +1,127 @@
//
// FullScreenCarouselView.swift
// damus
//
// Created by William Casarin on 2023-03-23.
//
import SwiftUI
struct FullScreenCarouselView: 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.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
}
}
}
.animation(.easeInOut, value: showMenu)
.padding(.bottom, geo.safeAreaInsets.bottom == 0 ? 12 : 0)
}
)
}
}
}
fileprivate struct ImageViewPreview: 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")!)
var body: some View {
FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex)
.environmentObject(OrientationTracker())
}
}
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
ImageViewPreview()
}
}

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

@@ -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,19 @@ struct DamusVideoPlayer: View {
let url: URL
@StateObject var model: DamusVideoPlayerViewModel
@EnvironmentObject private var orientationTracker: OrientationTracker
let style: Style
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController) {
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, style: Style) {
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.style = style
}
var body: some View {
@@ -31,7 +40,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,8 +57,10 @@ 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
@@ -115,9 +134,24 @@ 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)?)
}
}
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(