Unable to hide AVPlayer controls after showing up

73 Views Asked by At

SpecificMediaFileView is a view that shows up when the user presses a button, and it would show up either an image or a video. When a media type of video shows up, the view isn't obvious to the user that there are video playback controls, so the user has to manually tap it once to see the playback controls showing up to know that the media type shown up from SpecificMediaFileView is of type Video:

import SwiftUI
import AVKit

struct Media: Identifiable {
    let id = UUID()
    let creationTime: Date
    var image: UIImage?
    var videoURL: URL?
}

struct SpecificMediaFileView: View {
    let media: Media

    var body: some View {
        Group {
            if let image = media.image {
                Image(uiImage: image)
                    
            } else if let videoURL = media.videoURL {
                VideoPlayer(player: AVPlayer(url: videoURL))
            }
        }
    }
}

I have tried implementing the solution suggested by Amir Kosandi in this thread, and the video does show up with playback controls on the appearance of SpecificMediaFileView, but now the playback controls can't be manually hidden or shown with a tap gesture after this implementation:

struct SpecificMediaFileView: View {
    let media: Media

    var body: some View {
        Group {
            if let image = media.image {
                Image(uiImage: image)
                    
            } else if let videoURL = media.videoURL {
                CustomVideoPlayer(url: videoURL)
            }
        }
    }
}

struct CustomVideoPlayer: UIViewControllerRepresentable {
    var url: URL

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = AVPlayer(url)
        controller.showsPlaybackControls = true
        controller.setValue(false, forKey: "canHidePlaybackControls")
        return controller
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
        // Update the controller if needed.
    }
}

The former implementation doesn't have it show up on appear but is user show/hide-controllable via tap, but the latter does the opposite of the two. How can I modify my CustomVideoPlayer to show the playback controls on appear but allow them to be hidden/shown with a tap gesture?

1

There are 1 best solutions below

4
MatBuompy On

I just used an onTapGesture, to keep things simple, that updates the UIViewControllerRepresentable VideoPlayer using a Binding variable passed by the SpecificMediaFileView:

struct SpecificMediaFileView: View {
    
    @State private var showControls: Bool = true
    let media: Media
    
    var body: some View {
        Group {
            if let image = media.image {
                Image(uiImage: image)
            } else if let videoURL = media.videoURL {
                CustomVideoPlayer(url: videoURL, showControls: $showControls)
                    .onTapGesture {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            showControls.toggle()
                        }
                        
                    }
            }
        }
    }
}

And updated the CustomVideoPlayer like this:

/// Add this to your properties
@Binding var showControls: Bool

func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
    // Update the controller if needed.
    uiViewController.showsPlaybackControls = showControls
}

This will surely work for hiding or showing the player controls but, If you want full control over this you should implement your own controls using an overlay over the CustomVideoPlayer View. I could show you an example if you want.

By the way, as I said, according to me the best way to do this is to have a custom design for your controls. I already had one working in a project, I took the chance to simplify it a bit for this use case:

struct VidePlayerView: View {
    
    // MARK: - PROPERTIES
    var size: CGSize
    var safeArea: EdgeInsets
    
    /// VIew Properties
    @State private var player: AVPlayer? = {
        AVPlayer(url: .init(string: "https://www.youtube.com/watch?v=VBvphFzVQS4")!)
    }()
    @State private var showPlayerControls = false
    @State private var isPlaying = false
    @State private var timeoutTask: DispatchWorkItem?
    @State private var isFinishedPlaying = false
    
