swiftswiftuiavplayerlayeruiviewrepresentableavplayerlooper

AVQueuePlayer play/pause methods not updating UI


I have a looping video player that should play or pause based on a given isPlaying property. I initially created a looping video player (similar implementation to this example) -- now I want the ability to pause it.

The UI initializes the video in the correct playing/paused state, but the UI doesn't update when isPlaying changes unless the view is unmounted and re-rendered.

I used print to confirm that isPlaying is the correct value and updateUIView invokes play or pause.

I'm wondering why the UI doesn't show the updated playing/paused of the video.

isPlaying is passed into the LoopingVideoPlayer like so

...
var body: some View {
    LoopingVideoPlayer(
      fileUrl: localUrl,
      resizeMode: .resizeAspectFill, 
      isPlaying: isPlaying)
}
...

My code for the looping video player:

import SwiftUI
import AVKit
import AVFoundation

struct LoopingVideoPlayer: UIViewRepresentable {
    var fileUrl: URL
    var resizeMode: AVLayerVideoGravity
    var isPlaying: Bool = true

    private var loopingPlayerUIView: LoopingPlayerUIView

    init(fileUrl: URL, resizeMode: AVLayerVideoGravity, isPlaying: Bool = true) {
        self.fileUrl = fileUrl
        self.resizeMode = resizeMode
        self.isPlaying = isPlaying
        self.loopingPlayerUIView = LoopingPlayerUIView(frame: .zero)
    }

    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LoopingVideoPlayer>) {
        print("updateUIView called while isPlaying == \(isPlaying)")
        if isPlaying {
            loopingPlayerUIView.play()
        } else {
            loopingPlayerUIView.pause()
        }
    }

    func makeUIView(context: Context) -> UIView {
        print("makeUIView called")
        loopingPlayerUIView.initPlayer(fileUrl: fileUrl, resizeMode: resizeMode)
        return loopingPlayerUIView
    }
}

class LoopingPlayerUIView: UIView {
    private let playerLayer = AVPlayerLayer()
    private let queuePlayer = AVQueuePlayer()
    private var playerLooper: AVPlayerLooper?

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        playerLayer.player = queuePlayer
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }

    func initPlayer(fileUrl: URL, resizeMode: AVLayerVideoGravity) {
        print("initPlayer method invoked")
        let avAsset = AVAsset(url: fileUrl)
        let avPlayerItem = AVPlayerItem(asset: avAsset)

        playerLayer.videoGravity = resizeMode
        layer.addSublayer(playerLayer)

        playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: avPlayerItem)
    }

    func play() {
        print("play method invoked")
        print("playerLayer reference matches queuePlayer: \(queuePlayer === playerLayer.player)")
        queuePlayer.play()
    }

    func pause() {
        print("pause method invoked")
        print("playerLayer reference matches queuePlayer: \(queuePlayer === playerLayer.player)")
        queuePlayer.pause()
    }
}


Solution

  • To update the isPlaying value you'll need to use a Binding variable. Currently when you pass true or false to your LoopingVideoPlayer representable you're creating a copy of the value that's being initialized. That means when you update your variable, it won't update LoopingVideoPlayer. However, a Binding creates a single source of truth.

    The new code should look something like this:

    struct LoopingVideoPlayer: UIViewRepresentable {
        var fileUrl: URL
        var resizeMode: AVLayerVideoGravity
        @Binding var isPlaying: Bool
    
        private var loopingPlayerUIView: LoopingPlayerUIView
    
        init(fileUrl: URL, resizeMode: AVLayerVideoGravity, isPlaying: Binding<Bool>) {
            self.fileUrl = fileUrl
            self.resizeMode = resizeMode
            self._isPlaying = isPlaying
            self.loopingPlayerUIView = LoopingPlayerUIView(frame: .zero)
        }
    
    ...
    

    Then, in your parent view, the isPlaying variable should be a State value as shown below:

    ...
    @State var isPlaying: Bool = true
    
    var body: some View {
      LoopingVideoPlayer(
        fileUrl: localUrl,
        resizeMode: .resizeAspectFill, 
        isPlaying: $isPlaying)
      }
    ...
    

    I'd recommend reading up more on the relationship between State and Binding in SwiftUI here.