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
This commit is contained in:
Nick Fujita 2021-10-28 10:34:05 +09:00 committed by GitHub
parent 8b75438148
commit e27baeb065
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 573 additions and 379 deletions

View File

@ -317,6 +317,7 @@ var styles = StyleSheet.create({
* [trackId](#trackId) * [trackId](#trackId)
* [useTextureView](#usetextureview) * [useTextureView](#usetextureview)
* [volume](#volume) * [volume](#volume)
* [localSourceEncryptionKeyScheme](#localSourceEncryptionKeyScheme)
### Event props ### Event props
* [onAudioBecomingNoisy](#onaudiobecomingnoisy) * [onAudioBecomingNoisy](#onaudiobecomingnoisy)
@ -915,6 +916,18 @@ Adjust the volume.
Platforms: all 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 ### Event props

View File

@ -418,6 +418,7 @@ Video.propTypes = {
certificateUrl: PropTypes.string, certificateUrl: PropTypes.string,
getLicense: PropTypes.func, getLicense: PropTypes.func,
}), }),
localSourceEncryptionKeyScheme: PropTypes.string,
minLoadRetryCount: PropTypes.number, minLoadRetryCount: PropTypes.number,
maxBitRate: PropTypes.number, maxBitRate: PropTypes.number,
resizeMode: PropTypes.string, resizeMode: PropTypes.string,

View File

@ -28,39 +28,43 @@ class RCTPlayerObserver: NSObject {
var _handlers: RCTPlayerObserverHandler! var _handlers: RCTPlayerObserverHandler!
var player:AVPlayer? { var player:AVPlayer? {
willSet {
removePlayerObservers()
removePlayerTimeObserver()
}
didSet { didSet {
if player == nil { if player != nil {
removePlayerObservers()
removePlayerTimeObserver()
} else {
addPlayerObservers() addPlayerObservers()
addPlayerTimeObserver() addPlayerTimeObserver()
} }
} }
} }
var playerItem:AVPlayerItem? { var playerItem:AVPlayerItem? {
willSet {
removePlayerItemObservers()
}
didSet { didSet {
if playerItem == nil { if playerItem != nil {
removePlayerItemObservers()
} else {
addPlayerItemObservers() addPlayerItemObservers()
} }
} }
} }
var playerViewController:AVPlayerViewController? { var playerViewController:AVPlayerViewController? {
willSet {
removePlayerViewControllerObservers()
}
didSet { didSet {
if playerViewController == nil { if playerViewController != nil {
removePlayerViewControllerObservers()
} else {
addPlayerViewControllerObservers() addPlayerViewControllerObservers()
} }
} }
} }
var playerLayer:AVPlayerLayer? { var playerLayer:AVPlayerLayer? {
willSet {
removePlayerLayerObserver()
}
didSet { didSet {
if playerLayer == nil { if playerLayer == nil {
removePlayerLayerObserver()
} else {
addPlayerLayerObserver() addPlayerLayerObserver()
} }
} }
@ -148,8 +152,8 @@ class RCTPlayerObserver: NSObject {
/* Cancels the previously registered time observer. */ /* Cancels the previously registered time observer. */
func removePlayerTimeObserver() { func removePlayerTimeObserver() {
if let timeObserver = _timeObserver { if _timeObserver != nil {
player?.removeTimeObserver(timeObserver) player?.removeTimeObserver(_timeObserver)
_timeObserver = nil _timeObserver = nil
} }
} }

View File

@ -1,5 +1,6 @@
import AVFoundation import AVFoundation
import MediaAccessibility import MediaAccessibility
import Promises
let RCTVideoUnset = -1 let RCTVideoUnset = -1
@ -157,4 +158,23 @@ enum RCTPlayerOperations {
} }
} }
static func seek(player: AVPlayer, playerItem:AVPlayerItem, paused:Bool, seekTime:Float, seekTolerance:Float) -> Promise<Bool> {
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<Bool>(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)
})
}
}
} }

View File