    /// Video Seeker properties
    @GestureState private var isDragging: Bool = false
    @State private var isSeeking = false
    @State private var progress: CGFloat = 0
    @State private var lastDraggedProgress: CGFloat = 0
    @State private var isObserverAdded: Bool = false
    
    
    var body: some View {
        VStack(spacing: 0) {
            /// Swapping Size when rotated
            let playerWidth = size.width
            let playerHeight = (size.height / 3.5)
            let videoPlayerSize = CGSize(width: playerWidth, height: playerHeight)
            
            /// Custom Video Player
            ZStack {
                if let player {
                    CustomVideoPlayer(player: player)
                        .overlay {
                            Rectangle()
                                .fill(.black.opacity(0.4))
                                .opacity(showPlayerControls || isDragging ? 1 : 0)
                                /// Animating Drag State
                                .animation(.easeInOut(duration: 0.35), value: isDragging)
                                .overlay {
                                    PlaybackControls()
                                }
                        } //: Overlay Controls
                        .onTapGesture {
                            withAnimation(.easeInOut(duration: 0.35)) {
                                showPlayerControls.toggle()
                            }
                            /// Timing out contorls, only if the video is playing
                            timeoutControls()
                        }
                        .overlay(alignment: .bottom) {
                            VideoSeekerView(videoSize: videoPlayerSize)
                        } //: Overlay SeekerView
                }
                
            } //: ZSTACK
            .background {
                Rectangle()
                    .fill(.black)
            }
            .frame(width: videoPlayerSize.width, height: videoPlayerSize.height)
            /// To avoid other views expansion set its native view height
            .frame(width: size.width, height: size.height / 3.5, alignment: .bottomLeading)
            /// Making it top view
            .zIndex(1000)
            
            ScrollView(.vertical, showsIndicators: false) {
                VStack(spacing: 10) {
                    ForEach(1...5, id: \.self) { index in
                        GeometryReader {
                            let size = $0.size
                            
                            Image("thumb\(index)")
                                .resizable()
                                .scaledToFill()
                                .frame(width: size.width, height: size.height)
                                .clipShape(.rect(cornerRadius: 15, style: .continuous))
                        } //: GEOMETRY
                        .frame(height: 220)
                    } //: LOOP
                } //: VSTACK
                .padding(EdgeInsets(top: 20,
                                    leading: 15,
                                    bottom: 15 + safeArea.bottom,
                                    trailing: 15)
                )
            } //: SCROLL
            
        } //: VSTACK
        //.padding(.top, safeArea.top)
        .onAppear {
            guard !isObserverAdded else { return }
            /// Adding ovserver to update seeker when the video is playing
            player?.addPeriodicTimeObserver(forInterval: .init(seconds: 1, preferredTimescale: 600), queue: .main, using: { time in
                /// Calculating video progress
                if let currentPlayerItem = player?.currentItem {
                    let totalDuration = currentPlayerItem.duration.seconds
                    guard let currentDuration = player?.currentTime().seconds else {
                        return
                    }
                    
                    let calculatedProgress = currentDuration / totalDuration
                    if !isSeeking {
                        progress = calculatedProgress
                        lastDraggedProgress = progress
                    }
                    
                    if calculatedProgress == 1 {
                        /// Video has finiehed playing
                        isFinishedPlaying = true
                        isPlaying = false
                    }
                }
            })
            
            isObserverAdded = true
        }
    }
    
    @ViewBuilder
    func PlaybackControls() -> some View {
        HStack(spacing: 25) {
            
            Button(action: {
                seekVideo(forward: false)
            }, label: {
                Image(systemName: "gobackward.10")
                    .modifier(VideoPlayerIconStyle(fontSize: 22, fontWeight: .semibold))
            })
            /// Disabling button since we have no action for it
            .disabled(true)
            .opacity(0.6)
            
            Button(action: {
                /// Changing video status
                togglePlay()
                
                timeoutControls()
                
                withAnimation(.easeInOut(duration: 0.2)) {
                    isPlaying.toggle()
                }
                
            }, label: {
                Image(systemName: isFinishedPlaying ? "arrow.clockwise" : (isPlaying ? "pause.fill" : "play.fill"))
                    .modifier(VideoPlayerIconStyle())
            })
            .scaleEffect(1.1)
            
            Button(action: {
                seekVideo()
            }, label: {
                Image(systemName: "goforward.10")
                    .modifier(VideoPlayerIconStyle(fontSize: 22, fontWeight: .semibold))
            })
            /// Disabling button since we have no action for it
            .disabled(true)
            .opacity(0.6)
            
        } //: HSTACK
        .opacity(showPlayerControls && !isDragging ? 1 : 0)
        .animation(.easeIn(duration: 0.2), value: showPlayerControls)
    }
    
