How to create a looping video in SwiftUI without a loading delay

79 Views Asked by At

I'm trying to create a looping video in SwiftUI that will only display once the video is fully loaded. To highlight the issue, I have a blue rectangle in a ZStack under the video when the asset is ready. When the asset is ready, it will show the blue rectangle for a brief second before the video starts to play. I'm not sure how to get it perfect. Here is my code so far.

I also tried to do this with VideoPlayer in SwiftUI, but I couldn't figure out how to get a looping video with an aspect fill ratio.

import SwiftUI
import AVKit

class PlayerView: UIView {
    private let playerItem: AVPlayerItem
    private let player: AVPlayer
    private var playerLayer: AVPlayerLayer
    
    init(asset: AVAsset) {
        playerItem = AVPlayerItem(asset: asset)
        player = AVPlayer(playerItem: playerItem)
        playerLayer = AVPlayerLayer(player: player)
        super.init(frame: CGRectZero)
        
        player.play()
        playerLayer.videoGravity = .resizeAspectFill
        self.layer.addSublayer(playerLayer)
        
        NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: nil) { _ in
            self.player.seek(to: .zero)
            self.player.play()
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }
}

struct LoopingVideoPlayer: UIViewRepresentable {
    var asset: AVAsset
    
    func makeUIView(context: Context) -> PlayerView {
        PlayerView(asset: asset)
    }
    
    func updateUIView(_ uiView: PlayerView, context: Context) {

    }
}

struct ContentView: View {
    @State var video: AVAsset?
    var body: some View {
        ZStack {
            if let video {
                Rectangle()
                    .foregroundStyle(.blue)
                
                LoopingVideoPlayer(asset: video)
            }
            
            Button("Start") {
                Task {
                    guard let url = URL(string: "https://www.apple.com/105/media/us/apple-vision-pro/2024/6e1432b2-fe09-4113-a1af-f20987bcfeee/anim/experience-apps/large.mp4") else {
                        return
                    }
                    
                    let asset = AVAsset(url: url)
                    let _ = try await asset.load(.isPlayable)
                    video = asset
                }
            }
        }
    }
}

#Preview {
    ContentView()
}
1

There are 1 best solutions below

0
Hussein On

Give this a try:

class PlayerView: UIView {
    private var playerItem: AVPlayerItem
    private var player: AVPlayer
    private var playerLayer: AVPlayerLayer

    init(asset: AVAsset) {
        self.playerItem = AVPlayerItem(asset: asset)
        self.player = AVPlayer(playerItem: playerItem)
        self.playerLayer = AVPlayerLayer(player: player)
        super.init(frame: .zero)

        self.playerLayer.videoGravity = .resizeAspectFill
        self.layer.addSublayer(playerLayer)

        // Observe player item's status
        playerItem.addObserver(self, forKeyPath: "status", options: [.old, .new], context: nil)

        NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidReachEnd(notification:)), name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)
    }

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

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

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "status" {
            if playerItem.status == .readyToPlay {
                player.play()
            }
        }
    }

    @objc func playerItemDidReachEnd(notification: Notification) {
        self.player.seek(to: .zero)
        self.player.play()
    }

    deinit {
        playerItem.removeObserver(self, forKeyPath: "status")
        NotificationCenter.default.removeObserver(self)
    }
}

Notice the new function observeValue() which checks if the videos status is readyToPlay only then it would play it.