skip to Main Content

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?

2

Answers


  1. Chosen as BEST ANSWER

    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.


  2. To make the playback work, you can try these steps:

    (1) Download both the .m3u8 and the related .ts file(s) as listed within the M3U8 file.

    (2) Inside the M3U8, change the text of the path by removing any sub-folders.

    eg: if your M3U8 path is: /cdn_url/video720/filename.ts just remove the /cdn_url/video720/ text.

    Then your M3U8 should look something like this (where only its own file path is changed):

    #EXTM3U
    #EXT-X-VERSION:6
    #EXT-X-TARGETDURATION:30
    #EXT-X-MEDIA-SEQUENCE:0
    #EXT-X-PLAYLIST-TYPE:VOD
    #EXT-X-INDEPENDENT-SEGMENTS
    #EXTINF:30.0,
    filename.ts
    #EXT-X-ENDLIST
    

    It is a good idea also to rename both the M3U8 and TS with a matching file "name" to identify playlist videos.

    eg: Make it as Batman_ep_01.m3u8 and Batman_ep_01.ts

    (3) Save and test in AVPlayer.

    What about multiple TS files in one M3U8?
    It works the same as Step (2) but you simply put the M3U8 and its related TS files into a unique sub-folder. Something like below where the m3u8 and ts files are stored.

    eg: /Library/Caches/some_Show_multi/

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search