diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f586a3ee..3cb24acbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Changelog +### next +- Basic support for DRM on iOS and Android [#2115](https://github.com/react-native-community/react-native-video/pull/2115) + ### Version 5.1.0-alpha6 [WIP] - Fix iOS bug which would break size of views when video is displayed with controls on a non full-screen React view. [#1931](https://github.com/react-native-community/react-native-video/pull/1931) diff --git a/DRM.md b/DRM.md new file mode 100644 index 0000000000..fe697cf496 --- /dev/null +++ b/DRM.md @@ -0,0 +1,139 @@ +# DRM + +## Provide DRM data (only tested with http/https assets) + +You can provide some configuration to allow DRM playback. +This feature will disable the use of `TextureView` on Android. + +DRM object allows this members: + +| Property | Type | Default | Platform | Description | +| --- | --- | --- | --- | --- | +| [`type`](#type) | DRMType | undefined | iOS/Android | Specifies which type of DRM you are going to use, DRMType is an enum exposed on the JS module ('fairplay', 'playready', ...) | +| [`licenseServer`](#licenseserver) | string | undefined | iOS/Android | Specifies the license server URL | +| [`headers`](#headers) | Object | undefined | iOS/Android | Specifies the headers send to the license server URL on license acquisition | +| [`contentId`](#contentid) | string | undefined | iOS | Specify the content id of the stream, otherwise it will take the host value from `loadingRequest.request.URL.host` (f.e: `skd://testAsset` -> will take `testAsset`) | +| [`certificateUrl`](#certificateurl) | string | undefined | iOS | Specifies the url to obtain your ios certificate for fairplay, Url to the .cer file | +| [`base64Certificate`](#base64certificate) | bool | false | iOS | Specifies whether or not the certificate returned by the `certificateUrl` is on base64 | +| [`getLicense`](#getlicense)| function | undefined | iOS | Rather than setting the `licenseServer` url to get the license, you can manually get the license on the JS part, and send the result to the native part to configure FairplayDRM for the stream | + +### `base64Certificate` + +Whether or not the certificate url returns it on base64. + +Platforms: iOS + +### `certificateUrl` + +URL to fetch a valid certificate for FairPlay. + +Platforms: iOS + +### `getLicense` + +`licenseServer` and `headers` will be ignored. You will obtain as argument the `SPC` (as ASCII string, you will probably need to convert it to base 64) obtained from your `contentId` + the provided certificate via `[loadingRequest streamingContentKeyRequestDataForApp:certificateData contentIdentifier:contentIdData options:nil error:&spcError];`. + You should return on this method a `CKC` in Base64, either by just returning it or returning a `Promise` that resolves with the `CKC`. + +With this prop you can override the license acquisition flow, as an example: + +```js +getLicense: (spcString) => { + const base64spc = Base64.encode(spcString); + const formData = new FormData(); + formData.append('spc', base64spc); + return fetch(`https://license.pallycon.com/ri/licenseManager.do`, { + method: 'POST', + headers: { + 'pallycon-customdata-v2': 'd2VpcmRiYXNlNjRzdHJpbmcgOlAgRGFuaWVsIE1hcmnxbyB3YXMgaGVyZQ==', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData + }).then(response => response.text()).then((response) => { + return response; + }).catch((error) => { + console.error('Error', error); + }); +} +``` + +Platforms: iOS + +### `headers` + +You can customize headers send to the licenseServer. + +Example: + +```js +source={{ + uri: 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p.mpd', +}} +drm={{ + type: DRMType.WIDEVINE, + licenseServer: 'https://drm-widevine-licensing.axtest.net/AcquireLicense', + headers: { + 'X-AxDRM-Message': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXJzaW9uIjoxLCJjb21fa2V5X2lkIjoiYjMzNjRlYjUtNTFmNi00YWUzLThjOTgtMzNjZWQ1ZTMxYzc4IiwibWVzc2FnZSI6eyJ0eXBlIjoiZW50aXRsZW1lbnRfbWVzc2FnZSIsImZpcnN0X3BsYXlfZXhwaXJhdGlvbiI6NjAsInBsYXlyZWFkeSI6eyJyZWFsX3RpbWVfZXhwaXJhdGlvbiI6dHJ1ZX0sImtleXMiOlt7ImlkIjoiOWViNDA1MGQtZTQ0Yi00ODAyLTkzMmUtMjdkNzUwODNlMjY2IiwiZW5jcnlwdGVkX2tleSI6ImxLM09qSExZVzI0Y3Iya3RSNzRmbnc9PSJ9XX19.FAbIiPxX8BHi9RwfzD7Yn-wugU19ghrkBFKsaCPrZmU' + }, +}} +``` + +### `licenseServer` + +The URL pointing to the licenseServer that will provide the authorization to play the protected stream. + +### `type` + +You can specify the DRM type, either by string or using the exported DRMType enum. +Valid values are, for Android: DRMType.WIDEVINE / DRMType.PLAYREADY / DRMType.CLEARKEY. +for iOS: DRMType.FAIRPLAY + +## Common Usage Scenarios + +### Send cookies to license server + +You can send Cookies to the license server via `headers` prop. Example: + +```js +drm: { + type: DRMType.WIDEVINE + licenseServer: 'https://drm-widevine-licensing.axtest.net/AcquireLicense', + headers: { + 'Cookie': 'PHPSESSID=etcetc; csrftoken=mytoken; _gat=1; foo=bar' + }, +} +``` + +### Custom License Acquisition (only iOS for now) + +```js +drm: { + type: DRMType.FAIRPLAY, + getLicense: (spcString) => { + const base64spc = Base64.encode(spcString); + return fetch('YOUR LICENSE SERVER HERE', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + getFairplayLicense: { + foo: 'bar', + spcMessage: base64spc, + } + }) + }) + .then(response => response.json()) + .then((response) => { + if (response && response.getFairplayLicenseResponse + && response.getFairplayLicenseResponse.ckcResponse) { + return response.getFairplayLicenseResponse.ckcResponse; + } + throw new Error('No correct response'); + }) + .catch((error) => { + console.error('CKC error', error); + }); + } +} +``` \ No newline at end of file diff --git a/DRMType.js b/DRMType.js new file mode 100644 index 0000000000..473536b249 --- /dev/null +++ b/DRMType.js @@ -0,0 +1,6 @@ +export default { + WIDEVINE: 'widevine', + PLAYREADY: 'playready', + CLEARKEY: 'clearkey', + FAIRPLAY: 'fairplay' +}; diff --git a/README.md b/README.md index 00fc79321c..644185c011 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,11 @@ Determines whether video audio should override background music/audio in Android Platforms: Android Exoplayer +### DRM +To setup DRM please follow [this guide](./DRM.md) + +Platforms: Android Exoplayer, iOS + #### filter Add video filter * **FilterType.NONE (default)** - No Filter @@ -799,6 +804,17 @@ Note: Using this feature adding an entry for NSAppleMusicUsageDescription to you Platforms: iOS +##### Explicit mimetype for the stream + +Provide a member `type` with value (`mpd`/`m3u8`/`ism`) inside the source object. +Sometimes is needed when URL extension does not match with the mimetype that you are expecting, as seen on the next example. (Extension is .ism -smooth streaming- but file served is on format mpd -mpeg dash-) + +Example: +``` +source={{ uri: 'http://host-serving-a-type-different-than-the-extension.ism/manifest(format=mpd-time-csf)', +type: 'mpd' }} +``` + ###### Other protocols The following other types are supported on some platforms, but aren't fully documented yet: diff --git a/Video.js b/Video.js index 450a77969f..b703ce6f52 100644 --- a/Video.js +++ b/Video.js @@ -4,6 +4,7 @@ import { StyleSheet, requireNativeComponent, NativeModules, View, ViewPropTypes, import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; import TextTrackType from './TextTrackType'; import FilterType from './FilterType'; +import DRMType from './DRMType'; import VideoResizeMode from './VideoResizeMode.js'; const styles = StyleSheet.create({ @@ -12,7 +13,7 @@ const styles = StyleSheet.create({ }, }); -export { TextTrackType, FilterType }; +export { TextTrackType, FilterType, DRMType }; export default class Video extends Component { @@ -232,6 +233,27 @@ export default class Video extends Component { } }; + _onGetLicense = (event) => { + if (this.props.drm && this.props.drm.getLicense instanceof Function) { + const data = event.nativeEvent; + if (data && data.spc) { + const getLicenseOverride = this.props.drm.getLicense(data.spc, data.contentId, data.spcBase64, this.props); + const getLicensePromise = Promise.resolve(getLicenseOverride); // Handles both scenarios, getLicenseOverride being a promise and not. + getLicensePromise.then((result => { + if (result !== undefined) { + NativeModules.VideoManager.setLicenseResult(result, findNodeHandle(this._root)); + } else { + NativeModules.VideoManager.setLicenseError && NativeModules.VideoManager.setLicenseError('Empty license result', findNodeHandle(this._root)); + } + })).catch((error) => { + NativeModules.VideoManager.setLicenseError && NativeModules.VideoManager.setLicenseError(error, findNodeHandle(this._root)); + }); + } else { + NativeModules.VideoManager.setLicenseError && NativeModules.VideoManager.setLicenseError("No spc received", findNodeHandle(this._root)); + } + } + } + getViewManagerConfig = viewManagerName => { if (!NativeModules.UIManager.getViewManagerConfig) { return NativeModules.UIManager[viewManagerName]; @@ -304,6 +326,7 @@ export default class Video extends Component { onPlaybackRateChange: this._onPlaybackRateChange, onAudioFocusChanged: this._onAudioFocusChanged, onAudioBecomingNoisy: this._onAudioBecomingNoisy, + onGetLicense: nativeProps.drm && nativeProps.drm.getLicense && this._onGetLicense, onPictureInPictureStatusChanged: this._onPictureInPictureStatusChanged, onRestoreUserInterfaceForPictureInPictureStop: this._onRestoreUserInterfaceForPictureInPictureStop, }); @@ -379,6 +402,16 @@ Video.propTypes = { // Opaque type returned by require('./video.mp4') PropTypes.number, ]), + drm: PropTypes.shape({ + type: PropTypes.oneOf([ + DRMType.CLEARKEY, DRMType.FAIRPLAY, DRMType.WIDEVINE, DRMType.PLAYREADY + ]), + licenseServer: PropTypes.string, + headers: PropTypes.shape({}), + base64Certificate: PropTypes.bool, + certificateUrl: PropTypes.string, + getLicense: PropTypes.func, + }), minLoadRetryCount: PropTypes.number, maxBitRate: PropTypes.number, resizeMode: PropTypes.string, diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.java index 487efeb0ba..19dda002d6 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.java @@ -22,6 +22,7 @@ private DataSourceUtil() { private static DataSource.Factory rawDataSourceFactory = null; private static DataSource.Factory defaultDataSourceFactory = null; + private static HttpDataSource.Factory defaultHttpDataSourceFactory = null; private static String userAgent = null; public static void setUserAgent(String userAgent) { @@ -58,6 +59,17 @@ public static void setDefaultDataSourceFactory(DataSource.Factory factory) { DataSourceUtil.defaultDataSourceFactory = factory; } + public static HttpDataSource.Factory getDefaultHttpDataSourceFactory(ReactContext context, DefaultBandwidthMeter bandwidthMeter, Map requestHeaders) { + if (defaultHttpDataSourceFactory == null || (requestHeaders != null && !requestHeaders.isEmpty())) { + defaultHttpDataSourceFactory = buildHttpDataSourceFactory(context, bandwidthMeter, requestHeaders); + } + return defaultHttpDataSourceFactory; + } + + public static void setDefaultHttpDataSourceFactory(HttpDataSource.Factory factory) { + DataSourceUtil.defaultHttpDataSourceFactory = factory; + } + private static DataSource.Factory buildRawDataSourceFactory(ReactContext context) { return new RawResourceDataSourceFactory(context.getApplicationContext()); } diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 4e6fea586d..5668e54e3d 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -36,6 +36,13 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import com.google.android.exoplayer2.drm.DefaultDrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.drm.FrameworkMediaDrm; +import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; +import com.google.android.exoplayer2.drm.UnsupportedDrmException; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.metadata.Metadata; @@ -70,6 +77,7 @@ import java.net.CookiePolicy; import java.util.ArrayList; import java.util.Locale; +import java.util.UUID; import java.util.Map; @SuppressLint("ViewConstructor") @@ -79,7 +87,8 @@ class ReactExoplayerView extends FrameLayout implements BandwidthMeter.EventListener, BecomingNoisyListener, AudioManager.OnAudioFocusChangeListener, - MetadataOutput { + MetadataOutput, + DefaultDrmSessionEventListener { private static final String TAG = "ReactExoplayerView"; @@ -124,6 +133,8 @@ class ReactExoplayerView extends FrameLayout implements private int bufferForPlaybackMs = DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; private int bufferForPlaybackAfterRebufferMs = DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; + private Handler mainHandler; + // Props from React private Uri srcUri; private String extension; @@ -141,6 +152,9 @@ class ReactExoplayerView extends FrameLayout implements private boolean playInBackground = false; private Map requestHeaders; private boolean mReportBandwidth = false; + private UUID drmUUID = null; + private String drmLicenseUrl = null; + private String[] drmLicenseHeader = null; private boolean controls; // \ End props @@ -189,8 +203,6 @@ public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig confi audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); themedReactContext.addLifecycleEventListener(this); audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); - - initializePlayer(); } @@ -214,6 +226,8 @@ private void createViews() { exoPlayerView.setLayoutParams(layoutParams); addView(exoPlayerView, 0, layoutParams); + + mainHandler = new Handler(); } @Override @@ -395,9 +409,23 @@ public void run() { DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(getContext()) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF); - // TODO: Add drmSessionManager to 5th param from: https://github.com/react-native-community/react-native-video/pull/1445 + // DRM + DrmSessionManager drmSessionManager = null; + if (self.drmUUID != null) { + try { + drmSessionManager = buildDrmSessionManager(self.drmUUID, self.drmLicenseUrl, + self.drmLicenseHeader); + } catch (UnsupportedDrmException e) { + int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported + : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME + ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown); + eventEmitter.error(getResources().getString(errorStringId), e); + return; + } + } + // End DRM player = ExoPlayerFactory.newSimpleInstance(getContext(), renderersFactory, - trackSelector, defaultLoadControl, null, bandwidthMeter); + trackSelector, defaultLoadControl, drmSessionManager, bandwidthMeter); player.addListener(self); player.addMetadataOutput(self); exoPlayerView.setPlayer(player); @@ -444,6 +472,23 @@ public void run() { }, 1); } + private DrmSessionManager buildDrmSessionManager(UUID uuid, + String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException { + if (Util.SDK_INT < 18) { + return null; + } + HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(licenseUrl, + buildHttpDataSourceFactory(false)); + if (keyRequestPropertiesArray != null) { + for (int i = 0; i < keyRequestPropertiesArray.length - 1; i += 2) { + drmCallback.setKeyRequestProperty(keyRequestPropertiesArray[i], + keyRequestPropertiesArray[i + 1]); + } + } + return new DefaultDrmSessionManager<>(uuid, + FrameworkMediaDrm.newInstance(uuid), drmCallback, null, false, 3); + } + private MediaSource buildMediaSource(Uri uri, String overrideExtension) { int type = Util.inferContentType(!TextUtils.isEmpty(overrideExtension) ? "." + overrideExtension : uri.getLastPathSegment()); @@ -615,6 +660,17 @@ private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) { useBandwidthMeter ? bandwidthMeter : null, requestHeaders); } + /** + * Returns a new HttpDataSource factory. + * + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * DataSource factory. + * @return A new HttpDataSource factory. + */ + private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { + return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, requestHeaders); + } + // AudioManager.OnAudioFocusChangeListener implementation @Override @@ -924,10 +980,12 @@ private static boolean isBehindLiveWindow(ExoPlaybackException e) { } public int getTrackRendererIndex(int trackType) { - int rendererCount = player.getRendererCount(); - for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { - if (player.getRendererType(rendererIndex) == trackType) { - return rendererIndex; + if (player != null) { + int rendererCount = player.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (player.getRendererType(rendererIndex) == trackType) { + return rendererIndex; + } } } return C.INDEX_UNSET; @@ -1246,7 +1304,8 @@ public void setFullscreen(boolean fullscreen) { } public void setUseTextureView(boolean useTextureView) { - exoPlayerView.setUseTextureView(useTextureView); + boolean finallyUseTextureView = useTextureView && this.drmUUID == null; + exoPlayerView.setUseTextureView(finallyUseTextureView); } public void setHideShutterView(boolean hideShutterView) { @@ -1262,6 +1321,40 @@ public void setBufferConfig(int newMinBufferMs, int newMaxBufferMs, int newBuffe initializePlayer(); } + public void setDrmType(UUID drmType) { + this.drmUUID = drmType; + } + + public void setDrmLicenseUrl(String licenseUrl){ + this.drmLicenseUrl = licenseUrl; + } + + public void setDrmLicenseHeader(String[] header){ + this.drmLicenseHeader = header; + } + + + @Override + public void onDrmKeysLoaded() { + Log.d("DRM Info", "onDrmKeysLoaded"); + } + + @Override + public void onDrmSessionManagerError(Exception e) { + Log.d("DRM Info", "onDrmSessionManagerError"); + eventEmitter.error("onDrmSessionManagerError", e); + } + + @Override + public void onDrmKeysRestored() { + Log.d("DRM Info", "onDrmKeysRestored"); + } + + @Override + public void onDrmKeysRemoved() { + Log.d("DRM Info", "onDrmKeysRemoved"); + } + /** * Handling controls prop * diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java index cf50fdaecd..656669d203 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java @@ -3,19 +3,25 @@ import android.content.Context; import android.net.Uri; import android.text.TextUtils; +import android.util.Log; import com.facebook.react.bridge.Dynamic; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.ViewGroupManager; import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.bridge.ReactMethod; +import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.upstream.RawResourceDataSource; import java.util.HashMap; +import java.util.ArrayList; import java.util.Map; +import java.util.UUID; import javax.annotation.Nullable; @@ -26,6 +32,10 @@ public class ReactExoplayerViewManager extends ViewGroupManager drmKeyRequestPropertiesList = new ArrayList<>(); + ReadableMapKeySetIterator itr = drmHeaders.keySetIterator(); + while (itr.hasNextKey()) { + String key = itr.nextKey(); + drmKeyRequestPropertiesList.add(key); + drmKeyRequestPropertiesList.add(drmHeaders.getString(key)); + } + videoView.setDrmLicenseHeader(drmKeyRequestPropertiesList.toArray(new String[0])); + } + videoView.setUseTextureView(false); + } + } + } + @ReactProp(name = PROP_SRC) public void setSrc(final ReactExoplayerView videoView, @Nullable ReadableMap src) { Context context = videoView.getContext().getApplicationContext(); diff --git a/android-exoplayer/src/main/res/values/strings.xml b/android-exoplayer/src/main/res/values/strings.xml index 4f69ec34a6..57d9cf1101 100644 --- a/android-exoplayer/src/main/res/values/strings.xml +++ b/android-exoplayer/src/main/res/values/strings.xml @@ -9,6 +9,12 @@ Unable to instantiate decoder %1$s + Protected content not supported on API levels below 18 + Unrecognized media format + This device does not support the required DRM scheme + + An unknown DRM error occurred + diff --git a/android/build.gradle b/android/build.gradle index 2fb8dfd2ac..9229149007 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,6 +8,7 @@ android { compileSdkVersion safeExtGet('compileSdkVersion', 28) buildToolsVersion safeExtGet('buildToolsVersion', '28.0.3') + defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 16) targetSdkVersion safeExtGet('targetSdkVersion', 28) diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index 26d436c2e1..6fee2996f5 100644 --- a/ios/Video/RCTVideo.h +++ b/ios/Video/RCTVideo.h @@ -14,11 +14,11 @@ @class RCTEventDispatcher; #if __has_include() -@interface RCTVideo : UIView +@interface RCTVideo : UIView #elif TARGET_OS_TV -@interface RCTVideo : UIView +@interface RCTVideo : UIView #else -@interface RCTVideo : UIView +@interface RCTVideo : UIView #endif @property (nonatomic, copy) RCTDirectEventBlock onVideoLoadStart; @@ -42,11 +42,26 @@ @property (nonatomic, copy) RCTDirectEventBlock onVideoExternalPlaybackChange; @property (nonatomic, copy) RCTDirectEventBlock onPictureInPictureStatusChanged; @property (nonatomic, copy) RCTDirectEventBlock onRestoreUserInterfaceForPictureInPictureStop; +@property (nonatomic, copy) RCTDirectEventBlock onGetLicense; + +typedef NS_ENUM(NSInteger, RCTVideoError) { + RCTVideoErrorFromJSPart, + RCTVideoErrorLicenseRequestNotOk, + RCTVideoErrorNoDataFromLicenseRequest, + RCTVideoErrorNoSPC, + RCTVideoErrorNoDataRequest, + RCTVideoErrorNoCertificateData, + RCTVideoErrorNoCertificateURL, + RCTVideoErrorNoFairplayDRM, + RCTVideoErrorNoDRMData +}; - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER; - (AVPlayerViewController*)createPlayerViewController:(AVPlayer*)player withPlayerItem:(AVPlayerItem*)playerItem; - (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)setLicenseResult:(NSString * )license; +- (BOOL)setLicenseResultError:(NSString * )error; @end diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m index 01e1b1c6ed..ca30e76e30 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -33,6 +33,12 @@ @implementation RCTVideo BOOL _playerLayerObserverSet; RCTVideoPlayerViewController *_playerViewController; NSURL *_videoURL; + BOOL _requestingCertificate; + BOOL _requestingCertificateErrored; + + /* DRM */ + NSDictionary *_drm; + AVAssetResourceLoadingRequest *_loadingRequest; /* Required to publish events */ RCTEventDispatcher *_eventDispatcher; @@ -398,6 +404,7 @@ - (void)setSrc:(NSDictionary *)source @"uri": uri ? uri : [NSNull null], @"type": type ? type : [NSNull null], @"isNetwork": [NSNumber numberWithBool:(bool)[source objectForKey:@"isNetwork"]]}, + @"drm": self->_drm ? self->_drm : [NSNull null], @"target": self.reactTag }); } @@ -406,6 +413,10 @@ - (void)setSrc:(NSDictionary *)source _videoLoadStarted = YES; } +- (void)setDrm:(NSDictionary *)drm { + _drm = drm; +} + - (NSURL*) urlFilePath:(NSString*) filepath { if ([filepath containsString:@"file://"]) { return [NSURL URLWithString:filepath]; @@ -488,6 +499,7 @@ - (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlaye bool shouldCache = [RCTConvert BOOL:[source objectForKey:@"shouldCache"]]; NSString *uri = [source objectForKey:@"uri"]; NSString *type = [source objectForKey:@"type"]; + AVURLAsset *asset; if (!uri || [uri isEqualToString:@""]) { DebugLog(@"Could not find video URL in source '%@'", source); return; @@ -518,16 +530,24 @@ - (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlaye } #endif - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; - [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; - return; + asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; } else if (isAsset) { - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; - [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; - return; + asset = [AVURLAsset URLAssetWithURL:url options:nil]; + } else { + asset = [AVURLAsset URLAssetWithURL:[[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]] options:nil]; + } + // Reset _loadingRequest + if (_loadingRequest != nil) { + [_loadingRequest finishLoading]; + } + _requestingCertificate = NO; + _requestingCertificateErrored = NO; + // End Reset _loadingRequest + if (self->_drm != nil) { + dispatch_queue_t queue = dispatch_queue_create("assetQueue", nil); + [asset.resourceLoader setDelegate:self queue:queue]; } - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]] options:nil]; [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; } @@ -679,7 +699,10 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N [self applyModifiers]; } else if (_playerItem.status == AVPlayerItemStatusFailed && self.onVideoError) { self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: _playerItem.error.code], - @"domain": _playerItem.error.domain}, + @"localizedDescription": [_playerItem.error localizedDescription] == nil ? @"" : [_playerItem.error localizedDescription], + @"localizedFailureReason": [_playerItem.error localizedFailureReason] == nil ? @"" : [_playerItem.error localizedFailureReason], + @"localizedRecoverySuggestion": [_playerItem.error localizedRecoverySuggestion] == nil ? @"" : [_playerItem.error localizedRecoverySuggestion], + @"domain": _playerItem != nil && _playerItem.error != nil ? _playerItem.error.domain : @"RTCVideo"}, @"target": self.reactTag}); } } else if ([keyPath isEqualToString:playbackBufferEmptyKeyPath]) { @@ -760,6 +783,13 @@ - (void)attachListeners selector:@selector(handleAVPlayerAccess:) name:AVPlayerItemNewAccessLogEntryNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self + name: AVPlayerItemFailedToPlayToEndTimeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(didFailToFinishPlaying:) + name: AVPlayerItemFailedToPlayToEndTimeNotification + object:nil]; } @@ -774,6 +804,16 @@ - (void)handleAVPlayerAccess:(NSNotification *)notification { */ } +- (void)didFailToFinishPlaying:(NSNotification *)notification { + NSError *error = notification.userInfo[AVPlayerItemFailedToPlayToEndTimeErrorKey]; + self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: error.code], + @"localizedDescription": [error localizedDescription] == nil ? @"" : [error localizedDescription], + @"localizedFailureReason": [error localizedFailureReason] == nil ? @"" : [error localizedFailureReason], + @"localizedRecoverySuggestion": [error localizedRecoverySuggestion] == nil ? @"" : [error localizedRecoverySuggestion], + @"domain": error.domain}, + @"target": self.reactTag}); +} + - (void)playbackStalled:(NSNotification *)notification { if(self.onPlaybackStalled) { @@ -1423,6 +1463,11 @@ - (void)setProgressUpdateInterval:(float)progressUpdateInterval - (void)removePlayerLayer { + if (_loadingRequest != nil) { + [_loadingRequest finishLoading]; + } + _requestingCertificate = NO; + _requestingCertificateErrored = NO; [_playerLayer removeFromSuperlayer]; if (_playerLayerObserverSet) { [_playerLayer removeObserver:self forKeyPath:readyForDisplayKeyPath]; @@ -1630,6 +1675,48 @@ - (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve rej } } +- (void)setLicenseResult:(NSString *)license { + NSData *respondData = [self base64DataFromBase64String:license]; + if (_loadingRequest != nil && respondData != nil) { + AVAssetResourceLoadingDataRequest *dataRequest = [_loadingRequest dataRequest]; + [dataRequest respondWithData:respondData]; + [_loadingRequest finishLoading]; + } else { + [self setLicenseResultError:@"No data from JS license response"]; + } +} + +- (BOOL)setLicenseResultError:(NSString *)error { + if (_loadingRequest != nil) { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorFromJSPart + userInfo: @{ + NSLocalizedDescriptionKey: error, + NSLocalizedFailureReasonErrorKey: error, + NSLocalizedRecoverySuggestionErrorKey: error + } + ]; + [self finishLoadingWithError:licenseError]; + } + return NO; +} + +- (BOOL)finishLoadingWithError:(NSError *)error { + if (_loadingRequest && error != nil) { + NSError *licenseError = error; + [_loadingRequest finishLoadingWithError:licenseError]; + if (self.onVideoError) { + self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: error.code], + @"localizedDescription": [error localizedDescription] == nil ? @"" : [error localizedDescription], + @"localizedFailureReason": [error localizedFailureReason] == nil ? @"" : [error localizedFailureReason], + @"localizedRecoverySuggestion": [error localizedRecoverySuggestion] == nil ? @"" : [error localizedRecoverySuggestion], + @"domain": _playerItem.error == nil ? @"RCTVideo" : _playerItem.error.domain}, + @"target": self.reactTag}); + } + } + return NO; +} + - (BOOL)ensureDirExistsWithPath:(NSString *)path { BOOL isDir = NO; NSError *error; @@ -1654,6 +1741,205 @@ - (NSString *)cacheDirectoryPath { return array[0]; } +#pragma mark - AVAssetResourceLoaderDelegate + +- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForRenewalOfRequestedResource:(AVAssetResourceRenewalRequest *)renewalRequest { + return [self loadingRequestHandling:renewalRequest]; +} + +- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest { + return [self loadingRequestHandling:loadingRequest]; +} + +- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader +didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest { + NSLog(@"didCancelLoadingRequest"); +} + +- (BOOL)loadingRequestHandling:(AVAssetResourceLoadingRequest *)loadingRequest { + if (self->_requestingCertificate) { + return YES; + } else if (self->_requestingCertificateErrored) { + return NO; + } + _loadingRequest = loadingRequest; + NSURL *url = loadingRequest.request.URL; + NSString *contentId = url.host; + if (self->_drm != nil) { + NSString *contentIdOverride = (NSString *)[self->_drm objectForKey:@"contentId"]; + if (contentIdOverride != nil) { + contentId = contentIdOverride; + } + NSString *drmType = (NSString *)[self->_drm objectForKey:@"type"]; + if ([drmType isEqualToString:@"fairplay"]) { + NSString *certificateStringUrl = (NSString *)[self->_drm objectForKey:@"certificateUrl"]; + if (certificateStringUrl != nil) { + NSURL *certificateURL = [NSURL URLWithString:[certificateStringUrl stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *certificateData = [NSData dataWithContentsOfURL:certificateURL]; + if ([self->_drm objectForKey:@"base64Certificate"]) { + certificateData = [[NSData alloc] initWithBase64EncodedData:certificateData options:NSDataBase64DecodingIgnoreUnknownCharacters]; + } + + if (certificateData != nil) { + NSData *contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding]; + AVAssetResourceLoadingDataRequest *dataRequest = [loadingRequest dataRequest]; + if (dataRequest != nil) { + NSError *spcError = nil; + NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificateData contentIdentifier:contentIdData options:nil error:&spcError]; + // Request CKC to the server + NSString *licenseServer = (NSString *)[self->_drm objectForKey:@"licenseServer"]; + if (spcError != nil) { + [self finishLoadingWithError:spcError]; + self->_requestingCertificateErrored = YES; + } + if (spcData != nil) { + if(self.onGetLicense) { + NSString *spcStr = [[NSString alloc] initWithData:spcData encoding:NSASCIIStringEncoding]; + self->_requestingCertificate = YES; + self.onGetLicense(@{@"spc": spcStr, + @"contentId": contentId, + @"spcBase64": [[[NSData alloc] initWithBase64EncodedData:certificateData options:NSDataBase64DecodingIgnoreUnknownCharacters] base64EncodedStringWithOptions:0], + @"target": self.reactTag}); + } else if(licenseServer != nil) { + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init]; + [request setHTTPMethod:@"POST"]; + [request setURL:[NSURL URLWithString:licenseServer]]; + // HEADERS + NSDictionary *headers = (NSDictionary *)[self->_drm objectForKey:@"headers"]; + if (headers != nil) { + for (NSString *key in headers) { + NSString *value = headers[key]; + [request setValue:value forHTTPHeaderField:key]; + } + } + // + + [request setHTTPBody: spcData]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; + NSURLSessionDataTask *postDataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response; + if (error != nil) { + NSLog(@"Error getting license from %@, HTTP status code %li", url, (long)[httpResponse statusCode]); + [self finishLoadingWithError:error]; + self->_requestingCertificateErrored = YES; + } else { + if([httpResponse statusCode] != 200){ + NSLog(@"Error getting license from %@, HTTP status code %li", url, (long)[httpResponse statusCode]); + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorLicenseRequestNotOk + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining license.", + NSLocalizedFailureReasonErrorKey: [NSString stringWithFormat:@"License server responded with status code %li", (long)[httpResponse statusCode]], + NSLocalizedRecoverySuggestionErrorKey: @"Did you send the correct data to the license Server? Is the server ok?" + } + ]; + [self finishLoadingWithError:licenseError]; + self->_requestingCertificateErrored = YES; + } else if (data != nil) { + [dataRequest respondWithData:data]; + [loadingRequest finishLoading]; + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoDataFromLicenseRequest + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: @"No data received from the license server.", + NSLocalizedRecoverySuggestionErrorKey: @"Is the licenseServer ok?." + } + ]; + [self finishLoadingWithError:licenseError]; + self->_requestingCertificateErrored = YES; + } + } + }]; + [postDataTask resume]; + } + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoSPC + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining license.", + NSLocalizedFailureReasonErrorKey: @"No spc received.", + NSLocalizedRecoverySuggestionErrorKey: @"Check your DRM config." + } + ]; + [self finishLoadingWithError:licenseError]; + self->_requestingCertificateErrored = YES; + } + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoDataRequest + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: @"No dataRequest found.", + NSLocalizedRecoverySuggestionErrorKey: @"Check your DRM configuration." + } + ]; + [self finishLoadingWithError:licenseError]; + self->_requestingCertificateErrored = YES; + } + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoCertificateData + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: @"No certificate data obtained from the specificied url.", + NSLocalizedRecoverySuggestionErrorKey: @"Have you specified a valid 'certificateUrl'?" + } + ]; + [self finishLoadingWithError:licenseError]; + self->_requestingCertificateErrored = YES; + } + }); + return YES; + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoCertificateURL + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM License.", + NSLocalizedFailureReasonErrorKey: @"No certificate URL has been found.", + NSLocalizedRecoverySuggestionErrorKey: @"Did you specified the prop certificateUrl?" + } + ]; + return [self finishLoadingWithError:licenseError]; + } + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoFairplayDRM + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: @"Not a valid DRM Scheme has found", + NSLocalizedRecoverySuggestionErrorKey: @"Have you specified the 'drm' 'type' as fairplay?" + } + ]; + return [self finishLoadingWithError:licenseError]; + } + } else { + NSError *licenseError = [NSError errorWithDomain: @"RCTVideo" + code: RCTVideoErrorNoDRMData + userInfo: @{ + NSLocalizedDescriptionKey: @"Error obtaining DRM license.", + NSLocalizedFailureReasonErrorKey: @"No drm object found.", + NSLocalizedRecoverySuggestionErrorKey: @"Have you specified the 'drm' prop?" + } + ]; + return [self finishLoadingWithError:licenseError]; + } + + return NO; +} + +- (NSData *)base64DataFromBase64String: (NSString *)base64String { + if (base64String != nil) { + // NSData from the Base64 encoded str + NSData *base64Data = [[NSData alloc] initWithBase64EncodedString:base64String options:NSASCIIStringEncoding]; + return base64Data; + } + return nil; +} + #pragma mark - Picture in Picture #if TARGET_OS_IOS diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 62c8b821b3..21fa986254 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -19,6 +19,7 @@ - (dispatch_queue_t)methodQueue } RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary); +RCT_EXPORT_VIEW_PROPERTY(drm, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float); RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString); RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL); @@ -68,6 +69,7 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_VIEW_PROPERTY(onPlaybackResume, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onPlaybackRateChange, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoExternalPlaybackChange, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onGetLicense, RCTDirectEventBlock); RCT_REMAP_METHOD(save, options:(NSDictionary *)options reactTag:(nonnull NSNumber *)reactTag @@ -83,6 +85,33 @@ - (dispatch_queue_t)methodQueue } }]; } +RCT_REMAP_METHOD(setLicenseResult, + license:(NSString *)license + reactTag:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager prependUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTVideo *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTVideo class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTVideo, got: %@", view); + } else { + [view setLicenseResult:license]; + } + }]; +}; + +RCT_REMAP_METHOD(setLicenseResultError, + error:(NSString *)error + reactTag:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager prependUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTVideo *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTVideo class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTVideo, got: %@", view); + } else { + [view setLicenseResultError:error]; + } + }]; +}; RCT_EXPORT_VIEW_PROPERTY(onPictureInPictureStatusChanged, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onRestoreUserInterfaceForPictureInPictureStop, RCTDirectEventBlock); diff --git a/package.json b/package.json index 1ae4fb2563..a26b8cc247 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "ios", "windows", "FilterType.js", + "DRMType.js", "TextTrackType.js", "VideoResizeMode.js", "react-native-video.podspec" diff --git a/react-native-video.podspec b/react-native-video.podspec index 98ba5537e8..1650cebb6f 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -32,4 +32,8 @@ Pod::Spec.new do |s| s.dependency "React" s.default_subspec = "Video" + + s.xcconfig = { + 'OTHER_LDFLAGS': '-ObjC', + } end