Search code examples
iosswiftswiftuicachingvideo

How to playback an M3U8 Video from Cache in iOS?


I am dealing with an M3U8 video file where I'm trying to play it after storing such remote videos in my SwiftUI app.

The main approach I found so far is the accepted answer in this thread: Is it possible to cache Videos? IOS - Swift

Problem:
However, when I try to implement it, the M3U8 video does not load.
If I was to play directly from the videoURL, then there would be no issues and the video plays. But I would also like to play it from the cache.

VideoPlayer(player: player)
    .onAppear {
        CacheManager.shared.getFileWith(stringUrl: videoURL) { result in
            switch result {
            case .success(let url):
                self.player = AVPlayer(url: url)
                self.player?.play()
            case .failure:
                print("Error")
            }
        }
    }

For context, printing the URL from CacheManager gives file:///var/mobile/Containers/Data/Application/2F12CCE9-1D0C-447B-9B96-9EC6F6BE1413/Library/Caches/filename (where filename is the actual filename).

And this is what the video player looks like when running the app.

So is there an issue with my implementation of the code? Is the accepted answer from the old thread actually invalid/outdated and no longer works with current iOS versions? Does anyone have an alternative method for caching videos?


Solution

  • I ended up mainly implementing the approach in: https://developer.apple.com/documentation/avfoundation/offline_playback_and_storage/using_avfoundation_to_play_and_persist_http_live_streams.

    The sample code demonstrated a method for downloading HLS content however the videos were being saved in a public folder users could access from Settings, as well as using UserDefaults to store the locations, which were both features I did not want as I wanted to implement a cache that downloads videos when they load to remove later.

    So I modified and simplified the code as such:

    class Asset {
        var urlAsset: AVURLAsset
        var name: String
        
        init(urlAsset: AVURLAsset, name: String) {
            self.urlAsset = urlAsset
            self.name = name
        }
    }
    
    class AssetPersistenceManager: NSObject {
        static let shared = AssetPersistenceManager()
        private var assetDownloadURLSession: AVAssetDownloadURLSession!
        private var activeDownloadsMap = [AVAssetDownloadTask: Asset]()
        private var willDownloadToUrlMap = [AVAssetDownloadTask: URL]()
        
        private let fileManager = FileManager.default
        
        override private init() {
            super.init()
    
            // Create the configuration for the AVAssetDownloadURLSession.
            let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "AAPL-Identifier")
    
            // Create the AVAssetDownloadURLSession using the configuration.
            assetDownloadURLSession = AVAssetDownloadURLSession(configuration: backgroundConfiguration,
                                                                assetDownloadDelegate: self,
                                                                delegateQueue: OperationQueue.main)
        }
        
        func downloadStream(for asset: Asset) async {
            guard let task = assetDownloadURLSession.makeAssetDownloadTask(
                asset: asset.urlAsset,
                assetTitle: asset.urlAsset.url.lastPathComponent,
                assetArtworkData: nil,
                options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]
            ) else { return }
            
            activeDownloadsMap[task] = asset
            
            task.resume()
        }
        
        func localAssetForStream(withName name: String) -> AVURLAsset? {
            let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
            let localFileLocation = documentsUrl.appendingPathComponent(name)
            guard !fileManager.fileExists(atPath: localFileLocation.path)  else {
                return AVURLAsset(url: localFileLocation)
            }
            
            return nil
        }
        
        func cleanCache() {
            do {
                let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
                let contents = try fileManager.contentsOfDirectory(at: documentsUrl, includingPropertiesForKeys: nil)
                for file in contents {
                    do {
                        try fileManager.removeItem(at: file)
                    }
                    catch {
                        print("An error occured trying to delete the contents on disk for: \(file).")
                    }
                }
            } catch {
                print("Failed to clean cache.")
            }
        }
    }
    
    extension AssetPersistenceManager: AVAssetDownloadDelegate {
    
        /// Tells the delegate that the task finished transferring data.
        func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
            guard let task = task as? AVAssetDownloadTask,
                let asset = activeDownloadsMap.removeValue(forKey: task) else { return }
    
            guard let downloadURL = willDownloadToUrlMap.removeValue(forKey: task) else { return }
    
            if let error = error as NSError? {
                switch (error.domain, error.code) {
                case (NSURLErrorDomain, NSURLErrorCancelled):
                    /*
                     This task was canceled, you should perform cleanup using the
                     URL saved from AVAssetDownloadDelegate.urlSession(_:assetDownloadTask:didFinishDownloadingTo:).
                     */
                    guard let localFileLocation = localAssetForStream(withName: asset.name)?.url else { return }
    
                    do {
                        try fileManager.removeItem(at: localFileLocation)
                    } catch {
                        print("An error occured trying to delete the contents on disk for \(asset.name): \(error)")
                    }
    
                case (NSURLErrorDomain, NSURLErrorUnknown):
                    fatalError("Downloading HLS streams is not supported in the simulator.")
    
                default:
                    fatalError("An unexpected error occured \(error.domain)")
                }
            } else {
                do {
                    let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
                    let newURL = documentsUrl.appendingPathComponent(asset.name)
                    try fileManager.moveItem(at: downloadURL, to: newURL)
                } catch {
                    print("Failed to move downloaded file to temp directory.")
                }
            }
        }
    
        /// Method called when the an aggregate download task determines the location this asset will be downloaded to.
        func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
            willDownloadToUrlMap[assetDownloadTask] = location
        }
    }
    

    Most notably, I changed the delegate for handling the completion of the download to move the file to a /tmp directory afterwards so it no longer appears in Settings. This now lets me asynchronously download HTTP Streams and cache them in a temporary directory for later fetching.