From e27baeb065e407256b6f06e9f124b22e15342cac Mon Sep 17 00:00:00 2001 From: Nick Fujita Date: Thu, 28 Oct 2021 10:34:05 +0900 Subject: [PATCH] VEX-5938: Update resource loader to handle encrypted local files (#12) Adds offline decryption key and uses it to decrypt content during offline playback Jira: VEX-5938 https://jira.tenkasu.net/browse/VEX-5938 - Update to accept scheme for key required to play offline playback - Uses provided scheme to intercept call from player and return the key - Fixes player item observer removal pattern ### Reviews - Major reviewer (domain expert): @armadilio3 --- README.md | 13 + Video.js | 1 + ios/Video/Features/RCTPlayerObserver.swift | 32 +- ios/Video/Features/RCTPlayerOperations.swift | 20 ++ .../Features/RCTResourceLoaderDelegate.swift | 239 +++++--------- ios/Video/Features/RCTVideoDRM.swift | 168 ++++++++++ .../Features/RCTVideoErrorHandling.swift | 32 +- ios/Video/Features/RCTVideoUtils.swift | 107 +++++++ ios/Video/RCTVideo.swift | 294 +++++++----------- ios/Video/RCTVideoManager.m | 1 + ios/VideoCaching/RCTVideoCachingHandler.swift | 42 +-- react-native-video.podspec | 3 +- 12 files changed, 573 insertions(+), 379 deletions(-) create mode 100644 ios/Video/Features/RCTVideoDRM.swift diff --git a/README.md b/README.md index d46e8d39..ca6745d3 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ var styles = StyleSheet.create({ * [trackId](#trackId) * [useTextureView](#usetextureview) * [volume](#volume) +* [localSourceEncryptionKeyScheme](#localSourceEncryptionKeyScheme) ### Event props * [onAudioBecomingNoisy](#onaudiobecomingnoisy) @@ -915,6 +916,18 @@ Adjust the volume. Platforms: all +#### localSourceEncryptionKeyScheme +Set the url scheme for stream encryption key for local assets + +Type: String + +Example: +``` +localSourceEncryptionKeyScheme="my-offline-key" +``` + +Platforms: iOS + ### Event props diff --git a/Video.js b/Video.js index 88d90324..62a179a4 100644 --- a/Video.js +++ b/Video.js @@ -418,6 +418,7 @@ Video.propTypes = { certificateUrl: PropTypes.string, getLicense: PropTypes.func, }), + localSourceEncryptionKeyScheme: PropTypes.string, minLoadRetryCount: PropTypes.number, maxBitRate: PropTypes.number, resizeMode: PropTypes.string, diff --git a/ios/Video/Features/RCTPlayerObserver.swift b/ios/Video/Features/RCTPlayerObserver.swift index d17b27da..2f17a19c 100644 --- a/ios/Video/Features/RCTPlayerObserver.swift +++ b/ios/Video/Features/RCTPlayerObserver.swift @@ -28,39 +28,43 @@ class RCTPlayerObserver: NSObject { var _handlers: RCTPlayerObserverHandler! var player:AVPlayer? { + willSet { + removePlayerObservers() + removePlayerTimeObserver() + } didSet { - if player == nil { - removePlayerObservers() - removePlayerTimeObserver() - } else { + if player != nil { addPlayerObservers() addPlayerTimeObserver() } } } var playerItem:AVPlayerItem? { + willSet { + removePlayerItemObservers() + } didSet { - if playerItem == nil { - removePlayerItemObservers() - } else { + if playerItem != nil { addPlayerItemObservers() } } } var playerViewController:AVPlayerViewController? { + willSet { + removePlayerViewControllerObservers() + } didSet { - if playerViewController == nil { - removePlayerViewControllerObservers() - } else { + if playerViewController != nil { addPlayerViewControllerObservers() } } } var playerLayer:AVPlayerLayer? { + willSet { + removePlayerLayerObserver() + } didSet { if playerLayer == nil { - removePlayerLayerObserver() - } else { addPlayerLayerObserver() } } @@ -148,8 +152,8 @@ class RCTPlayerObserver: NSObject { /* Cancels the previously registered time observer. */ func removePlayerTimeObserver() { - if let timeObserver = _timeObserver { - player?.removeTimeObserver(timeObserver) + if _timeObserver != nil { + player?.removeTimeObserver(_timeObserver) _timeObserver = nil } } diff --git a/ios/Video/Features/RCTPlayerOperations.swift b/ios/Video/Features/RCTPlayerOperations.swift index 8d70f46d..094cbf44 100644 --- a/ios/Video/Features/RCTPlayerOperations.swift +++ b/ios/Video/Features/RCTPlayerOperations.swift @@ -1,5 +1,6 @@ import AVFoundation import MediaAccessibility +import Promises let RCTVideoUnset = -1 @@ -157,4 +158,23 @@ enum RCTPlayerOperations { } } + + static func seek(player: AVPlayer, playerItem:AVPlayerItem, paused:Bool, seekTime:Float, seekTolerance:Float) -> Promise { + let timeScale:Int = 1000 + let cmSeekTime:CMTime = CMTimeMakeWithSeconds(Float64(seekTime), preferredTimescale: Int32(timeScale)) + let current:CMTime = playerItem.currentTime() + let tolerance:CMTime = CMTimeMake(value: Int64(seekTolerance), timescale: Int32(timeScale)) + + return Promise(on: .global()) { fulfill, reject in + guard CMTimeCompare(current, cmSeekTime) != 0 else { + reject(NSError()) + return + } + if !paused { player.pause() } + + player.seek(to: cmSeekTime, toleranceBefore:tolerance, toleranceAfter:tolerance, completionHandler:{ (finished:Bool) in + fulfill(finished) + }) + } + } } diff --git a/ios/Video/Features/RCTResourceLoaderDelegate.swift b/ios/Video/Features/RCTResourceLoaderDelegate.swift index 5ead7fef..e1f11528 100644 --- a/ios/Video/Features/RCTResourceLoaderDelegate.swift +++ b/ios/Video/Features/RCTResourceLoaderDelegate.swift @@ -1,4 +1,5 @@ import AVFoundation +import Promises class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate { @@ -6,6 +7,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes private var _requestingCertificate:Bool = false private var _requestingCertificateErrored:Bool = false private var _drm: DRMParams? + private var _localSourceEncryptionKeyScheme: String? private var _reactTag: NSNumber? private var _onVideoError: RCTDirectEventBlock? private var _onGetLicense: RCTDirectEventBlock? @@ -14,6 +16,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes init( asset: AVURLAsset, drm: DRMParams?, + localSourceEncryptionKeyScheme: String?, onVideoError: RCTDirectEventBlock?, onGetLicense: RCTDirectEventBlock?, reactTag: NSNumber @@ -25,6 +28,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes _onVideoError = onVideoError _onGetLicense = onGetLicense _drm = drm + _localSourceEncryptionKeyScheme = localSourceEncryptionKeyScheme } deinit { @@ -42,16 +46,9 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes func resourceLoader(_ resourceLoader:AVAssetResourceLoader, didCancel loadingRequest:AVAssetResourceLoadingRequest) { NSLog("didCancelLoadingRequest") } - - func base64DataFromBase64String(base64String:String?) -> Data? { - if let base64String = base64String { - return Data(base64Encoded:base64String) - } - return nil - } - + func setLicenseResult(_ license:String!) { - guard let respondData = self.base64DataFromBase64String(base64String: license), + guard let respondData = RCTVideoUtils.base64DataFromBase64String(base64String: license), let _loadingRequest = _loadingRequest else { setLicenseResultError("No data from JS license response") return @@ -67,14 +64,13 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes } } - func finishLoadingWithError(error:NSError!) -> Bool { + func finishLoadingWithError(error:Error!) -> Bool { if let _loadingRequest = _loadingRequest, let error = error { - let licenseError:NSError! = error - _loadingRequest.finishLoading(with: licenseError) + _loadingRequest.finishLoading(with: error as! NSError) _onVideoError?([ "error": [ - "code": NSNumber(value: error.code), + "code": NSNumber(value: (error as NSError).code), "localizedDescription": error.localizedDescription == nil ? "" : error.localizedDescription, "localizedFailureReason": ((error as NSError).localizedFailureReason == nil ? "" : (error as NSError).localizedFailureReason) ?? "", "localizedRecoverySuggestion": ((error as NSError).localizedRecoverySuggestion == nil ? "" : (error as NSError).localizedRecoverySuggestion) ?? "", @@ -88,6 +84,35 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes } func loadingRequestHandling(_ loadingRequest:AVAssetResourceLoadingRequest!) -> Bool { + if handleEmbeddedKey(loadingRequest) { + return true + } + + if _drm != nil { + return handleDrm(loadingRequest) + } + + return false + } + + func handleEmbeddedKey(_ loadingRequest:AVAssetResourceLoadingRequest!) -> Bool { + guard let url = loadingRequest.request.url, + let _localSourceEncryptionKeyScheme = _localSourceEncryptionKeyScheme, + let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: _localSourceEncryptionKeyScheme) + else { + return false + } + + loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType + loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true + loadingRequest.contentInformationRequest?.contentLength = Int64(persistentKeyData.count) + loadingRequest.dataRequest?.respond(with: persistentKeyData) + loadingRequest.finishLoading() + + return true + } + + func handleDrm(_ loadingRequest:AVAssetResourceLoadingRequest!) -> Bool { if _requestingCertificate { return true } else if _requestingCertificateErrored { @@ -95,164 +120,48 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes } _loadingRequest = loadingRequest - let url = loadingRequest.request.url - guard let _drm = _drm else { + guard let _drm = _drm, let drmType = _drm.type, drmType == "fairplay" else { return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData) } - var contentId:String! - let contentIdOverride:String! = _drm.contentId - if contentIdOverride != nil { - contentId = contentIdOverride - } else if (_onGetLicense != nil) { - contentId = url?.host - } else { - contentId = url?.absoluteString.replacingOccurrences(of: "skd://", with:"") - } - - let drmType:String! = _drm.type - guard drmType == "fairplay" else { - return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData) - } - - let certificateStringUrl:String! = _drm.certificateUrl - guard let certificateStringUrl = certificateStringUrl, let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else { - return finishLoadingWithError(error: RCTVideoErrorHandler.noCertificateURL) - } - DispatchQueue.global().async { [weak self] in - guard let self = self else { return } - var certificateData:Data? - if (_drm.base64Certificate != nil) { - certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters) - } else { - do { - certificateData = try Data(contentsOf: certificateURL) - } catch {} - } - - guard let certificateData = certificateData else { - self.finishLoadingWithError(error: RCTVideoErrorHandler.noCertificateData) - self._requestingCertificateErrored = true - return - } - - var contentIdData:NSData! - if self._onGetLicense != nil { - contentIdData = contentId.data(using: .utf8) as NSData? - } else { - contentIdData = NSData(bytes: contentId.cString(using: String.Encoding.utf8), length:contentId.lengthOfBytes(using: String.Encoding.utf8)) - } - - let dataRequest:AVAssetResourceLoadingDataRequest! = loadingRequest.dataRequest - guard dataRequest != nil else { - self.finishLoadingWithError(error: RCTVideoErrorHandler.noCertificateData) - self._requestingCertificateErrored = true - return - } - - var spcError:NSError! - var spcData: Data? - do { - spcData = try loadingRequest.streamingContentKeyRequestData(forApp: certificateData, contentIdentifier: contentIdData as Data, options: nil) - } catch let spcError { - print("SPC error") - } - // Request CKC to the server - var licenseServer:String! = _drm.licenseServer - if spcError != nil { - self.finishLoadingWithError(error: spcError) - self._requestingCertificateErrored = true - } - - guard spcData != nil else { - self.finishLoadingWithError(error: RCTVideoErrorHandler.noSPC) - self._requestingCertificateErrored = true - return - } - - // js client has a onGetLicense callback and will handle license fetching - if let _onGetLicense = self._onGetLicense { - let base64Encoded = spcData?.base64EncodedString(options: []) + var promise: Promise + if _onGetLicense != nil { + let contentId = _drm.contentId ?? loadingRequest.request.url?.host + promise = RCTVideoDRM.handleWithOnGetLicense( + loadingRequest:loadingRequest, + contentId:contentId, + certificateUrl:_drm.certificateUrl, + base64Certificate:_drm.base64Certificate + ) .then{ spcData -> Void in self._requestingCertificate = true - if licenseServer == nil { - licenseServer = "" - } - _onGetLicense(["licenseUrl": licenseServer, - "contentId": contentId, - "spcBase64": base64Encoded, - "target": self._reactTag]) - - - } else if licenseServer != nil { - self.fetchLicense( - licenseServer: licenseServer, - spcData: spcData, - contentId: contentId, - dataRequest: dataRequest - ) + self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "", + "contentId": contentId, + "spcBase64": spcData.base64EncodedString(options: []), + "target": self._reactTag]) } + } else { + promise = RCTVideoDRM.handleInternalGetLicense( + loadingRequest:loadingRequest, + contentId:_drm.contentId, + licenseServer:_drm.licenseServer, + certificateUrl:_drm.certificateUrl, + base64Certificate:_drm.base64Certificate, + headers:_drm.headers + ) .then{ data -> Void in + guard let dataRequest = loadingRequest.dataRequest else { + throw RCTVideoErrorHandler.noCertificateData + } + dataRequest.respond(with:data) + loadingRequest.finishLoading() + } } + + + promise.catch{ error in + self.finishLoadingWithError(error:error) + self._requestingCertificateErrored = true + } + return true } - - func fetchLicense( - licenseServer: String, - spcData: Data?, - contentId: String, - dataRequest: AVAssetResourceLoadingDataRequest! - ) { - var request = URLRequest(url: URL(string: licenseServer)!) - request.httpMethod = "POST" - - // HEADERS - if let headers = _drm?.headers { - for item in headers { - guard let key = item.key as? String, let value = item.value as? String else { - continue - } - request.setValue(value, forHTTPHeaderField: key) - } - } - - if (_onGetLicense != nil) { - request.httpBody = spcData - } else { - let spcEncoded = spcData?.base64EncodedString(options: []) - let spcUrlEncoded = CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, spcEncoded as? CFString? as! CFString, nil, "?=&+" as CFString, CFStringBuiltInEncodings.UTF8.rawValue) as? String - let post = String(format:"spc=%@&%@", spcUrlEncoded as! CVarArg, contentId) - let postData = post.data(using: String.Encoding.utf8, allowLossyConversion:true) - request.httpBody = postData - } - - let postDataTask = URLSession.shared.dataTask(with: request as URLRequest, completionHandler:{ [weak self] (data:Data!,response:URLResponse!,error:Error!) in - guard let self = self else { return } - let httpResponse:HTTPURLResponse! = response as! HTTPURLResponse - guard error == nil else { - print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") - self.finishLoadingWithError(error: error as NSError?) - self._requestingCertificateErrored = true - return - } - guard httpResponse.statusCode == 200 else { - print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") - self.finishLoadingWithError(error: RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode)) - self._requestingCertificateErrored = true - return - } - - guard data != nil else { - self.finishLoadingWithError(error: RCTVideoErrorHandler.noDataFromLicenseRequest) - self._requestingCertificateErrored = true - return - } - - if (self._onGetLicense != nil) { - dataRequest.respond(with: data) - } else if let decodedData = Data(base64Encoded: data, options: []) { - dataRequest.respond(with: decodedData) - } - self._loadingRequest?.finishLoading() - }) - postDataTask.resume() - } } diff --git a/ios/Video/Features/RCTVideoDRM.swift b/ios/Video/Features/RCTVideoDRM.swift new file mode 100644 index 00000000..d059bbc4 --- /dev/null +++ b/ios/Video/Features/RCTVideoDRM.swift @@ -0,0 +1,168 @@ +import AVFoundation +import Promises + +struct RCTVideoDRM { + @available(*, unavailable) private init() {} + + static func fetchLicense( + licenseServer: String, + spcData: Data?, + contentId: String, + headers: [String:Any]? + ) -> Promise { + let request = createLicenseRequest(licenseServer:licenseServer, spcData:spcData, contentId:contentId, headers:headers) + + return Promise(on: .global()) { fulfill, reject in + let postDataTask = URLSession.shared.dataTask(with: request as URLRequest, completionHandler:{ (data:Data!,response:URLResponse!,error:Error!) in + + let httpResponse:HTTPURLResponse! = (response as! HTTPURLResponse) + + guard error == nil else { + print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") + reject(error) + return + } + guard httpResponse.statusCode == 200 else { + print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") + reject(RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode)) + return + } + + guard data != nil, let decodedData = Data(base64Encoded: data, options: []) else { + reject(RCTVideoErrorHandler.noDataFromLicenseRequest) + return + } + + fulfill(decodedData) + }) + postDataTask.resume() + } + } + + static func createLicenseRequest( + licenseServer: String, + spcData: Data?, + contentId: String, + headers: [String:Any]? + ) -> URLRequest { + var request = URLRequest(url: URL(string: licenseServer)!) + request.httpMethod = "POST" + + if let headers = headers { + for item in headers { + guard let key = item.key as? String, let value = item.value as? String else { + continue + } + request.setValue(value, forHTTPHeaderField: key) + } + } + + let spcEncoded = spcData?.base64EncodedString(options: []) + let spcUrlEncoded = CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, spcEncoded as? CFString? as! CFString, nil, "?=&+" as CFString, CFStringBuiltInEncodings.UTF8.rawValue) as? String + let post = String(format:"spc=%@&%@", spcUrlEncoded as! CVarArg, contentId) + let postData = post.data(using: String.Encoding.utf8, allowLossyConversion:true) + request.httpBody = postData + + return request + } + + static func fetchSpcData( + loadingRequest: AVAssetResourceLoadingRequest, + certificateData: Data, + contentIdData: Data + ) -> Promise { + return Promise(on: .global()) { fulfill, reject in + var spcError:NSError! + var spcData: Data? + do { + spcData = try loadingRequest.streamingContentKeyRequestData(forApp: certificateData, contentIdentifier: contentIdData as Data, options: nil) + } catch _ { + print("SPC error") + } + + if spcError != nil { + reject(spcError) + } + + guard let spcData = spcData else { + reject(RCTVideoErrorHandler.noSPC) + return + } + + fulfill(spcData) + } + } + + static func createCertificateData(certificateStringUrl:String?, base64Certificate:Bool?) -> Promise { + return Promise(on: .global()) { fulfill, reject in + + guard let certificateStringUrl = certificateStringUrl, + let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else { + reject(RCTVideoErrorHandler.noCertificateURL) + return + } + + var certificateData:Data? + do { + certificateData = try Data(contentsOf: certificateURL) + if (base64Certificate != nil) { + certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters) + } + } catch {} + + guard let certificateData = certificateData else { + reject(RCTVideoErrorHandler.noCertificateData) + return + } + + fulfill(certificateData) + } + } + + static func handleWithOnGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId:String?, certificateUrl:String?, base64Certificate:Bool?) -> Promise { + let contentIdData = contentId?.data(using: .utf8) + + return RCTVideoDRM.createCertificateData(certificateStringUrl:certificateUrl, base64Certificate:base64Certificate) + .then{ certificateData -> Promise in + guard let contentIdData = contentIdData else { + throw RCTVideoError.invalidContentId as! Error + } + + return RCTVideoDRM.fetchSpcData( + loadingRequest:loadingRequest, + certificateData:certificateData, + contentIdData:contentIdData + ) + } + } + + static func handleInternalGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId:String?, licenseServer:String?, certificateUrl:String?, base64Certificate:Bool?, headers: [String:Any]?) -> Promise { + let url = loadingRequest.request.url + + guard let contentId = contentId ?? url?.absoluteString.replacingOccurrences(of: "skd://", with:"") else { + return Promise(RCTVideoError.invalidContentId as! Error) + } + + let contentIdData = NSData(bytes: contentId.cString(using: String.Encoding.utf8), length:contentId.lengthOfBytes(using: String.Encoding.utf8)) as Data + + return RCTVideoDRM.createCertificateData(certificateStringUrl:certificateUrl, base64Certificate:base64Certificate) + .then{ certificateData in + return RCTVideoDRM.fetchSpcData( + loadingRequest:loadingRequest, + certificateData:certificateData, + contentIdData:contentIdData + ) + } + .then{ spcData -> Promise in + guard let licenseServer = licenseServer else { + throw RCTVideoError.noLicenseServerURL as! Error + } + return RCTVideoDRM.fetchLicense( + licenseServer: licenseServer, + spcData: spcData, + contentId: contentId, + headers: headers + ) + } + } +} diff --git a/ios/Video/Features/RCTVideoErrorHandling.swift b/ios/Video/Features/RCTVideoErrorHandling.swift index 2b65a969..e795aa28 100644 --- a/ios/Video/Features/RCTVideoErrorHandling.swift +++ b/ios/Video/Features/RCTVideoErrorHandling.swift @@ -1,5 +1,6 @@ enum RCTVideoError : Int { case fromJSPart + case noLicenseServerURL case licenseRequestNotOk case noDataFromLicenseRequest case noSPC @@ -8,11 +9,12 @@ enum RCTVideoError : Int { case noCertificateURL case noFairplayDRM case noDRMData + case invalidContentId } enum RCTVideoErrorHandler { - static let noDRMData: NSError = NSError( + static let noDRMData = NSError( domain: "RCTVideo", code: RCTVideoError.noDRMData.rawValue, userInfo: [ @@ -21,7 +23,7 @@ enum RCTVideoErrorHandler { NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?" ]) - static let noCertificateURL: NSError = NSError( + static let noCertificateURL = NSError( domain: "RCTVideo", code: RCTVideoError.noCertificateURL.rawValue, userInfo: [ @@ -30,7 +32,7 @@ enum RCTVideoErrorHandler { NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?" ]) - static let noCertificateData: NSError = NSError( + static let noCertificateData = NSError( domain: "RCTVideo", code: RCTVideoError.noCertificateData.rawValue, userInfo: [ @@ -39,7 +41,7 @@ enum RCTVideoErrorHandler { NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?" ]) - static let noSPC:NSError! = NSError( + static let noSPC = NSError( domain: "RCTVideo", code: RCTVideoError.noSPC.rawValue, userInfo: [ @@ -48,13 +50,22 @@ enum RCTVideoErrorHandler { NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config." ]) - static let noDataFromLicenseRequest:NSError! = NSError( + static let noLicenseServerURL = NSError( + domain: "RCTVideo", + code: RCTVideoError.noLicenseServerURL.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "Error obtaining DRM License.", + NSLocalizedFailureReasonErrorKey: "No license server URL has been found.", + NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop licenseServer?" + ]) + + static let noDataFromLicenseRequest = NSError( domain: "RCTVideo", code: RCTVideoError.noDataFromLicenseRequest.rawValue, userInfo: [ NSLocalizedDescriptionKey: "Error obtaining DRM license.", NSLocalizedFailureReasonErrorKey: "No data received from the license server.", - NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?." + NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?" ]) static func licenseRequestNotOk(_ statusCode: Int) -> NSError { @@ -80,4 +91,13 @@ enum RCTVideoErrorHandler { NSLocalizedRecoverySuggestionErrorKey: error ]) } + + static let invalidContentId = NSError( + domain: "RCTVideo", + code: RCTVideoError.invalidContentId.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: "No valide content Id received", + NSLocalizedRecoverySuggestionErrorKey: "Is the contentId and url ok?" + ]) } diff --git a/ios/Video/Features/RCTVideoUtils.swift b/ios/Video/Features/RCTVideoUtils.swift index 51a6aae7..69c127af 100644 --- a/ios/Video/Features/RCTVideoUtils.swift +++ b/ios/Video/Features/RCTVideoUtils.swift @@ -1,4 +1,5 @@ import AVFoundation +import Promises /*! * Collection of pure functions @@ -140,4 +141,110 @@ enum RCTVideoUtils { static func getCurrentTime(playerItem:AVPlayerItem?) -> Float { return Float(CMTimeGetSeconds(playerItem?.currentTime() ?? .zero)) } + + static func base64DataFromBase64String(base64String:String?) -> Data? { + if let base64String = base64String { + return Data(base64Encoded:base64String) + } + return nil + } + + static func replaceURLScheme(url: URL, scheme: String?) -> URL? { + var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + urlComponents?.scheme = scheme + + return urlComponents?.url + } + + static func extractDataFromCustomSchemeUrl(from url: URL, scheme: String) -> Data? { + guard url.scheme == scheme, + let adoptURL = RCTVideoUtils.replaceURLScheme(url:url, scheme: nil) else { return nil } + + return Data(base64Encoded: adoptURL.absoluteString) + } + + static func generateMixComposition(_ asset:AVAsset) -> AVMutableComposition { + let mixComposition:AVMutableComposition = AVMutableComposition() + + let videoAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.video).first + let videoCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID:kCMPersistentTrackID_Invalid) + do { + try videoCompTrack.insertTimeRange( + CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), + of: videoAsset, + at: .zero) + } catch { + } + + let audioAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.audio).first + let audioCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID:kCMPersistentTrackID_Invalid) + do { + try audioCompTrack.insertTimeRange( + CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), + of: audioAsset, + at: .zero) + } catch { + } + + return mixComposition + } + + static func getValidTextTracks(asset:AVAsset, assetOptions:NSDictionary?, mixComposition:AVMutableComposition, textTracks:[TextTrack]?) -> [TextTrack] { + let videoAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.video).first + var validTextTracks:[TextTrack] = [] + if let textTracks = textTracks, textTracks.count > 0 { + for i in 0.. Promise { + return Promise(on: .global()) { fulfill, reject in + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(seconds)) / Double(NSEC_PER_SEC), execute: { + fulfill(()) + }) + } + } + + static func prepareAsset(source:VideoSource) -> (asset:AVURLAsset?, assetOptions:NSMutableDictionary?)? { + guard source.uri != nil && source.uri != "" else { return nil } + var asset:AVURLAsset! + let bundlePath = Bundle.main.path(forResource: source.uri, ofType: source.type) ?? "" + let url = source.isNetwork || source.isAsset + ? URL(string: source.uri ?? "") + : URL(fileURLWithPath: bundlePath) + let assetOptions:NSMutableDictionary! = NSMutableDictionary() + + if source.isNetwork { + if let headers = source.requestHeaders, headers.count > 0 { + assetOptions.setObject(headers, forKey:"AVURLAssetHTTPHeaderFieldsKey" as NSCopying) + } + let cookies:[AnyObject]! = HTTPCookieStorage.shared.cookies + assetOptions.setObject(cookies, forKey:AVURLAssetHTTPCookiesKey as NSCopying) + asset = AVURLAsset(url: url!, options:assetOptions as! [String : Any]) + } else { + asset = AVURLAsset(url: url!) + } + return (asset, assetOptions) + } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index fb5b66ba..d124998d 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -2,6 +2,7 @@ import AVFoundation import AVKit import Foundation import React +import Promises class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler { @@ -17,6 +18,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH /* DRM */ private var _drm:DRMParams? + private var _localSourceEncryptionKeyScheme:String? + /* Required to publish events */ private var _eventDispatcher:RCTEventDispatcher? private var _videoLoadStarted:Bool = false @@ -61,7 +64,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _playerObserver: RCTPlayerObserver = RCTPlayerObserver() #if canImport(RCTVideoCache) - private var _videoCache:RCTVideoCachingHandler = RCTVideoCachingHandler(self.playerItemPrepareText) + private let _videoCache:RCTVideoCachingHandler = RCTVideoCachingHandler() #endif #if TARGET_OS_IOS @@ -125,6 +128,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH object: nil ) _playerObserver._handlers = self +#if canImport(RCTVideoCache) + _videoCache.playerItemPrepareText = playerItemPrepareText +#endif } required init?(coder aDecoder: NSCoder) { @@ -216,10 +222,39 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH removePlayerLayer() _playerObserver.player = nil _playerObserver.playerItem = nil - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0)) / Double(NSEC_PER_SEC), execute: { [weak self] in - guard let self = self else {return} - // perform on next run loop, otherwise other passed react-props may not be set - self.playerItemForSource(withCallback:{ (playerItem:AVPlayerItem!) in + + // perform on next run loop, otherwise other passed react-props may not be set + RCTVideoUtils.delay() + .then{ [weak self] in + guard let self = self else {throw NSError(domain: "", code: 0, userInfo: nil)} + guard let source = self._source, + let assetResult = RCTVideoUtils.prepareAsset(source: source), + let asset = assetResult.asset, + let assetOptions = assetResult.assetOptions else { + DebugLog("Could not find video URL in source '\(self._source)'") + throw NSError(domain: "", code: 0, userInfo: nil) + } + +#if canImport(RCTVideoCache) + if self._videoCache.shouldCache(source:source, textTracks:self._textTracks) { + return self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions:assetOptions) + } +#endif + + if self._drm != nil || self._localSourceEncryptionKeyScheme != nil { + self._resouceLoaderDelegate = RCTResourceLoaderDelegate( + asset: asset, + drm: self._drm, + localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme, + onVideoError: self.onVideoError, + onGetLicense: self.onGetLicense, + reactTag: self.reactTag + ) + } + return Promise{self.playerItemPrepareText(asset: asset, assetOptions:assetOptions)} + }.then{[weak self] (playerItem:AVPlayerItem!) in + guard let self = self else {throw NSError(domain: "", code: 0, userInfo: nil)} + self._player?.pause() self._playerItem = playerItem self._playerObserver.playerItem = self._playerItem @@ -233,7 +268,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self._playerObserver.player = self._player self._player?.actionAtItemEnd = .none - + if #available(iOS 10.0, *) { self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) } @@ -248,122 +283,38 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH "drm": self._drm?.json ?? NSNull(), "target": self.reactTag ]) - - }) - }) + }.catch{_ in } _videoLoadStarted = true } @objc - func setDrm(_ drm:NSDictionary!) { + func setDrm(_ drm:NSDictionary) { _drm = DRMParams(drm) } - func playerItemPrepareText(asset:AVAsset!, assetOptions:NSDictionary?, withCallback handler:(AVPlayerItem?)->Void) { + @objc + func setLocalSourceEncryptionKeyScheme(_ keyScheme:String) { + _localSourceEncryptionKeyScheme = keyScheme + } + + func playerItemPrepareText(asset:AVAsset!, assetOptions:NSDictionary?) -> AVPlayerItem { if (_textTracks == nil) || _textTracks?.count==0 { - handler(AVPlayerItem(asset: asset)) - return + return AVPlayerItem(asset: asset) } // AVPlayer can't airplay AVMutableCompositions _allowsExternalPlayback = false - - // sideload text tracks - let mixComposition:AVMutableComposition! = AVMutableComposition() - - let videoAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.video).first - let videoCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID:kCMPersistentTrackID_Invalid) - do { - try videoCompTrack.insertTimeRange( - CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), - of: videoAsset, - at: .zero) - } catch { - } - - let audioAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.audio).first - let audioCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID:kCMPersistentTrackID_Invalid) - do { - try audioCompTrack.insertTimeRange( - CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), - of: audioAsset, - at: .zero) - } catch { - } - - var validTextTracks:[TextTrack] = [] - if let textTracks = _textTracks, let textTrackCount = _textTracks?.count { - for i in 0..Void) { - var asset:AVURLAsset! - guard let source = _source, source.uri != nil && source.uri != "" else { - DebugLog("Could not find video URL in source '\(_source)'") - return - } - - let bundlePath = Bundle.main.path(forResource: source.uri, ofType: source.type) ?? "" - let url = source.isNetwork || source.isAsset - ? URL(string: source.uri ?? "") - : URL(fileURLWithPath: bundlePath) - - let assetOptions:NSMutableDictionary! = NSMutableDictionary() - - if url != nil && source.isNetwork { - if let headers = source.requestHeaders, headers.count > 0 { - assetOptions.setObject(headers, forKey:"AVURLAssetHTTPHeaderFieldsKey" as NSCopying) - } - let cookies:[AnyObject]! = HTTPCookieStorage.shared.cookies - assetOptions.setObject(cookies, forKey:AVURLAssetHTTPCookiesKey as NSCopying) -#if canImport(RCTVideoCache) - if _videoCache.playerItemForSourceUsingCache(shouldCache:shouldCache, textTracks:_textTracks, uri:uri, assetOptions:assetOptions, handler:handler) { - return - } -#endif - - asset = AVURLAsset(url: url!, options:assetOptions as! [String : Any]) - } else { - asset = AVURLAsset(url: url!) - } - - if _drm != nil { - _resouceLoaderDelegate = RCTResourceLoaderDelegate( - asset: asset, - drm: _drm, - onVideoError: onVideoError, - onGetLicense: onGetLicense, - reactTag: reactTag - ) - } - - self.playerItemPrepareText(asset: asset, assetOptions:assetOptions, withCallback:handler) + return AVPlayerItem(asset: mixComposition) } // MARK: - Prop setters @@ -415,13 +366,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } @objc - func setIgnoreSilentSwitch(_ ignoreSilentSwitch:String!) { + func setIgnoreSilentSwitch(_ ignoreSilentSwitch:String?) { _ignoreSilentSwitch = ignoreSilentSwitch self.applyModifiers() } @objc - func setMixWithOthers(_ mixWithOthers:String!) { + func setMixWithOthers(_ mixWithOthers:String?) { _mixWithOthers = mixWithOthers self.applyModifiers() } @@ -479,7 +430,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setCurrentTime(_ currentTime:Float) { - let info:NSDictionary! = [ + let info:NSDictionary = [ "time": NSNumber(value: currentTime), "tolerance": NSNumber(value: 100) ] @@ -490,37 +441,31 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH func setSeek(_ info:NSDictionary!) { let seekTime:NSNumber! = info["time"] as! NSNumber let seekTolerance:NSNumber! = info["tolerance"] as! NSNumber - - let timeScale:Int = 1000 - - let item:AVPlayerItem! = _player?.currentItem - guard item != nil && item.status == AVPlayerItem.Status.readyToPlay else { + let item:AVPlayerItem? = _player?.currentItem + guard item != nil, let player = _player, let item = item, item.status == AVPlayerItem.Status.readyToPlay else { _pendingSeek = true _pendingSeekTime = seekTime.floatValue return } + let wasPaused = _paused - // TODO check loadedTimeRanges - let cmSeekTime:CMTime = CMTimeMakeWithSeconds(Float64(seekTime.floatValue), preferredTimescale: Int32(timeScale)) - let current:CMTime = item.currentTime() - // TODO figure out a good tolerance level - let tolerance:CMTime = CMTimeMake(value: Int64(seekTolerance.floatValue), timescale: Int32(timeScale)) - let wasPaused:Bool = _paused - - guard CMTimeCompare(current, cmSeekTime) != 0 else { return } - if !wasPaused { _player?.pause() } - - _player?.seek(to: cmSeekTime, toleranceBefore:tolerance, toleranceAfter:tolerance, completionHandler:{ [weak self] (finished:Bool) in - guard let self = self else { return } - - self._playerObserver.addTimeObserverIfNotSet() - if !wasPaused { - self.setPaused(false) - } - self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))), - "seekTime": seekTime, - "target": self.reactTag]) - }) + RCTPlayerOperations.seek( + player:player, + playerItem:item, + paused:wasPaused, + seekTime:seekTime.floatValue, + seekTolerance:seekTolerance.floatValue) + .then{ [weak self] (finished:Bool) in + guard let self = self else { return } + + self._playerObserver.addTimeObserverIfNotSet() + if !wasPaused { + self.setPaused(false) + } + self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))), + "seekTime": seekTime, + "target": self.reactTag]) + }.catch{_ in } _pendingSeek = false } @@ -605,46 +550,46 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _repeat = `repeat` } - + @objc - func setSelectedAudioTrack(_ selectedAudioTrack:NSDictionary!) { + func setSelectedAudioTrack(_ selectedAudioTrack:NSDictionary?) { setSelectedAudioTrack(SelectedTrackCriteria(selectedAudioTrack)) } - func setSelectedAudioTrack(_ selectedAudioTrack:SelectedTrackCriteria!) { + func setSelectedAudioTrack(_ selectedAudioTrack:SelectedTrackCriteria?) { _selectedAudioTrackCriteria = selectedAudioTrack RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.audible, - criteria:_selectedAudioTrackCriteria) + criteria:_selectedAudioTrackCriteria) } @objc - func setSelectedTextTrack(_ selectedTextTrack:NSDictionary!) { + func setSelectedTextTrack(_ selectedTextTrack:NSDictionary?) { setSelectedTextTrack(SelectedTrackCriteria(selectedTextTrack)) } - func setSelectedTextTrack(_ selectedTextTrack:SelectedTrackCriteria!) { + func setSelectedTextTrack(_ selectedTextTrack:SelectedTrackCriteria?) { _selectedTextTrackCriteria = selectedTextTrack if (_textTracks != nil) { // sideloaded text tracks RCTPlayerOperations.setSideloadedText(player:_player, textTracks:_textTracks, criteria:_selectedTextTrackCriteria) } else { // text tracks included in the HLS playlist RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.legible, - criteria:_selectedTextTrackCriteria) + criteria:_selectedTextTrackCriteria) } } @objc - func setTextTracks(_ textTracks:[NSDictionary]!) { - setTextTracks(textTracks.map { TextTrack($0) }) + func setTextTracks(_ textTracks:[NSDictionary]?) { + setTextTracks(textTracks?.map { TextTrack($0) }) } - func setTextTracks(_ textTracks:[TextTrack]!) { + func setTextTracks(_ textTracks:[TextTrack]?) { _textTracks = textTracks // in case textTracks was set after selectedTextTrack if (_selectedTextTrackCriteria != nil) {setSelectedTextTrack(_selectedTextTrackCriteria)} } - + @objc func setFullscreen(_ fullscreen:Bool) { if fullscreen && !_fullscreenPlayerPresented && _player != nil { @@ -697,7 +642,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } @objc - func setFullscreenOrientation(_ orientation:String!) { + func setFullscreenOrientation(_ orientation:String?) { _fullscreenOrientation = orientation if _fullscreenPlayerPresented { _playerViewController?.preferredOrientation = orientation @@ -705,18 +650,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } func usePlayerViewController() { - guard _player != nil else { return } + guard let _player = _player, let _playerItem = _playerItem else { return } if _playerViewController == nil { - _playerViewController = createPlayerViewController(player: _player, withPlayerItem:_playerItem) - + _playerViewController = createPlayerViewController(player:_player, withPlayerItem:_playerItem) } // to prevent video from being animated when resizeMode is 'cover' // resize mode must be set before subview is added setResizeMode(_resizeMode) guard let _playerViewController = _playerViewController else { return } - + if _controls { let viewController:UIViewController! = self.reactViewController() viewController.addChild(_playerViewController) @@ -726,8 +670,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerObserver.playerViewController = _playerViewController } - func createPlayerViewController(player:AVPlayer!, withPlayerItem playerItem:AVPlayerItem!) -> RCTVideoPlayerViewController! { - let viewController:RCTVideoPlayerViewController! = RCTVideoPlayerViewController() + func createPlayerViewController(player:AVPlayer, withPlayerItem playerItem:AVPlayerItem) -> RCTVideoPlayerViewController { + let viewController = RCTVideoPlayerViewController() viewController.showsPlaybackControls = true viewController.rctDelegate = self viewController.preferredOrientation = _fullscreenOrientation @@ -953,20 +897,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH return } - var metadata: [[String:String?]?] = [] - for item in _items { - let value = item.value as? String - let identifier = item.identifier?.rawValue + var metadata: [[String:String?]?] = [] + for item in _items { + let value = item.value as? String + let identifier = item.identifier?.rawValue - if let value = value { - metadata.append(["value":value, "identifier":identifier]) - } - } + if let value = value { + metadata.append(["value":value, "identifier":identifier]) + } + } - onTimedMetadata?([ - "target": reactTag, - "metadata": metadata - ]) + onTimedMetadata?([ + "target": reactTag, + "metadata": metadata + ]) } // Handle player item status change. @@ -1134,14 +1078,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } //unused -// @objc func handleAVPlayerAccess(notification:NSNotification!) { -// let accessLog:AVPlayerItemAccessLog! = (notification.object as! AVPlayerItem).accessLog() -// let lastEvent:AVPlayerItemAccessLogEvent! = accessLog.events.last -// -// /* TODO: get this working -// if (self.onBandwidthUpdate) { -// self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]}); -// } -// */ -// } + // @objc func handleAVPlayerAccess(notification:NSNotification!) { + // let accessLog:AVPlayerItemAccessLog! = (notification.object as! AVPlayerItem).accessLog() + // let lastEvent:AVPlayerItemAccessLogEvent! = accessLog.events.last + // + // /* TODO: get this working + // if (self.onBandwidthUpdate) { + // self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]}); + // } + // */ + // } } diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index ece0a1e3..9979b440 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -34,6 +34,7 @@ RCT_EXPORT_VIEW_PROPERTY(filter, NSString); RCT_EXPORT_VIEW_PROPERTY(filterEnabled, BOOL); RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float); RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL); +RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString); /* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */ RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock); diff --git a/ios/VideoCaching/RCTVideoCachingHandler.swift b/ios/VideoCaching/RCTVideoCachingHandler.swift index 8e37f11a..5f6ffb77 100644 --- a/ios/VideoCaching/RCTVideoCachingHandler.swift +++ b/ios/VideoCaching/RCTVideoCachingHandler.swift @@ -1,52 +1,50 @@ import Foundation import AVFoundation import DVAssetLoaderDelegate +import Promises class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { private var _videoCache:RCTVideoCache! = RCTVideoCache.sharedInstance() - private var _playerItemPrepareText: (AVAsset?, NSDictionary?, (AVPlayerItem?)->Void) -> Void + var playerItemPrepareText: ((AVAsset?, NSDictionary?) -> AVPlayerItem)? - init(_ playerItemPrepareText: @escaping (AVAsset?, NSDictionary?, (AVPlayerItem?)->Void) -> Void) { - _playerItemPrepareText = playerItemPrepareText + override init() { + super.init() } - func playerItemForSourceUsingCache(shouldCache:Bool, textTracks:[AnyObject]?, uri:String, assetOptions:NSMutableDictionary, handler:@escaping (AVPlayerItem?)->Void) -> Bool { - if shouldCache && ((textTracks == nil) || (textTracks!.count == 0)) { + func shouldCache(source: VideoSource, textTracks:[TextTrack]?) -> Bool { + if source.isNetwork && source.shouldCache && ((textTracks == nil) || (textTracks!.count == 0)) { /* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying * to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded. * Until this is fixed, we need to bypass caching when text tracks are specified. */ - DebugLog("Caching is not supported for uri '\(uri)' because text tracks are not compatible with the cache. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md") - playerItemForSourceUsingCache(uri: uri, assetOptions:assetOptions, withCallback:handler) + DebugLog("Caching is not supported for uri '\(source.uri)' because text tracks are not compatible with the cache. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md") return true } return false } - func playerItemForSourceUsingCache(uri:String!, assetOptions options:NSDictionary!, withCallback handler: @escaping (AVPlayerItem?)->Void) { + func playerItemForSourceUsingCache(uri:String!, assetOptions options:NSDictionary!) -> Promise { let url = URL(string: uri) - _videoCache.getItemForUri(uri, withCallback:{ [weak self] (videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?) in - guard let self = self else { return } + return getItemForUri(uri) + .then{ [weak self] (videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?) -> AVPlayerItem in + guard let self = self, let playerItemPrepareText = self.playerItemPrepareText else {throw NSError(domain: "", code: 0, userInfo: nil)} switch (videoCacheStatus) { case .missingFileExtension: DebugLog("Could not generate cache key for uri '\(uri)'. It is currently not supported to cache urls that do not include a file extension. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md") let asset:AVURLAsset! = AVURLAsset(url: url!, options:options as! [String : Any]) - self._playerItemPrepareText(asset, options, handler) - return + return playerItemPrepareText(asset, options) case .unsupportedFileExtension: DebugLog("Could not generate cache key for uri '\(uri)'. The file extension of that uri is currently not supported. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md") let asset:AVURLAsset! = AVURLAsset(url: url!, options:options as! [String : Any]) - self._playerItemPrepareText(asset, options, handler) - return + return playerItemPrepareText(asset, options) default: if let cachedAsset = cachedAsset { DebugLog("Playing back uri '\(uri)' from cache") // See note in playerItemForSource about not being able to support text tracks & caching - handler(AVPlayerItem(asset: cachedAsset)) - return + return AVPlayerItem(asset: cachedAsset) } } @@ -65,8 +63,16 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { asset?.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) */ - handler(AVPlayerItem(asset: asset)) - }) + return AVPlayerItem(asset: asset) + } + } + + func getItemForUri(_ uri:String) -> Promise<(videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?)> { + return Promise<(videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?)> { fulfill, reject in + self._videoCache.getItemForUri(uri, withCallback:{ (videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?) in + fulfill((videoCacheStatus, cachedAsset)) + }) + } } // MARK: - DVAssetLoaderDelegate diff --git a/react-native-video.podspec b/react-native-video.podspec index 5c3f5e5d..e33dcf9d 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -12,11 +12,12 @@ Pod::Spec.new do |s| s.homepage = 'https://github.com/react-native-community/react-native-video' s.source = { :git => "https://github.com/react-native-community/react-native-video.git", :tag => "#{s.version}" } - s.ios.deployment_target = "8.0" + s.ios.deployment_target = "9.0" s.tvos.deployment_target = "9.0" s.subspec "Video" do |ss| ss.source_files = "ios/Video/**/*.{h,m,swift}" + ss.dependency "PromisesSwift" end s.subspec "VideoCaching" do |ss|