How to Play Encrypted HTTP Live Streams Offline with AVFoundation for iOS using Swift 4
-
AVPlayer: The core class of playing videos on iOS.
-
AVPlayerLayer: This is a CALayer subclass that can display the playback of a given AVPlayer instance.
-
AVAsset: This is a representation of a media asset. An asset object contains information such as duration and creation date.
-
AVPlayerItem: This represents the current state of a playable video. This is what you need to provide to an AVPlayer to get things going.
#EXTM3U #EXT-X-VERSION:3 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1755600,CODECS="avc1.42001f,mp4a.40.2",RESOLUTION=640x360 media/hls_360.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2855600,CODECS="avc1.4d001f,mp4a.40.2",RESOLUTION=960x540 media/hls_540.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=5605600,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1280x720 media/hls_720.m3u8
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:9.9001, http://www.example.com/segment0.ts #EXTINF:9.9001, http://www.example.com/wifi/segment1.ts #EXTINF:9.9001, http://www.example.com/segment2.ts #EXT-X-ENDLIST
3. Play HTTP Live Streams
To play a media file, we need to create an AVPlayer instance pointing at the video URL.
Then to play/pause, we use these obvious commands:
func initializePlayer(videoUrl: URL) { let videoAsset = AVURLAsset(url: videoUrl) let playerItem = AVPlayerItem(asset: videoAsset) player = AVPlayer(playerItem: playerItem) playerLayer = AVPlayerLayer(player: player) }
1. Create an AVUrlAsset from the video URL.
func play() { player.play() } func pause() { player.pause() }
For encrypted HLS, the playlist contains the URL of the decryption key as shown below:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-KEY:METHOD=AES-128,URI="https://www.example.com/hls.key",IV=0xecd0d06aef664d8226c33816e78efa44
4. Download encrypted HTTP Live Streams
Because it’s an encrypted HLS, besides downloading the media file, we also need to download and save the encryption key locally. For this task, we will trigger the asset resource loader to access the .m3u8 files manually:
func initialiseDownloadSession() { let configuration = URLSessionConfiguration.background(withIdentifier: downloadIdentifier) downloadURLSession = AVAssetDownloadURLSession( configuration: configuration, assetDownloadDelegate: self, delegateQueue: OperationQueue.main ) }
We pass an AVURLAsset with the video URL to AVAssetDownloadURLSession, but before this, we need to replace the URL schema of the video URL with a fake one (“nothttps”).
This method forces the download task to call the AVAssetResourceLoader delegate because it cannot handle the fake URL:
func downloadTask(videoUrl: URL) { var urlComponents = URLComponents( url: videoUrl, resolvingAgainstBaseURL: false )! urlComponents.scheme = "nothttps" do { let asset = try AVURLAsset(url: urlComponents.asURL()) asset.resourceLoader.setDelegate(self, queue: DispatchQueue(label: "com.example.AssetResourceLoaderDelegateQueue")) downloadURLSession .makeAssetDownloadTask( asset: asset, assetTitle: "My Video", assetArtworkData: nil, options: nil )?.resume() } catch { print("Erorr while parsing the URL.") } }
Next, we return true to indicate that the delegate will load the requested resource:
func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) throws -> Bool { guard let url = loadingRequest.request.url else { return false } if url.scheme == "nothttps" { var urlComponents = URLComponents( url: url, resolvingAgainstBaseURL: false ) urlComponents!.scheme = "https" let newUrl = try urlComponents!.asURL() downloadHlsFile(videoUrl: newUrl, loadingRequest: loadingRequest) return true } return false }
func downloadHlsFile(videoUrl: URL, loadingRequest: AVAssetResourceLoadingRequest) { var request = URLRequest(url: videoUrl) request.httpMethod = "GET" let session = URLSession(configuration: URLSessionConfiguration.default) let task = session.dataTask(with: request) { data, response, _ in guard let data = data else { return } let strData = String(data: data, encoding: .utf8)! let modifiedData: Data! if strData.contains("EXT-X-KEY") { let start = strData.range(of: "URI=\"")!.upperBound let end = strData[start...].range(of: "\"")!.lowerBound let keyUrl = strData[start..<end] downloadHlsKey(keyUrl: keyUrl) let replacedKey = strData.replacingOccurrences( of: keyUrl, with: "notkeyhttps://example.com/hlsKey" ) modifiedData = replacedKey.data(using: .utf8) } else { let replacedSchema = strData.replacingOccurrences(of: "https", with: "nothttps") modifiedData = replacedSchema.data(using: .utf8) } loadingRequest.contentInformationRequest?.contentType = response?.mimeType loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true loadingRequest.contentInformationRequest?.contentLength = response!.expectedContentLength loadingRequest.dataRequest?.respond(with: modifiedData) loadingRequest.finishLoading() } task.resume() }
Notice that for the previous requests, we used the MIME content type for the loading request; this time we will use AVStreamingKeyDeliveryPersistentContentKeyType as the content type. This will tell AVFoundation that the content contains the encryption key.
func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) throws -> Bool { guard let url = loadingRequest.request.url else { return false } if url.scheme == "nothttps" { var urlComponents = URLComponents( url: url, resolvingAgainstBaseURL: false ) urlComponents!.scheme = "https" let newUrl = try urlComponents!.asURL() downloadHlsFile(videoUrl: newUrl, loadingRequest: loadingRequest) return true } return false } else if url.scheme == "notkeyhttps" { let hlsKey = Keychain.getData(key: .hlsKey) loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true loadingRequest.contentInformationRequest?.contentLength = hlsKey.count loadingRequest.dataRequest?.respond(with: hlsKey) loadingRequest.finishLoading() return true }
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { var percentageComplete = 0.0 for value in loadedTimeRanges { let loadedTimeRange = value.timeRangeValue percentageComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds } percentageComplete *= 100 DispatchQueue.main.async { self.progressView.updateProgress(percentageComplete.toCGFloat) } }
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { OfflineHandler.save(key: .location, data: location.relativePath) let storageManager = AVAssetDownloadStorageManager.shared() let newPolicy = AVMutableAssetDownloadStorageManagementPolicy() newPolicy.expirationDate = Date().addYears(1)! newPolicy.priority = .important let baseURL = URL(fileURLWithPath: NSHomeDirectory()) let assetURL = baseURL.appendingPathComponent(location.relativePath) storageManager.setStorageManagementPolicy(newPolicy, for: assetURL) }
In this case, we will set a policy for the new downloaded file with a duration and a priority level.
5. Play encrypted HTTP Live Streams Offline
func initializePlayer() { let assetPath = OfflineHandler.get(.location) as! String let baseURL = URL(fileURLWithPath: NSHomeDirectory()) let assetURL = baseURL.appendingPathComponent(assetPath) let videoAsset = AVURLAsset(url: assetURL) videoAsset.resourceLoader.setDelegate(self, queue: DispatchQueue(label: "com.example.AssetResourceLoaderDelegateQueue")) let playerItem = AVPlayerItem(asset: videoAsset) player = AVPlayer(playerItem: playerItem) playerLayer = AVPlayerLayer(player: player) }
func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) throws -> Bool { guard let url = loadingRequest.request.url else { return false } if url.scheme == "notkeyhttps" { let hlsKey = Keychain.getData(key: .hlsKey) loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true loadingRequest.contentInformationRequest?.contentLength = hlsKey.count loadingRequest.dataRequest?.respond(with: hlsKey) loadingRequest.finishLoading() return true } return false }