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:
committed by
William Casarin
parent
98eddf1337
commit
671b0b67ce
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
127
damus/Views/Images/FullScreenCarouselView.swift
Normal file
127
damus/Views/Images/FullScreenCarouselView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user