    /// Video Seeker view
    @ViewBuilder
    func VideoSeekerView(videoSize: CGSize) -> some View {
        ZStack(alignment: .leading) {
            Rectangle()
                .fill(.gray)
            
            Rectangle()
                .fill(.red)
                .frame(width: max(videoSize.width * progress, 0))
            
        } //: ZSTACK
        .frame(height: 3)
        .overlay(alignment: .leading) {
            Circle()
                .fill(.red)
                .frame(width: 15, height: 15)
                .scaleEffect(showPlayerControls || isDragging ? 1 : 0.001, anchor: progress * videoSize.width > 15 ? .trailing : .leading)
                /// For more dragging space
                .frame(width: 50, height: 50)
                .contentShape(.circle)
                /// Moving alongside with Gesture Progress
                .offset(x: videoSize.width * progress)
                .gesture(
                    DragGesture()
                        .updating($isDragging, body: { value, out, _ in
                            out = true
                        })
                        .onChanged({ value in
                            /// Cancelling existing timeout task
                            if let timeoutTask {
                                timeoutTask.cancel()
                            }
                            
                            /// Calculating progress
                            let translationX: CGFloat = value.translation.width
                            let calculatedProgress = (translationX / videoSize.width) + lastDraggedProgress
                            
                            progress = max(min(calculatedProgress, 1), 0)
                            isSeeking = true
                        })
                        .onEnded({ value in
                            lastDraggedProgress = progress
                            /// Bringing video playback to dragged time
                            if let currentPlayerItem = player?.currentItem {
                                let totalDuration = currentPlayerItem.duration.seconds
                                player?.seek(to: .init(seconds: totalDuration * progress, preferredTimescale: 600))
                                
                                /// Reschduling timeout task
                                if isPlaying {
                                    timeoutControls()
                                }
                                
                                /// Releasing with slight delay
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                                    isSeeking = false
                                }
                            }
                        })
                )
                .offset(x: progress * videoSize.width > 15 ? -15 : 0)
                .frame(width: 15, height: 15)
        }
    }
    
    
    // MARK: - FUNCTIONS
    
    /// Timing out play back controls
    private func timeoutControls() {
        /// Cancelling already pending timeout tasks
        if let timeoutTask {
            timeoutTask.cancel()
        }
        
        timeoutTask = .init(block: {
            withAnimation(.easeInOut(duration: 0.35)) {
                showPlayerControls = false
            }
        })
        
        /// Scheduling task
        if let timeoutTask {
            DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: timeoutTask)
        }
    }
    
    private func togglePlay() {
        if isPlaying {
            /// Pause Video
            player?.pause()
        } else {
            /// Play video
            player?.play()
        }
    }
    
    private func seekVideo(forward: Bool = true) {
        guard let player else { return }
        let seconds = player.currentTime().seconds + (forward ? +defaultStandardSeconds : -defaultStandardSeconds)
        player.seek(to: .init(seconds: seconds, preferredTimescale: 600))
    }
    
}


struct VideoPlayerIconStyle: ViewModifier {
    
    var fontSize: CGFloat = 14
    var fontWeight: Font.Weight = .ultraLight
    
    func body(content: Content) -> some View {
        content
            .font(.system(size: fontSize, weight: fontWeight, design: .rounded))
            .foregroundColor(.white)
            .padding(15)
            .background(Circle().fill(.black.opacity(0.35)))
    }
}

Edit the CustomVideoPlayer like this:

struct CustomVideoPlayer: UIViewControllerRepresentable {
    
    var player: AVPlayer

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = player
        controller.showsPlaybackControls = false
        return controller
    }
    
    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
}

And you use it like this:

GeometryReader {
    let size = $0.size
    let safeArea = $0.safeAreaInsets
    VidePlayerView(size: size, safeArea: safeArea)
}

I know it can be a bit overwhelming at first sight, but it's pretty straightforward when you look at the code. It's just a bunch overlayed icons and the timer to make them appear/disapper. You can customise it for your needs, of course.

Let me know your thoughts on this!