From 81b42e7ca76d376863548dcc7538b177ceac1ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mari=C3=B1o?= <35027703+danielmarino24i@users.noreply.github.com> Date: Thu, 13 Aug 2020 03:56:21 +0200 Subject: [PATCH] Add iOS and Android basic DRM support (#1445) This PR adds support for DRM streams on iOS (Fairplay) and Android (Playready, Widevine, Clearkey) I am neither Android nor iOS developer, so feel free to provide feedback to improve this PR. **Test stream for ANDROID:** ``` testStream = { uri: 'http://profficialsite.origin.mediaservices.windows.net/c51358ea-9a5e-4322-8951-897d640fdfd7/tearsofsteel_4k.ism/manifest(format=mpd-time-csf)', type: 'mpd', drm: { type: DRMType.PLAYREADY, licenseServer: 'http://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:150)' } }; ``` or ``` { uri: 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p.mpd', drm: { type: 'widevine', //or DRMType.WIDEVINE licenseServer: 'https://drm-widevine-licensing.axtest.net/AcquireLicense', headers: { 'X-AxDRM-Message': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXJzaW9uIjoxLCJjb21fa2V5X2lkIjoiYjMzNjRlYjUtNTFmNi00YWUzLThjOTgtMzNjZWQ1ZTMxYzc4IiwibWVzc2FnZSI6eyJ0eXBlIjoiZW50aXRsZW1lbnRfbWVzc2FnZSIsImZpcnN0X3BsYXlfZXhwaXJhdGlvbiI6NjAsInBsYXlyZWFkeSI6eyJyZWFsX3RpbWVfZXhwaXJhdGlvbiI6dHJ1ZX0sImtleXMiOlt7ImlkIjoiOWViNDA1MGQtZTQ0Yi00ODAyLTkzMmUtMjdkNzUwODNlMjY2IiwiZW5jcnlwdGVkX2tleSI6ImxLM09qSExZVzI0Y3Iya3RSNzRmbnc9PSJ9XX19.FAbIiPxX8BHi9RwfzD7Yn-wugU19ghrkBFKsaCPrZmU' }, } } ``` **Test stream for iOS:** Sorry but I can not provide free streams to test. If anyone can provide test streams, or found some we can use, please let me know to also test them. It has been tested with a private provider and they work, at least with the `getLicense` override method. (An example implementation is provided in the README) --- CHANGELOG.md | 4 + DRM.md | 139 +++ DRMType.js | 6 + README.md | 16 + Video.js | 34 +- android-exoplayer/build.gradle | 5 + .../brentvatne/exoplayer/DataSourceUtil.java | 12 + .../exoplayer/ReactExoplayerView.java | 124 ++- .../exoplayer/ReactExoplayerViewManager.java | 36 +- .../src/main/res/values/strings.xml | 5 + ios/Video/RCTVideo.h | 21 +- ios/Video/RCTVideo.m | 792 ++++++++++++------ ios/Video/RCTVideoManager.m | 31 +- package.json | 1 + react-native-video.podspec | 4 + 15 files changed, 958 insertions(+), 272 deletions(-) create mode 100644 DRM.md create mode 100644 DRMType.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f586a3..41630b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Changelog +### Version 5.1.0-alpha7 + +- Basic support for DRM on iOS and Android [#1445](https://github.com/react-native-community/react-native-video/pull/1445) + ### 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 00000000..d0ce88bf --- /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); + }); + } +} +``` diff --git a/DRMType.js b/DRMType.js new file mode 100644 index 00000000..473536b2 --- /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 00fc7932..644185c0 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 450a7796..6b84021f 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,26 @@ 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 +325,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 +401,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/build.gradle b/android-exoplayer/build.gradle index 44918610..296e21cb 100644 --- a/android-exoplayer/build.gradle +++ b/android-exoplayer/build.gradle @@ -19,6 +19,11 @@ android { versionCode 1 versionName "1.0" } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } dependencies { 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 487efeb0..19dda002 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 @@ public class 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 class DataSourceUtil { 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 4e6fea58..b41b2768 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.PlaybackParameters; 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.CookieManager; 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 @@ class ReactExoplayerView extends FrameLayout implements audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); themedReactContext.addLifecycleEventListener(this); audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); - - initializePlayer(); } @@ -214,6 +226,8 @@ class ReactExoplayerView extends FrameLayout implements exoPlayerView.setLayoutParams(layoutParams); addView(exoPlayerView, 0, layoutParams); + + mainHandler = new Handler(); } @Override @@ -395,9 +409,23 @@ class ReactExoplayerView extends FrameLayout implements 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 @@ class ReactExoplayerView extends FrameLayout implements }, 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,18 @@ class ReactExoplayerView extends FrameLayout implements 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 +981,12 @@ class ReactExoplayerView extends FrameLayout implements } 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; @@ -1182,12 +1241,12 @@ class ReactExoplayerView extends FrameLayout implements } public void setRateModifier(float newRate) { - rate = newRate; + rate = newRate; - if (player != null) { - PlaybackParameters params = new PlaybackParameters(rate, 1f); - player.setPlaybackParameters(params); - } + if (player != null) { + PlaybackParameters params = new PlaybackParameters(rate, 1f); + player.setPlaybackParameters(params); + } } public void setMaxBitRateModifier(int newMaxBitRate) { @@ -1246,7 +1305,8 @@ class ReactExoplayerView extends FrameLayout implements } public void setUseTextureView(boolean useTextureView) { - exoPlayerView.setUseTextureView(useTextureView); + boolean finallyUseTextureView = useTextureView && this.drmUUID == null; + exoPlayerView.setUseTextureView(finallyUseTextureView); } public void setHideShutterView(boolean hideShutterView) { @@ -1262,6 +1322,40 @@ class ReactExoplayerView extends FrameLayout implements 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 cf50fdae..0d81e0b2 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 @@ package com.brentvatne.exoplayer; 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(); @@ -108,7 +143,6 @@ public class ReactExoplayerViewManager extends ViewGroupManager headers = src.hasKey(PROP_SRC_HEADERS) ? toStringMap(src.getMap(PROP_SRC_HEADERS)) : null; - if (TextUtils.isEmpty(uriString)) { return; } diff --git a/android-exoplayer/src/main/res/values/strings.xml b/android-exoplayer/src/main/res/values/strings.xml index 4f69ec34..1f037779 100644 --- a/android-exoplayer/src/main/res/values/strings.xml +++ b/android-exoplayer/src/main/res/values/strings.xml @@ -8,7 +8,12 @@ Unable to query device decoders 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/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index 26d436c2..6fee2996 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 01e1b1c6..8780f48f 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -33,6 +33,12 @@ static int const RCTVideoUnset = -1; BOOL _playerLayerObserverSet; RCTVideoPlayerViewController *_playerViewController; NSURL *_videoURL; + BOOL _requestingCertificate; + BOOL _requestingCertificateErrored; + + /* DRM */ + NSDictionary *_drm; + AVAssetResourceLoadingRequest *_loadingRequest; /* Required to publish events */ RCTEventDispatcher *_eventDispatcher; @@ -146,14 +152,14 @@ static int const RCTVideoUnset = -1; - (RCTVideoPlayerViewController*)createPlayerViewController:(AVPlayer*)player withPlayerItem:(AVPlayerItem*)playerItem { - RCTVideoPlayerViewController* viewController = [[RCTVideoPlayerViewController alloc] init]; - viewController.showsPlaybackControls = YES; - viewController.rctDelegate = self; - viewController.preferredOrientation = _fullscreenOrientation; - - viewController.view.frame = self.bounds; - viewController.player = player; - return viewController; + RCTVideoPlayerViewController* viewController = [[RCTVideoPlayerViewController alloc] init]; + viewController.showsPlaybackControls = YES; + viewController.rctDelegate = self; + viewController.preferredOrientation = _fullscreenOrientation; + + viewController.view.frame = self.bounds; + viewController.player = player; + return viewController; } /* --------------------------------------------------------- @@ -247,11 +253,11 @@ static int const RCTVideoUnset = -1; - (void)audioRouteChanged:(NSNotification *)notification { - NSNumber *reason = [[notification userInfo] objectForKey:AVAudioSessionRouteChangeReasonKey]; - NSNumber *previousRoute = [[notification userInfo] objectForKey:AVAudioSessionRouteChangePreviousRouteKey]; - if (reason.unsignedIntValue == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) { - self.onVideoAudioBecomingNoisy(@{@"target": self.reactTag}); - } + NSNumber *reason = [[notification userInfo] objectForKey:AVAudioSessionRouteChangeReasonKey]; + NSNumber *previousRoute = [[notification userInfo] objectForKey:AVAudioSessionRouteChangePreviousRouteKey]; + if (reason.unsignedIntValue == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) { + self.onVideoAudioBecomingNoisy(@{@"target": self.reactTag}); + } } #pragma mark - Progress @@ -354,16 +360,16 @@ static int const RCTVideoUnset = -1; [self removePlayerLayer]; [self removePlayerTimeObserver]; [self removePlayerItemObservers]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t) 0), dispatch_get_main_queue(), ^{ - + // perform on next run loop, otherwise other passed react-props may not be set - [self playerItemForSource:source withCallback:^(AVPlayerItem * playerItem) { + [self playerItemForSource:self->_source withCallback:^(AVPlayerItem * playerItem) { + self->_playerItem = playerItem; _playerItem = playerItem; [self setPreferredForwardBufferDuration:_preferredForwardBufferDuration]; [self addPlayerItemObservers]; - [self setFilter:_filterName]; - [self setMaxBitRate:_maxBitRate]; + [self setFilter:self->_filterName]; + [self setMaxBitRate:self->_maxBitRate]; [_player pause]; @@ -371,20 +377,20 @@ static int const RCTVideoUnset = -1; [_player removeObserver:self forKeyPath:playbackRate context:nil]; _playbackRateObserverRegistered = NO; } - if (_isExternalPlaybackActiveObserverRegistered) { - [_player removeObserver:self forKeyPath:externalPlaybackActive context:nil]; - _isExternalPlaybackActiveObserverRegistered = NO; + if (self->_isExternalPlaybackActiveObserverRegistered) { + [self->_player removeObserver:self forKeyPath:externalPlaybackActive context:nil]; + self->_isExternalPlaybackActiveObserverRegistered = NO; } - - _player = [AVPlayer playerWithPlayerItem:_playerItem]; - _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; - - [_player addObserver:self forKeyPath:playbackRate options:0 context:nil]; - _playbackRateObserverRegistered = YES; - [_player addObserver:self forKeyPath:externalPlaybackActive options:0 context:nil]; - _isExternalPlaybackActiveObserverRegistered = YES; - + self->_player = [AVPlayer playerWithPlayerItem:self->_playerItem]; + self->_player.actionAtItemEnd = AVPlayerActionAtItemEndNone; + + [self->_player addObserver:self forKeyPath:playbackRate options:0 context:nil]; + self->_playbackRateObserverRegistered = YES; + + [self->_player addObserver:self forKeyPath:externalPlaybackActive options:0 context:nil]; + self->_isExternalPlaybackActiveObserverRegistered = YES; + [self addPlayerTimeObserver]; if (@available(iOS 10.0, *)) { [self setAutomaticallyWaitsToMinimizeStalling:_automaticallyWaitsToMinimizeStalling]; @@ -392,13 +398,14 @@ static int const RCTVideoUnset = -1; //Perform on next run loop, otherwise onVideoLoadStart is nil if (self.onVideoLoadStart) { - id uri = [source objectForKey:@"uri"]; - id type = [source objectForKey:@"type"]; + id uri = [self->_source objectForKey:@"uri"]; + id type = [self->_source objectForKey:@"type"]; self.onVideoLoadStart(@{@"src": @{ - @"uri": uri ? uri : [NSNull null], - @"type": type ? type : [NSNull null], - @"isNetwork": [NSNumber numberWithBool:(bool)[source objectForKey:@"isNetwork"]]}, - @"target": self.reactTag + @"uri": uri ? uri : [NSNull null], + @"type": type ? type : [NSNull null], + @"isNetwork": [NSNumber numberWithBool:(bool)[self->_source objectForKey:@"isNetwork"]]}, + @"drm": self->_drm ? self->_drm : [NSNull null], + @"target": self.reactTag }); } }]; @@ -406,6 +413,10 @@ static int const RCTVideoUnset = -1; _videoLoadStarted = YES; } +- (void)setDrm:(NSDictionary *)drm { + _drm = drm; +} + - (NSURL*) urlFilePath:(NSString*) filepath { if ([filepath containsString:@"file://"]) { return [NSURL URLWithString:filepath]; @@ -436,7 +447,7 @@ static int const RCTVideoUnset = -1; // AVPlayer can't airplay AVMutableCompositions _allowsExternalPlayback = NO; - + // sideload text tracks AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init]; @@ -477,7 +488,7 @@ static int const RCTVideoUnset = -1; if (validTextTracks.count != _textTracks.count) { [self setTextTracks:validTextTracks]; } - + handler([AVPlayerItem playerItemWithAsset:mixComposition]); } @@ -488,11 +499,12 @@ static int const RCTVideoUnset = -1; 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; } - + NSURL *url = isNetwork || isAsset ? [NSURL URLWithString:uri] : [[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]]; @@ -505,7 +517,7 @@ static int const RCTVideoUnset = -1; } NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]; [assetOptions setObject:cookies forKey:AVURLAssetHTTPCookiesKey]; - + #if __has_include() if (shouldCache && (!_textTracks || !_textTracks.count)) { /* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying @@ -517,61 +529,69 @@ static int const RCTVideoUnset = -1; return; } #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]; } - - AVURLAsset *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]; + } + [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; } #if __has_include() - (void)playerItemForSourceUsingCache:(NSString *)uri assetOptions:(NSDictionary *)options withCallback:(void(^)(AVPlayerItem *))handler { - NSURL *url = [NSURL URLWithString:uri]; - [_videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { - switch (videoCacheStatus) { - case RCTVideoCacheStatusMissingFileExtension: { - DebugLog(@"Could not generate cache key for 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", uri); - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; - [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; - return; - } - case RCTVideoCacheStatusUnsupportedFileExtension: { - DebugLog(@"Could not generate cache key for 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", uri); - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; - [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; - return; - } - default: - if (cachedAsset) { - DebugLog(@"Playing back uri '%@' from cache", uri); - // See note in playerItemForSource about not being able to support text tracks & caching - handler([AVPlayerItem playerItemWithAsset:cachedAsset]); - return; - } + NSURL *url = [NSURL URLWithString:uri]; + [_videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { + switch (videoCacheStatus) { + case RCTVideoCacheStatusMissingFileExtension: { + DebugLog(@"Could not generate cache key for 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", uri); + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; + [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; + return; + } + case RCTVideoCacheStatusUnsupportedFileExtension: { + DebugLog(@"Could not generate cache key for 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", uri); + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; + [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; + return; + } + default: + if (cachedAsset) { + DebugLog(@"Playing back uri '%@' from cache", uri); + // See note in playerItemForSource about not being able to support text tracks & caching + handler([AVPlayerItem playerItemWithAsset:cachedAsset]); + return; } - - DVURLAsset *asset = [[DVURLAsset alloc] initWithURL:url options:options networkTimeout:10000]; - asset.loaderDelegate = self; - - /* More granular code to have control over the DVURLAsset - DVAssetLoaderDelegate *resourceLoaderDelegate = [[DVAssetLoaderDelegate alloc] initWithURL:url]; - resourceLoaderDelegate.delegate = self; - NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO]; - components.scheme = [DVAssetLoaderDelegate scheme]; - AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options]; - [asset.resourceLoader setDelegate:resourceLoaderDelegate queue:dispatch_get_main_queue()]; - */ - - handler([AVPlayerItem playerItemWithAsset:asset]); - }]; + } + + DVURLAsset *asset = [[DVURLAsset alloc] initWithURL:url options:options networkTimeout:10000]; + asset.loaderDelegate = self; + + /* More granular code to have control over the DVURLAsset + DVAssetLoaderDelegate *resourceLoaderDelegate = [[DVAssetLoaderDelegate alloc] initWithURL:url]; + resourceLoaderDelegate.delegate = self; + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO]; + components.scheme = [DVAssetLoaderDelegate scheme]; + AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options]; + [asset.resourceLoader setDelegate:resourceLoaderDelegate queue:dispatch_get_main_queue()]; + */ + + handler([AVPlayerItem playerItemWithAsset:asset]); + }]; } #pragma mark - DVAssetLoaderDelegate @@ -579,9 +599,9 @@ static int const RCTVideoUnset = -1; - (void)dvAssetLoaderDelegate:(DVAssetLoaderDelegate *)loaderDelegate didLoadData:(NSData *)data forURL:(NSURL *)url { - [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { - DebugLog(@"Cache data stored successfully 🎉"); - }]; + [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { + DebugLog(@"Cache data stored successfully 🎉"); + }]; } #endif @@ -679,7 +699,10 @@ static int const RCTVideoUnset = -1; [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]) { @@ -708,10 +731,10 @@ static int const RCTVideoUnset = -1; } } else if([keyPath isEqualToString:externalPlaybackActive]) { - if(self.onVideoExternalPlaybackChange) { - self.onVideoExternalPlaybackChange(@{@"isExternalPlaybackActive": [NSNumber numberWithBool:_player.isExternalPlaybackActive], - @"target": self.reactTag}); - } + if(self.onVideoExternalPlaybackChange) { + self.onVideoExternalPlaybackChange(@{@"isExternalPlaybackActive": [NSNumber numberWithBool:_player.isExternalPlaybackActive], + @"target": self.reactTag}); + } } } else if (object == _playerViewController.contentOverlayView) { // when controls==true, this is a hack to reset the rootview when rotation happens in fullscreen @@ -752,7 +775,7 @@ static int const RCTVideoUnset = -1; selector:@selector(playbackStalled:) name:AVPlayerItemPlaybackStalledNotification object:nil]; - + [[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemNewAccessLogEntryNotification object:nil]; @@ -760,18 +783,35 @@ static int const RCTVideoUnset = -1; 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]; + } - (void)handleAVPlayerAccess:(NSNotification *)notification { - AVPlayerItemAccessLog *accessLog = [((AVPlayerItem *)notification.object) accessLog]; - AVPlayerItemAccessLogEvent *lastEvent = accessLog.events.lastObject; - - /* TODO: get this working - if (self.onBandwidthUpdate) { - self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]}); - } - */ + AVPlayerItemAccessLog *accessLog = [((AVPlayerItem *)notification.object) accessLog]; + AVPlayerItemAccessLogEvent *lastEvent = accessLog.events.lastObject; + + /* TODO: get this working + if (self.onBandwidthUpdate) { + self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]}); + } + */ +} + +- (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 @@ -825,8 +865,8 @@ static int const RCTVideoUnset = -1; - (void)setAllowsExternalPlayback:(BOOL)allowsExternalPlayback { - _allowsExternalPlayback = allowsExternalPlayback; - _player.allowsExternalPlayback = _allowsExternalPlayback; + _allowsExternalPlayback = allowsExternalPlayback; + _player.allowsExternalPlayback = _allowsExternalPlayback; } - (void)setPlayWhenInactive:(BOOL)playWhenInactive @@ -840,7 +880,7 @@ static int const RCTVideoUnset = -1; if (_pictureInPicture == pictureInPicture) { return; } - + _pictureInPicture = pictureInPicture; if (_pipController && _pictureInPicture && ![_pipController isPictureInPictureActive]) { dispatch_async(dispatch_get_main_queue(), ^{ @@ -849,7 +889,7 @@ static int const RCTVideoUnset = -1; } else if (_pipController && !_pictureInPicture && [_pipController isPictureInPictureActive]) { dispatch_async(dispatch_get_main_queue(), ^{ [_pipController stopPictureInPicture]; - }); + }); } #endif } @@ -1053,52 +1093,52 @@ static int const RCTVideoUnset = -1; - (void)setMediaSelectionTrackForCharacteristic:(AVMediaCharacteristic)characteristic withCriteria:(NSDictionary *)criteria { - NSString *type = criteria[@"type"]; - AVMediaSelectionGroup *group = [_player.currentItem.asset - mediaSelectionGroupForMediaCharacteristic:characteristic]; - AVMediaSelectionOption *mediaOption; + NSString *type = criteria[@"type"]; + AVMediaSelectionGroup *group = [_player.currentItem.asset + mediaSelectionGroupForMediaCharacteristic:characteristic]; + AVMediaSelectionOption *mediaOption; - if ([type isEqualToString:@"disabled"]) { - // Do nothing. We want to ensure option is nil - } else if ([type isEqualToString:@"language"] || [type isEqualToString:@"title"]) { - NSString *value = criteria[@"value"]; - for (int i = 0; i < group.options.count; ++i) { - AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i]; - NSString *optionValue; - if ([type isEqualToString:@"language"]) { - optionValue = [currentOption extendedLanguageTag]; - } else { - optionValue = [[[currentOption commonMetadata] - valueForKey:@"value"] - objectAtIndex:0]; - } - if ([value isEqualToString:optionValue]) { - mediaOption = currentOption; - break; - } + if ([type isEqualToString:@"disabled"]) { + // Do nothing. We want to ensure option is nil + } else if ([type isEqualToString:@"language"] || [type isEqualToString:@"title"]) { + NSString *value = criteria[@"value"]; + for (int i = 0; i < group.options.count; ++i) { + AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i]; + NSString *optionValue; + if ([type isEqualToString:@"language"]) { + optionValue = [currentOption extendedLanguageTag]; + } else { + optionValue = [[[currentOption commonMetadata] + valueForKey:@"value"] + objectAtIndex:0]; + } + if ([value isEqualToString:optionValue]) { + mediaOption = currentOption; + break; } - //} else if ([type isEqualToString:@"default"]) { - // option = group.defaultOption; */ - } else if ([type isEqualToString:@"index"]) { - if ([criteria[@"value"] isKindOfClass:[NSNumber class]]) { - int index = [criteria[@"value"] intValue]; - if (group.options.count > index) { - mediaOption = [group.options objectAtIndex:index]; - } - } - } else { // default. invalid type or "system" - [_player.currentItem selectMediaOptionAutomaticallyInMediaSelectionGroup:group]; - return; } + //} else if ([type isEqualToString:@"default"]) { + // option = group.defaultOption; */ + } else if ([type isEqualToString:@"index"]) { + if ([criteria[@"value"] isKindOfClass:[NSNumber class]]) { + int index = [criteria[@"value"] intValue]; + if (group.options.count > index) { + mediaOption = [group.options objectAtIndex:index]; + } + } + } else { // default. invalid type or "system" + [_player.currentItem selectMediaOptionAutomaticallyInMediaSelectionGroup:group]; + return; + } - // If a match isn't found, option will be nil and text tracks will be disabled - [_player.currentItem selectMediaOption:mediaOption inMediaSelectionGroup:group]; + // If a match isn't found, option will be nil and text tracks will be disabled + [_player.currentItem selectMediaOption:mediaOption inMediaSelectionGroup:group]; } - (void)setSelectedAudioTrack:(NSDictionary *)selectedAudioTrack { - _selectedAudioTrack = selectedAudioTrack; - [self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicAudible - withCriteria:_selectedAudioTrack]; + _selectedAudioTrack = selectedAudioTrack; + [self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicAudible + withCriteria:_selectedAudioTrack]; } - (void)setSelectedTextTrack:(NSDictionary *)selectedTextTrack { @@ -1233,25 +1273,25 @@ static int const RCTVideoUnset = -1; - (NSArray *)getAudioTrackInfo { - NSMutableArray *audioTracks = [[NSMutableArray alloc] init]; - AVMediaSelectionGroup *group = [_player.currentItem.asset - mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]; - for (int i = 0; i < group.options.count; ++i) { - AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i]; - NSString *title = @""; - NSArray *values = [[currentOption commonMetadata] valueForKey:@"value"]; - if (values.count > 0) { - title = [values objectAtIndex:0]; - } - NSString *language = [currentOption extendedLanguageTag] ? [currentOption extendedLanguageTag] : @""; - NSDictionary *audioTrack = @{ - @"index": [NSNumber numberWithInt:i], - @"title": title, - @"language": language - }; - [audioTracks addObject:audioTrack]; + NSMutableArray *audioTracks = [[NSMutableArray alloc] init]; + AVMediaSelectionGroup *group = [_player.currentItem.asset + mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]; + for (int i = 0; i < group.options.count; ++i) { + AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i]; + NSString *title = @""; + NSArray *values = [[currentOption commonMetadata] valueForKey:@"value"]; + if (values.count > 0) { + title = [values objectAtIndex:0]; } - return audioTracks; + NSString *language = [currentOption extendedLanguageTag] ? [currentOption extendedLanguageTag] : @""; + NSDictionary *audioTrack = @{ + @"index": [NSNumber numberWithInt:i], + @"title": title, + @"language": language + }; + [audioTracks addObject:audioTrack]; + } + return audioTracks; } - (NSArray *)getTextTrackInfo @@ -1423,6 +1463,11 @@ static int const RCTVideoUnset = -1; - (void)removePlayerLayer { + if (_loadingRequest != nil) { + [_loadingRequest finishLoading]; + } + _requestingCertificate = NO; + _requestingCertificateErrored = NO; [_playerLayer removeFromSuperlayer]; if (_playerLayerObserverSet) { [_playerLayer removeObserver:self forKeyPath:readyForDisplayKeyPath]; @@ -1461,29 +1506,29 @@ static int const RCTVideoUnset = -1; } - (void)setFilter:(NSString *)filterName { - _filterName = filterName; - - if (!_filterEnabled) { - return; - } else if ([[_source objectForKey:@"uri"] rangeOfString:@"m3u8"].location != NSNotFound) { - return; // filters don't work for HLS... return - } else if (!_playerItem.asset) { - return; - } - - CIFilter *filter = [CIFilter filterWithName:filterName]; - _playerItem.videoComposition = [AVVideoComposition - videoCompositionWithAsset:_playerItem.asset - applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest *_Nonnull request) { - if (filter == nil) { - [request finishWithImage:request.sourceImage context:nil]; - } else { - CIImage *image = request.sourceImage.imageByClampingToExtent; - [filter setValue:image forKey:kCIInputImageKey]; - CIImage *output = [filter.outputImage imageByCroppingToRect:request.sourceImage.extent]; - [request finishWithImage:output context:nil]; - } - }]; + _filterName = filterName; + + if (!_filterEnabled) { + return; + } else if ([[_source objectForKey:@"uri"] rangeOfString:@"m3u8"].location != NSNotFound) { + return; // filters don't work for HLS... return + } else if (!_playerItem.asset) { + return; + } + + CIFilter *filter = [CIFilter filterWithName:filterName]; + _playerItem.videoComposition = [AVVideoComposition + videoCompositionWithAsset:_playerItem.asset + applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest *_Nonnull request) { + if (filter == nil) { + [request finishWithImage:request.sourceImage context:nil]; + } else { + CIImage *image = request.sourceImage.imageByClampingToExtent; + [filter setValue:image forKey:kCIInputImageKey]; + CIImage *output = [filter.outputImage imageByCroppingToRect:request.sourceImage.extent]; + [request finishWithImage:output context:nil]; + } + }]; } - (void)setFilterEnabled:(BOOL)filterEnabled { @@ -1583,106 +1628,351 @@ static int const RCTVideoUnset = -1; #pragma mark - Export - (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - - AVAsset *asset = _playerItem.asset; - - if (asset != nil) { - - AVAssetExportSession *exportSession = [AVAssetExportSession - exportSessionWithAsset:asset presetName:AVAssetExportPresetHighestQuality]; - - if (exportSession != nil) { - NSString *path = nil; - NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); - path = [self generatePathInDirectory:[[self cacheDirectoryPath] stringByAppendingPathComponent:@"Videos"] - withExtension:@".mp4"]; - NSURL *url = [NSURL fileURLWithPath:path]; - exportSession.outputFileType = AVFileTypeMPEG4; - exportSession.outputURL = url; - exportSession.videoComposition = _playerItem.videoComposition; - exportSession.shouldOptimizeForNetworkUse = true; - [exportSession exportAsynchronouslyWithCompletionHandler:^{ - - switch ([exportSession status]) { - case AVAssetExportSessionStatusFailed: - reject(@"ERROR_COULD_NOT_EXPORT_VIDEO", @"Could not export video", exportSession.error); - break; - case AVAssetExportSessionStatusCancelled: - reject(@"ERROR_EXPORT_SESSION_CANCELLED", @"Export session was cancelled", exportSession.error); - break; - default: - resolve(@{@"uri": url.absoluteString}); - break; - } - - }]; - - } else { - - reject(@"ERROR_COULD_NOT_CREATE_EXPORT_SESSION", @"Could not create export session", nil); - + + AVAsset *asset = _playerItem.asset; + + if (asset != nil) { + + AVAssetExportSession *exportSession = [AVAssetExportSession + exportSessionWithAsset:asset presetName:AVAssetExportPresetHighestQuality]; + + if (exportSession != nil) { + NSString *path = nil; + NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + path = [self generatePathInDirectory:[[self cacheDirectoryPath] stringByAppendingPathComponent:@"Videos"] + withExtension:@".mp4"]; + NSURL *url = [NSURL fileURLWithPath:path]; + exportSession.outputFileType = AVFileTypeMPEG4; + exportSession.outputURL = url; + exportSession.videoComposition = _playerItem.videoComposition; + exportSession.shouldOptimizeForNetworkUse = true; + [exportSession exportAsynchronouslyWithCompletionHandler:^{ + + switch ([exportSession status]) { + case AVAssetExportSessionStatusFailed: + reject(@"ERROR_COULD_NOT_EXPORT_VIDEO", @"Could not export video", exportSession.error); + break; + case AVAssetExportSessionStatusCancelled: + reject(@"ERROR_EXPORT_SESSION_CANCELLED", @"Export session was cancelled", exportSession.error); + break; + default: + resolve(@{@"uri": url.absoluteString}); + break; } - + + }]; + } else { - - reject(@"ERROR_ASSET_NIL", @"Asset is nil", nil); - + + reject(@"ERROR_COULD_NOT_CREATE_EXPORT_SESSION", @"Could not create export session", nil); + } + + } else { + + reject(@"ERROR_ASSET_NIL", @"Asset is nil", nil); + + } +} + +- (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; - BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir]; - if (!(exists && isDir)) { - [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error]; - if (error) { - return NO; - } + BOOL isDir = NO; + NSError *error; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir]; + if (!(exists && isDir)) { + [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error]; + if (error) { + return NO; } - return YES; + } + return YES; } - (NSString *)generatePathInDirectory:(NSString *)directory withExtension:(NSString *)extension { - NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingString:extension]; - [self ensureDirExistsWithPath:directory]; - return [directory stringByAppendingPathComponent:fileName]; + NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingString:extension]; + [self ensureDirExistsWithPath:directory]; + return [directory stringByAppendingPathComponent:fileName]; } - (NSString *)cacheDirectoryPath { - NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); - return array[0]; + NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + 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 - (void)pictureInPictureControllerDidStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { if (self.onPictureInPictureStatusChanged) { self.onPictureInPictureStatusChanged(@{ - @"isActive": [NSNumber numberWithBool:false] - }); + @"isActive": [NSNumber numberWithBool:false] + }); } } - (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { if (self.onPictureInPictureStatusChanged) { self.onPictureInPictureStatusChanged(@{ - @"isActive": [NSNumber numberWithBool:true] - }); + @"isActive": [NSNumber numberWithBool:true] + }); } } - (void)pictureInPictureControllerWillStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { - + } - (void)pictureInPictureControllerWillStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { - + } - (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController failedToStartPictureInPictureWithError:(NSError *)error { - + } - (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:(void (^)(BOOL))completionHandler { diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 62c8b821..000a9e83 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -19,6 +19,7 @@ RCT_EXPORT_MODULE(); } 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 @@ RCT_EXPORT_VIEW_PROPERTY(onPlaybackStalled, RCTDirectEventBlock); 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 @@ -82,7 +84,34 @@ RCT_REMAP_METHOD(save, [view save:options resolve:resolve reject:reject]; } }]; -} +}; +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 1ae4fb25..a26b8cc2 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 98ba5537..1650cebb 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