@ -1,4 +1,5 @@
import AVFoundation import AVFoundation
import Promises
class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate { class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate {
@ -6,6 +7,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
private var _requestingCertificate:Bool = false private var _requestingCertificate:Bool = false
private var _requestingCertificateErrored:Bool = false private var _requestingCertificateErrored:Bool = false
private var _drm: DRMParams? private var _drm: DRMParams?
private var _localSourceEncryptionKeyScheme: String?
private var _reactTag: NSNumber? private var _reactTag: NSNumber?
private var _onVideoError: RCTDirectEventBlock? private var _onVideoError: RCTDirectEventBlock?
private var _onGetLicense: RCTDirectEventBlock? private var _onGetLicense: RCTDirectEventBlock?
@ -14,6 +16,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
init( init(
asset: AVURLAsset, asset: AVURLAsset,
drm: DRMParams?, drm: DRMParams?,
localSourceEncryptionKeyScheme: String?,
onVideoError: RCTDirectEventBlock?, onVideoError: RCTDirectEventBlock?,
onGetLicense: RCTDirectEventBlock?, onGetLicense: RCTDirectEventBlock?,
reactTag: NSNumber reactTag: NSNumber
@ -25,6 +28,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
_onVideoError = onVideoError _onVideoError = onVideoError
_onGetLicense = onGetLicense _onGetLicense = onGetLicense
_drm = drm _drm = drm
_localSourceEncryptionKeyScheme = localSourceEncryptionKeyScheme
} }
deinit { deinit {
@ -42,16 +46,9 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
func resourceLoader(_ resourceLoader:AVAssetResourceLoader, didCancel loadingRequest:AVAssetResourceLoadingRequest) { func resourceLoader(_ resourceLoader:AVAssetResourceLoader, didCancel loadingRequest:AVAssetResourceLoadingRequest) {
NSLog("didCancelLoadingRequest") NSLog("didCancelLoadingRequest")
} }
func base64DataFromBase64String(base64String:String?) -> Data? {
if let base64String = base64String {
return Data(base64Encoded:base64String)
}
return nil
}
func setLicenseResult(_ license:String!) { func setLicenseResult(_ license:String!) {
guard let respondData = self.base64DataFromBase64String(base64String: license), guard let respondData = RCTVideoUtils.base64DataFromBase64String(base64String: license),
let _loadingRequest = _loadingRequest else { let _loadingRequest = _loadingRequest else {
setLicenseResultError("No data from JS license response") setLicenseResultError("No data from JS license response")
return 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 { if let _loadingRequest = _loadingRequest, let error = error {
let licenseError:NSError! = error _loadingRequest.finishLoading(with: error as! NSError)
_loadingRequest.finishLoading(with: licenseError)
_onVideoError?([ _onVideoError?([
"error": [ "error": [
"code": NSNumber(value: error.code), "code": NSNumber(value: (error as NSError).code),
"localizedDescription": error.localizedDescription == nil ? "" : error.localizedDescription, "localizedDescription": error.localizedDescription == nil ? "" : error.localizedDescription,
"localizedFailureReason": ((error as NSError).localizedFailureReason == nil ? "" : (error as NSError).localizedFailureReason) ?? "", "localizedFailureReason": ((error as NSError).localizedFailureReason == nil ? "" : (error as NSError).localizedFailureReason) ?? "",
"localizedRecoverySuggestion": ((error as NSError).localizedRecoverySuggestion == nil ? "" : (error as NSError).localizedRecoverySuggestion) ?? "", "localizedRecoverySuggestion": ((error as NSError).localizedRecoverySuggestion == nil ? "" : (error as NSError).localizedRecoverySuggestion) ?? "",
@ -88,6 +84,35 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
} }
func loadingRequestHandling(_ loadingRequest:AVAssetResourceLoadingRequest!) -> Bool { 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 { if _requestingCertificate {
return true return true
} else if _requestingCertificateErrored { } else if _requestingCertificateErrored {
@ -95,164 +120,48 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes
} }
_loadingRequest = loadingRequest _loadingRequest = loadingRequest
let url = loadingRequest.request.url guard let _drm = _drm, let drmType = _drm.type, drmType == "fairplay" else {
guard let _drm = _drm else {
return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData) return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData)
} }
var contentId:String! var promise: Promise<Data>
let contentIdOverride:String! = _drm.contentId if _onGetLicense != nil {
if contentIdOverride != nil { let contentId = _drm.contentId ?? loadingRequest.request.url?.host
contentId = contentIdOverride promise = RCTVideoDRM.handleWithOnGetLicense(
} else if (_onGetLicense != nil) { loadingRequest:loadingRequest,
contentId = url?.host contentId:contentId,
} else { certificateUrl:_drm.certificateUrl,
contentId = url?.absoluteString.replacingOccurrences(of: "skd://", with:"") base64Certificate:_drm.base64Certificate
} ) .then{ spcData -> Void in
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: [])
self._requestingCertificate = true self._requestingCertificate = true
if licenseServer == nil { self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "",
licenseServer = "" "contentId": contentId,
} "spcBase64": spcData.base64EncodedString(options: []),
_onGetLicense(["licenseUrl": licenseServer, "target": self._reactTag])
"contentId": contentId,
"spcBase64": base64Encoded,
"target": self._reactTag])
} else if licenseServer != nil {
self.fetchLicense(
licenseServer: licenseServer,
spcData: spcData,
contentId: contentId,
dataRequest: dataRequest
)
} }
} 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 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()
}
} }

View File

@ -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<Data> {
let request = createLicenseRequest(licenseServer:licenseServer, spcData:spcData, contentId:contentId, headers:headers)
return Promise<Data>(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<Data> {
return Promise<Data>(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<Data> {
return Promise<Data>(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<Data> {
let contentIdData = contentId?.data(using: .utf8)
return RCTVideoDRM.createCertificateData(certificateStringUrl:certificateUrl, base64Certificate:base64Certificate)
.then{ certificateData -> Promise<Data> 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<Data> {
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<Data> in
guard let licenseServer = licenseServer else {
throw RCTVideoError.noLicenseServerURL as! Error
}
return RCTVideoDRM.fetchLicense(
licenseServer: licenseServer,
spcData: spcData,
contentId: contentId,
headers: headers
)
}
}
}

View File

@ -1,5 +1,6 @@
enum RCTVideoError : Int { enum RCTVideoError : Int {
case fromJSPart case fromJSPart
case noLicenseServerURL
case licenseRequestNotOk case licenseRequestNotOk
case noDataFromLicenseRequest case noDataFromLicenseRequest
case noSPC case noSPC
@ -8,11 +9,12 @@ enum RCTVideoError : Int {
case noCertificateURL case noCertificateURL
case noFairplayDRM case noFairplayDRM
case noDRMData case noDRMData
case invalidContentId
} }
enum RCTVideoErrorHandler { enum RCTVideoErrorHandler {
static let noDRMData: NSError = NSError( static let noDRMData = NSError(
domain: "RCTVideo", domain: "RCTVideo",
code: RCTVideoError.noDRMData.rawValue, code: RCTVideoError.noDRMData.rawValue,
userInfo: [ userInfo: [
@ -21,7 +23,7 @@ enum RCTVideoErrorHandler {
NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?" NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?"
]) ])
static let noCertificateURL: NSError = NSError( static let noCertificateURL = NSError(
domain: "RCTVideo", domain: "RCTVideo",
code: RCTVideoError.noCertificateURL.rawValue, code: RCTVideoError.noCertificateURL.rawValue,
userInfo: [ userInfo: [
@ -30,7 +32,7 @@ enum RCTVideoErrorHandler {
NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?" NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?"
]) ])
static let noCertificateData: NSError = NSError( static let noCertificateData = NSError(
domain: "RCTVideo", domain: "RCTVideo",
code: RCTVideoError.noCertificateData.rawValue, code: RCTVideoError.noCertificateData.rawValue,
userInfo: [ userInfo: [
@ -39,7 +41,7 @@ enum RCTVideoErrorHandler {
NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?" NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?"
]) ])
static let noSPC:NSError! = NSError( static let noSPC = NSError(
domain: "RCTVideo", domain: "RCTVideo",
code: RCTVideoError.noSPC.rawValue, code: RCTVideoError.noSPC.rawValue,
userInfo: [ userInfo: [
@ -48,13 +50,22 @@ enum RCTVideoErrorHandler {
NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config." 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", domain: "RCTVideo",
code: RCTVideoError.noDataFromLicenseRequest.rawValue, code: RCTVideoError.noDataFromLicenseRequest.rawValue,
userInfo: [ userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM license.", NSLocalizedDescriptionKey: "Error obtaining DRM license.",
NSLocalizedFailureReasonErrorKey: "No data received from the license server.", NSLocalizedFailureReasonErrorKey: "No data received from the license server.",
NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?." NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?"
]) ])
static func licenseRequestNotOk(_ statusCode: Int) -> NSError { static func licenseRequestNotOk(_ statusCode: Int) -> NSError {
@ -80,4 +91,13 @@ enum RCTVideoErrorHandler {
NSLocalizedRecoverySuggestionErrorKey: error 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?"
])
} }

View File

@ -1,4 +1,5 @@
import AVFoundation import AVFoundation
import Promises
/*! /*!
* Collection of pure functions * Collection of pure functions
@ -140,4 +141,110 @@ enum RCTVideoUtils {
static func getCurrentTime(playerItem:AVPlayerItem?) -> Float { static func getCurrentTime(playerItem:AVPlayerItem?) -> Float {
return Float(CMTimeGetSeconds(playerItem?.currentTime() ?? .zero)) 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..<textTracks.count {
var textURLAsset:AVURLAsset!
let textUri:String = textTracks[i].uri
if textUri.lowercased().hasPrefix("http") {
textURLAsset = AVURLAsset(url: NSURL(string: textUri)! as URL, options:(assetOptions as! [String : Any]))
} else {
textURLAsset = AVURLAsset(url: RCTVideoUtils.urlFilePath(filepath: textUri as NSString?) as URL, options:nil)
}
let textTrackAsset:AVAssetTrack! = textURLAsset.tracks(withMediaType: AVMediaType.text).first
if (textTrackAsset == nil) {continue} // fix when there's no textTrackAsset
validTextTracks.append(textTracks[i])
let textCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.text,
preferredTrackID:kCMPersistentTrackID_Invalid)
do {
try textCompTrack.insertTimeRange(
CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration),
of: textTrackAsset,
at: .zero)
} catch {
}
}
}
return validTextTracks
}
static func delay(seconds: Int = 0) -> Promise<Void> {
return Promise<Void>(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)
}
} }

View File

@ -2,6 +2,7 @@ import AVFoundation
import AVKit import AVKit
import Foundation import Foundation
import React import React
import Promises
class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler { class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler {
@ -17,6 +18,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
/* DRM */ /* DRM */
private var _drm:DRMParams? private var _drm:DRMParams?
private var _localSourceEncryptionKeyScheme:String?
/* Required to publish events */ /* Required to publish events */
private var _eventDispatcher:RCTEventDispatcher? private var _eventDispatcher:RCTEventDispatcher?
private var _videoLoadStarted:Bool = false private var _videoLoadStarted:Bool = false
@ -61,7 +64,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _playerObserver: RCTPlayerObserver = RCTPlayerObserver() private var _playerObserver: RCTPlayerObserver = RCTPlayerObserver()
#if canImport(RCTVideoCache) #if canImport(RCTVideoCache)
private var _videoCache:RCTVideoCachingHandler = RCTVideoCachingHandler(self.playerItemPrepareText) private let _videoCache:RCTVideoCachingHandler = RCTVideoCachingHandler()
#endif #endif
#if TARGET_OS_IOS #if TARGET_OS_IOS
@ -125,6 +128,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
object: nil object: nil
) )
_playerObserver._handlers = self _playerObserver._handlers = self
#if canImport(RCTVideoCache)
_videoCache.playerItemPrepareText = playerItemPrepareText
#endif
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
@ -216,10 +222,39 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
removePlayerLayer() removePlayerLayer()
_playerObserver.player = nil _playerObserver.player = nil
_playerObserver.playerItem = 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
// perform on next run loop, otherwise other passed react-props may not be set RCTVideoUtils.delay()
self.playerItemForSource(withCallback:{ (playerItem:AVPlayerItem!) in .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._player?.pause()
self._playerItem = playerItem self._playerItem = playerItem
self._playerObserver.playerItem = self._playerItem self._playerObserver.playerItem = self._playerItem
@ -233,7 +268,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self._playerObserver.player = self._player self._playerObserver.player = self._player
self._player?.actionAtItemEnd = .none self._player?.actionAtItemEnd = .none
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling)
} }
@ -248,122 +283,38 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
"drm": self._drm?.json ?? NSNull(), "drm": self._drm?.json ?? NSNull(),
"target": self.reactTag "target": self.reactTag
]) ])
}.catch{_ in }
})
})
_videoLoadStarted = true _videoLoadStarted = true
} }
@objc @objc
func setDrm(_ drm:NSDictionary!) { func setDrm(_ drm:NSDictionary) {
_drm = DRMParams(drm) _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 { if (_textTracks == nil) || _textTracks?.count==0 {
handler(AVPlayerItem(asset: asset)) return AVPlayerItem(asset: asset)
return
} }
// AVPlayer can't airplay AVMutableCompositions // AVPlayer can't airplay AVMutableCompositions
_allowsExternalPlayback = false _allowsExternalPlayback = false
let mixComposition = RCTVideoUtils.generateMixComposition(asset)
// sideload text tracks let validTextTracks = RCTVideoUtils.getValidTextTracks(
let mixComposition:AVMutableComposition! = AVMutableComposition() asset:asset,
assetOptions:assetOptions,
let videoAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.video).first mixComposition:mixComposition,
let videoCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID:kCMPersistentTrackID_Invalid) textTracks:_textTracks)
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..<textTracks.count {
var textURLAsset:AVURLAsset!
let textUri:String = textTracks[i].uri
if textUri.lowercased().hasPrefix("http") {
textURLAsset = AVURLAsset(url: NSURL(string: textUri)! as URL, options:(assetOptions as! [String : Any]))
} else {
textURLAsset = AVURLAsset(url: RCTVideoUtils.urlFilePath(filepath: textUri as NSString?) as URL, options:nil)
}
let textTrackAsset:AVAssetTrack! = textURLAsset.tracks(withMediaType: AVMediaType.text).first
if (textTrackAsset == nil) {continue} // fix when there's no textTrackAsset
validTextTracks.append(textTracks[i])
let textCompTrack:AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.text,
preferredTrackID:kCMPersistentTrackID_Invalid)
do {
try textCompTrack.insertTimeRange(
CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration),
of: textTrackAsset,
at: .zero)
} catch {
}
}
}
if validTextTracks.count != _textTracks?.count { if validTextTracks.count != _textTracks?.count {
setTextTracks(validTextTracks) setTextTracks(validTextTracks)
} }
handler(AVPlayerItem(asset: mixComposition)) return AVPlayerItem(asset: mixComposition)
}
func playerItemForSource(withCallback handler:(AVPlayerItem?)->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)
} }
// MARK: - Prop setters // MARK: - Prop setters
@ -415,13 +366,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
@objc @objc
func setIgnoreSilentSwitch(_ ignoreSilentSwitch:String!) { func setIgnoreSilentSwitch(_ ignoreSilentSwitch:String?) {
_ignoreSilentSwitch = ignoreSilentSwitch _ignoreSilentSwitch = ignoreSilentSwitch
self.applyModifiers() self.applyModifiers()
} }
@objc @objc
func setMixWithOthers(_ mixWithOthers:String!) { func setMixWithOthers(_ mixWithOthers:String?) {
_mixWithOthers = mixWithOthers _mixWithOthers = mixWithOthers
self.applyModifiers() self.applyModifiers()
} }
@ -479,7 +430,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setCurrentTime(_ currentTime:Float) { func setCurrentTime(_ currentTime:Float) {
let info:NSDictionary! = [ let info:NSDictionary = [
"time": NSNumber(value: currentTime), "time": NSNumber(value: currentTime),
"tolerance": NSNumber(value: 100) "tolerance": NSNumber(value: 100)
] ]
@ -490,37 +441,31 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func setSeek(_ info:NSDictionary!) { func setSeek(_ info:NSDictionary!) {
let seekTime:NSNumber! = info["time"] as! NSNumber let seekTime:NSNumber! = info["time"] as! NSNumber
let seekTolerance:NSNumber! = info["tolerance"] as! NSNumber let seekTolerance:NSNumber! = info["tolerance"] as! NSNumber
let item:AVPlayerItem? = _player?.currentItem
let timeScale:Int = 1000 guard item != nil, let player = _player, let item = item, item.status == AVPlayerItem.Status.readyToPlay else {
let item:AVPlayerItem! = _player?.currentItem
guard item != nil && item.status == AVPlayerItem.Status.readyToPlay else {
_pendingSeek = true _pendingSeek = true
_pendingSeekTime = seekTime.floatValue _pendingSeekTime = seekTime.floatValue
return return
} }
let wasPaused = _paused
// TODO check loadedTimeRanges RCTPlayerOperations.seek(
let cmSeekTime:CMTime = CMTimeMakeWithSeconds(Float64(seekTime.floatValue), preferredTimescale: Int32(timeScale)) player:player,
let current:CMTime = item.currentTime() playerItem:item,
// TODO figure out a good tolerance level paused:wasPaused,
let tolerance:CMTime = CMTimeMake(value: Int64(seekTolerance.floatValue), timescale: Int32(timeScale)) seekTime:seekTime.floatValue,
let wasPaused:Bool = _paused seekTolerance:seekTolerance.floatValue)
.then{ [weak self] (finished:Bool) in
guard CMTimeCompare(current, cmSeekTime) != 0 else { return } guard let self = self else { return }
if !wasPaused { _player?.pause() }
self._playerObserver.addTimeObserverIfNotSet()
_player?.seek(to: cmSeekTime, toleranceBefore:tolerance, toleranceAfter:tolerance, completionHandler:{ [weak self] (finished:Bool) in if !wasPaused {
guard let self = self else { return } self.setPaused(false)
}
self._playerObserver.addTimeObserverIfNotSet() self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))),
if !wasPaused { "seekTime": seekTime,
self.setPaused(false) "target": self.reactTag])
} }.catch{_ in }
self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))),
"seekTime": seekTime,
"target": self.reactTag])
})
_pendingSeek = false _pendingSeek = false
} }
@ -605,46 +550,46 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_repeat = `repeat` _repeat = `repeat`
} }
@objc @objc
func setSelectedAudioTrack(_ selectedAudioTrack:NSDictionary!) { func setSelectedAudioTrack(_ selectedAudioTrack:NSDictionary?) {
setSelectedAudioTrack(SelectedTrackCriteria(selectedAudioTrack)) setSelectedAudioTrack(SelectedTrackCriteria(selectedAudioTrack))
} }
func setSelectedAudioTrack(_ selectedAudioTrack:SelectedTrackCriteria!) { func setSelectedAudioTrack(_ selectedAudioTrack:SelectedTrackCriteria?) {
_selectedAudioTrackCriteria = selectedAudioTrack _selectedAudioTrackCriteria = selectedAudioTrack
RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.audible, RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.audible,
criteria:_selectedAudioTrackCriteria) criteria:_selectedAudioTrackCriteria)
} }
@objc @objc
func setSelectedTextTrack(_ selectedTextTrack:NSDictionary!) { func setSelectedTextTrack(_ selectedTextTrack:NSDictionary?) {
setSelectedTextTrack(SelectedTrackCriteria(selectedTextTrack)) setSelectedTextTrack(SelectedTrackCriteria(selectedTextTrack))
} }
func setSelectedTextTrack(_ selectedTextTrack:SelectedTrackCriteria!) { func setSelectedTextTrack(_ selectedTextTrack:SelectedTrackCriteria?) {
_selectedTextTrackCriteria = selectedTextTrack _selectedTextTrackCriteria = selectedTextTrack
if (_textTracks != nil) { // sideloaded text tracks if (_textTracks != nil) { // sideloaded text tracks
RCTPlayerOperations.setSideloadedText(player:_player, textTracks:_textTracks, criteria:_selectedTextTrackCriteria) RCTPlayerOperations.setSideloadedText(player:_player, textTracks:_textTracks, criteria:_selectedTextTrackCriteria)
} else { // text tracks included in the HLS playlist } else { // text tracks included in the HLS playlist
RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.legible, RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.legible,
criteria:_selectedTextTrackCriteria) criteria:_selectedTextTrackCriteria)
} }
} }
@objc @objc
func setTextTracks(_ textTracks:[NSDictionary]!) { func setTextTracks(_ textTracks:[NSDictionary]?) {
setTextTracks(textTracks.map { TextTrack($0) }) setTextTracks(textTracks?.map { TextTrack($0) })
} }
func setTextTracks(_ textTracks:[TextTrack]!) { func setTextTracks(_ textTracks:[TextTrack]?) {
_textTracks = textTracks _textTracks = textTracks
// in case textTracks was set after selectedTextTrack // in case textTracks was set after selectedTextTrack
if (_selectedTextTrackCriteria != nil) {setSelectedTextTrack(_selectedTextTrackCriteria)} if (_selectedTextTrackCriteria != nil) {setSelectedTextTrack(_selectedTextTrackCriteria)}
} }
@objc @objc
func setFullscreen(_ fullscreen:Bool) { func setFullscreen(_ fullscreen:Bool) {
if fullscreen && !_fullscreenPlayerPresented && _player != nil { if fullscreen && !_fullscreenPlayerPresented && _player != nil {
@ -697,7 +642,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
@objc @objc
func setFullscreenOrientation(_ orientation:String!) { func setFullscreenOrientation(_ orientation:String?) {
_fullscreenOrientation = orientation _fullscreenOrientation = orientation
if _fullscreenPlayerPresented { if _fullscreenPlayerPresented {
_playerViewController?.preferredOrientation = orientation _playerViewController?.preferredOrientation = orientation
@ -705,18 +650,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
func usePlayerViewController() { func usePlayerViewController() {
guard _player != nil else { return } guard let _player = _player, let _playerItem = _playerItem else { return }
if _playerViewController == nil { 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' // to prevent video from being animated when resizeMode is 'cover'
// resize mode must be set before subview is added // resize mode must be set before subview is added
setResizeMode(_resizeMode) setResizeMode(_resizeMode)
guard let _playerViewController = _playerViewController else { return } guard let _playerViewController = _playerViewController else { return }
if _controls { if _controls {
let viewController:UIViewController! = self.reactViewController() let viewController:UIViewController! = self.reactViewController()
viewController.addChild(_playerViewController) viewController.addChild(_playerViewController)
@ -726,8 +670,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_playerObserver.playerViewController = _playerViewController _playerObserver.playerViewController = _playerViewController
} }
func createPlayerViewController(player:AVPlayer!, withPlayerItem playerItem:AVPlayerItem!) -> RCTVideoPlayerViewController! { func createPlayerViewController(player:AVPlayer, withPlayerItem playerItem:AVPlayerItem) -> RCTVideoPlayerViewController {
let viewController:RCTVideoPlayerViewController! = RCTVideoPlayerViewController() let viewController = RCTVideoPlayerViewController()
viewController.showsPlaybackControls = true viewController.showsPlaybackControls = true
viewController.rctDelegate = self viewController.rctDelegate = self
viewController.preferredOrientation = _fullscreenOrientation viewController.preferredOrientation = _fullscreenOrientation
@ -953,20 +897,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
return return
} }
var metadata: [[String:String?]?] = [] var metadata: [[String:String?]?] = []
for item in _items { for item in _items {
let value = item.value as? String let value = item.value as? String
let identifier = item.identifier?.rawValue let identifier = item.identifier?.rawValue
if let value = value { if let value = value {
metadata.append(["value":value, "identifier":identifier]) metadata.append(["value":value, "identifier":identifier])
} }
} }
onTimedMetadata?([ onTimedMetadata?([
"target": reactTag, "target": reactTag,
"metadata": metadata "metadata": metadata
]) ])
} }
// Handle player item status change. // Handle player item status change.
@ -1134,14 +1078,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
//unused //unused
// @objc func handleAVPlayerAccess(notification:NSNotification!) { // @objc func handleAVPlayerAccess(notification:NSNotification!) {
// let accessLog:AVPlayerItemAccessLog! = (notification.object as! AVPlayerItem).accessLog() // let accessLog:AVPlayerItemAccessLog! = (notification.object as! AVPlayerItem).accessLog()
// let lastEvent:AVPlayerItemAccessLogEvent! = accessLog.events.last // let lastEvent:AVPlayerItemAccessLogEvent! = accessLog.events.last
// //
// /* TODO: get this working // /* TODO: get this working
// if (self.onBandwidthUpdate) { // if (self.onBandwidthUpdate) {
// self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]}); // self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]});
// } // }
// */ // */
// } // }
} }

View File

@ -34,6 +34,7 @@ RCT_EXPORT_VIEW_PROPERTY(filter, NSString);
RCT_EXPORT_VIEW_PROPERTY(filterEnabled, BOOL); RCT_EXPORT_VIEW_PROPERTY(filterEnabled, BOOL);
RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float); RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float);
RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL); RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL);
RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString);
/* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */ /* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */
RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock);

View File

@ -1,52 +1,50 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import DVAssetLoaderDelegate import DVAssetLoaderDelegate
import Promises
class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
private var _videoCache:RCTVideoCache! = RCTVideoCache.sharedInstance() 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) { override init() {
_playerItemPrepareText = playerItemPrepareText super.init()
} }
func playerItemForSourceUsingCache(shouldCache:Bool, textTracks:[AnyObject]?, uri:String, assetOptions:NSMutableDictionary, handler:@escaping (AVPlayerItem?)->Void) -> Bool { func shouldCache(source: VideoSource, textTracks:[TextTrack]?) -> Bool {
if shouldCache && ((textTracks == nil) || (textTracks!.count == 0)) { if source.isNetwork && source.shouldCache && ((textTracks == nil) || (textTracks!.count == 0)) {
/* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying /* 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. * 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. * 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") 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")
playerItemForSourceUsingCache(uri: uri, assetOptions:assetOptions, withCallback:handler)
return true return true
} }
return false return false
} }
func playerItemForSourceUsingCache(uri:String!, assetOptions options:NSDictionary!, withCallback handler: @escaping (AVPlayerItem?)->Void) { func playerItemForSourceUsingCache(uri:String!, assetOptions options:NSDictionary!) -> Promise<AVPlayerItem?> {
let url = URL(string: uri) let url = URL(string: uri)
_videoCache.getItemForUri(uri, withCallback:{ [weak self] (videoCacheStatus:RCTVideoCacheStatus,cachedAsset:AVAsset?) in return getItemForUri(uri)
guard let self = self else { return } .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) { switch (videoCacheStatus) {
case .missingFileExtension: 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") 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]) let asset:AVURLAsset! = AVURLAsset(url: url!, options:options as! [String : Any])
self._playerItemPrepareText(asset, options, handler) return playerItemPrepareText(asset, options)
return
case .unsupportedFileExtension: 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") 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]) let asset:AVURLAsset! = AVURLAsset(url: url!, options:options as! [String : Any])
self._playerItemPrepareText(asset, options, handler) return playerItemPrepareText(asset, options)
return
default: default:
if let cachedAsset = cachedAsset { if let cachedAsset = cachedAsset {
DebugLog("Playing back uri '\(uri)' from cache") DebugLog("Playing back uri '\(uri)' from cache")
// See note in playerItemForSource about not being able to support text tracks & caching // See note in playerItemForSource about not being able to support text tracks & caching
handler(AVPlayerItem(asset: cachedAsset)) return AVPlayerItem(asset: cachedAsset)
return
} }
} }
@ -65,8 +63,16 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
asset?.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) 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 // MARK: - DVAssetLoaderDelegate

View File

@ -12,11 +12,12 @@ Pod::Spec.new do |s|
s.homepage = 'https://github.com/react-native-community/react-native-video' 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.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.tvos.deployment_target = "9.0"
s.subspec "Video" do |ss| s.subspec "Video" do |ss|
ss.source_files = "ios/Video/**/*.{h,m,swift}" ss.source_files = "ios/Video/**/*.{h,m,swift}"
ss.dependency "PromisesSwift"
end end
s.subspec "VideoCaching" do |ss| s.subspec "VideoCaching" do |ss|