diff --git a/API.md b/API.md index f3c6a129..625efeb5 100644 --- a/API.md +++ b/API.md @@ -263,80 +263,82 @@ var styles = StyleSheet.create({ ``` ### Configurable props -| Name |Plateforms Support | -|--|--| -|[allowsExternalPlayback](#allowsexternalplayback) |iOS | -|[audioOnly](#audioonly)|All | -|[automaticallyWaitsToMinimizeStalling](#automaticallyWaitsToMinimizeStalling) | iOS|\ -|[backBufferDurationMs](#backBufferDurationMs)| Android | -|[bufferConfig](#bufferconfig)|Android| -|[contentStartTime](#contentStartTime)| Android | -|[controls](#controls)|Android, iOS| -|[currentPlaybackTime](#currentPlaybackTime)|Android| -|[disableFocus](#disableFocus)|Android, iOS| -|[disableDisconnectError](#disableDisconnectError)|Android| -|[filter](#filter)|iOS| -|[filterEnabled](#filterEnabled)|iOS| -|[focusable](#focusable)|Android| -|[fullscreen](#fullscreen)|iOS| -|[fullscreenAutorotate](#fullscreenautorotate)|iOS| -|[fullscreenOrientation](#fullscreenorientation)|iOS| -|[headers](#headers)|Android| -|[hideShutterView](#hideshutterview)|Android| -|[ignoreSilentSwitch](#ignoresilentswitch)|iOS| -|[maxBitRate](#maxbitrate)|Android, iOS| -|[minLoadRetryCount](#minLoadRetryCount)|Android| -|[mixWithOthers](#mixWithOthers)|iOS| -|[muted](#muted)|All| -|[paused](#paused)|All| -|[pictureInPicture](#pictureinpicture)|iOS| -|[playInBackground](#playinbackground)|Android, iOS| -|[playWhenInactive](#playwheninactive)|iOS| -|[poster](#poster)|All| -|[posterResizeMode](#posterresizemode)|All| -|[preferredForwardBufferDuration](#preferredForwardBufferDuration)|iOS| -|[preventsDisplaySleepDuringVideoPlayback](#preventsDisplaySleepDuringVideoPlayback)|iOS, Android| -|[progressUpdateInterval](#progressupdateinterval)|All| -|[rate](#rate)|All| -|[repeat](#repeat)|All| -|[reportBandwidth](#reportbandwidth)|Android| -|[resizeMode](#resizemode)|Android, iOS, Windows UWP| -|[selectedAudioTrack](#selectedaudiotrack)|Android, iOS| -|[selectedTextTrack](#selectedtexttrack)|Android, iOS| -|[selectedVideoTrack](#selectedvideotrack)|Android| -|[source](#source)|All| -|[subtitleStyle](#subtitleStyle)|Android| -|[textTracks](#texttracks)|Android, iOS| -|[trackId](#trackId)|Android| -|[useTextureView](#usetextureview)|Android| -|[useSecureView](#useSecureView)|Android| -|[volume](#volume)|All| -|[localSourceEncryptionKeyScheme](#localSourceEncryptionKeyScheme)|All| +| Name | Platforms Support | +|-------------------------------------------------------------------------------------|---------------------------| +| [adTagUrl](#adTagUrl) | Android, iOS | +| [allowsExternalPlayback](#allowsexternalplayback) | iOS | +| [audioOnly](#audioonly) | All | +| [automaticallyWaitsToMinimizeStalling](#automaticallyWaitsToMinimizeStalling) | iOS | +| [backBufferDurationMs](#backBufferDurationMs) | Android | +| [bufferConfig](#bufferconfig) | Android | +| [contentStartTime](#contentStartTime) | Android | +| [controls](#controls) | Android, iOS | +| [currentPlaybackTime](#currentPlaybackTime) | Android | +| [disableFocus](#disableFocus) | Android, iOS | +| [disableDisconnectError](#disableDisconnectError) | Android | +| [filter](#filter) | iOS | +| [filterEnabled](#filterEnabled) | iOS | +| [focusable](#focusable) | Android | +| [fullscreen](#fullscreen) | iOS | +| [fullscreenAutorotate](#fullscreenautorotate) | iOS | +| [fullscreenOrientation](#fullscreenorientation) | iOS | +| [headers](#headers) | Android | +| [hideShutterView](#hideshutterview) | Android | +| [ignoreSilentSwitch](#ignoresilentswitch) | iOS | +| [maxBitRate](#maxbitrate) | Android, iOS | +| [minLoadRetryCount](#minLoadRetryCount) | Android | +| [mixWithOthers](#mixWithOthers) | iOS | +| [muted](#muted) | All | +| [paused](#paused) | All | +| [pictureInPicture](#pictureinpicture) | iOS | +| [playInBackground](#playinbackground) | Android, iOS | +| [playWhenInactive](#playwheninactive) | iOS | +| [poster](#poster) | All | +| [posterResizeMode](#posterresizemode) | All | +| [preferredForwardBufferDuration](#preferredForwardBufferDuration) | iOS | +| [preventsDisplaySleepDuringVideoPlayback](#preventsDisplaySleepDuringVideoPlayback) | iOS, Android | +| [progressUpdateInterval](#progressupdateinterval) | All | +| [rate](#rate) | All | +| [repeat](#repeat) | All | +| [reportBandwidth](#reportbandwidth) | Android | +| [resizeMode](#resizemode) | Android, iOS, Windows UWP | +| [selectedAudioTrack](#selectedaudiotrack) | Android, iOS | +| [selectedTextTrack](#selectedtexttrack) | Android, iOS | +| [selectedVideoTrack](#selectedvideotrack) | Android | +| [source](#source) | All | +| [subtitleStyle](#subtitleStyle) | Android | +| [textTracks](#texttracks) | Android, iOS | +| [trackId](#trackId) | Android | +| [useTextureView](#usetextureview) | Android | +| [useSecureView](#useSecureView) | Android | +| [volume](#volume) | All | +| [localSourceEncryptionKeyScheme](#localSourceEncryptionKeyScheme) | All | ### Event props -| Name |Plateforms Support | -|--|--| -|[onAudioBecomingNoisy](#onaudiobecomingnoisy)|Android, iOS| -|[onBandwidthUpdate](#onbandwidthupdate)|Android| -|[onBuffer](#onbuffer)|Android, iOS| -|[onEnd](#onend)|All| -|[onError](#onerror)|Android, iOS| -|[onExternalPlaybackChange](#onexternalplaybackchange)|iOS| -|[onFullscreenPlayerWillPresent](#onfullscreenplayerwillpresent)|Android, iOS| -|[onFullscreenPlayerDidPresent](#onfullscreenplayerdidpresent)|Android, iOS| -|[onFullscreenPlayerWillDismiss](#onfullscreenplayerwilldismiss)|Android, iOS| -|[onFullscreenPlayerDidDismiss](#onfullscreenplayerdiddismiss)|Android, iOS| -|[onLoad](#onload)|All| -|[onLoadStart](#onloadstart)|All| -|[onReadyForDisplay](#onreadyfordisplay)|Android, iOS, Web| -|[onPictureInPictureStatusChanged](#onpictureinpicturestatuschanged)|iOS| -|[onPlaybackRateChange](#onplaybackratechange)|All| -|[onProgress](#onprogress)|All| -|[onSeek](#onseek)|Android, iOS, Windows UWP| -|[onRestoreUserInterfaceForPictureInPictureStop](#onrestoreuserinterfaceforpictureinpicturestop)|iOS| -|[onTimedMetadata](#ontimedmetadata)|Android, iOS| +| Name | Platforms Support | +|-------------------------------------------------------------------------------------------------|---------------------------| +| [onAudioBecomingNoisy](#onaudiobecomingnoisy) | Android, iOS | +| [onBandwidthUpdate](#onbandwidthupdate) | Android | +| [onBuffer](#onbuffer) | Android, iOS | +| [onEnd](#onend) | All | +| [onError](#onerror) | Android, iOS | +| [onExternalPlaybackChange](#onexternalplaybackchange) | iOS | +| [onFullscreenPlayerWillPresent](#onfullscreenplayerwillpresent) | Android, iOS | +| [onFullscreenPlayerDidPresent](#onfullscreenplayerdidpresent) | Android, iOS | +| [onFullscreenPlayerWillDismiss](#onfullscreenplayerwilldismiss) | Android, iOS | +| [onFullscreenPlayerDidDismiss](#onfullscreenplayerdiddismiss) | Android, iOS | +| [onLoad](#onload) | All | +| [onLoadStart](#onloadstart) | All | +| [onPictureInPictureStatusChanged](#onpictureinpicturestatuschanged) | iOS | +| [onPlaybackRateChange](#onplaybackratechange) | All | +| [onProgress](#onprogress) | All | +| [onReadyForDisplay](#onreadyfordisplay) | Android, iOS, Web | +| [onReceiveAdEvent](#onReceiveAdEvent) | Android, iOS | +| [onRestoreUserInterfaceForPictureInPictureStop](#onrestoreuserinterfaceforpictureinpicturestop) | iOS | +| [onSeek](#onseek) | Android, iOS, Windows UWP | +| [onTimedMetadata](#ontimedmetadata) | Android, iOS | ### Methods | Name |Plateforms Support | @@ -357,6 +359,16 @@ var styles = StyleSheet.create({ ### Configurable props +#### adTagUrl +Sets the VAST uri to play AVOD ads. + +Example: +``` +adTagUrl="https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=" +``` + +Platforms: Android, iOS + #### allowsExternalPlayback Indicates whether the player allows switching to external playback mode such as AirPlay or HDMI. * **true (default)** - allow switching to external playback mode @@ -1161,16 +1173,6 @@ Example: Platforms: Android -#### onReadyForDisplay -Callback function that is called when the first video frame is ready for display. This is when the poster is removed. - -Payload: none - -* iOS: [readyForDisplay](https://developer.apple.com/documentation/avkit/avplayerviewcontroller/1615830-readyfordisplay?language=objc) -* Android [STATE_READY](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/Player.html#STATE_READY) - -Platforms: Android, iOS, Web - #### onPictureInPictureStatusChanged Callback function that is called when picture in picture becomes active or inactive. @@ -1203,7 +1205,6 @@ Example: Platforms: all - #### onProgress Callback function that is called every progressUpdateInterval milliseconds with info about which position the media is currently playing. @@ -1224,6 +1225,83 @@ Example: Platforms: all +#### onReadyForDisplay +Callback function that is called when the first video frame is ready for display. This is when the poster is removed. + +Payload: none + +* iOS: [readyForDisplay](https://developer.apple.com/documentation/avkit/avplayerviewcontroller/1615830-readyfordisplay?language=objc) +* Android [STATE_READY](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/Player.html#STATE_READY) + +Platforms: Android, iOS, Web + +#### onReceiveAdEvent +Callback function that is called when an AdEvent is received from the IMA's SDK. + +Enum `AdEvent` possible values for [Android](https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/reference/js/google.ima.AdEvent) and [iOS](https://developers.google.com/interactive-media-ads/docs/sdks/ios/client-side/reference/Enums/IMAAdEventType): + +| Event | Platform | Description | +|----------------------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `AD_BREAK_ENDED` | iOS | Fired the first time each ad break ends. Applications must reenable seeking when this occurs (only used for dynamic ad insertion). | +| `AD_BREAK_READY` | Android, iOS | Fires when an ad rule or a VMAP ad break would have played if autoPlayAdBreaks is false. | +| `AD_BREAK_STARTED` | iOS | Fired first time each ad break begins playback. If an ad break is watched subsequent times this will not be fired. Applications must disable seeking when this occurs (only used for dynamic ad insertion). | +| `AD_BUFFERING` | Android | Fires when the ad has stalled playback to buffer. | +| `AD_CAN_PLAY` | Android | Fires when the ad is ready to play without buffering, either at the beginning of the ad or after buffering completes. | +| `AD_METADATA` | Android | Fires when an ads list is loaded. | +| `AD_PERIOD_ENDED` | iOS | Fired every time the stream switches from advertising or slate to content. This will be fired even when an ad is played a second time or when seeking into an ad (only used for dynamic ad insertion). | +| `AD_PERIOD_STARTED` | iOS | Fired every time the stream switches from content to advertising or slate. This will be fired even when an ad is played a second time or when seeking into an ad (only used for dynamic ad insertion). | +| `AD_PROGRESS` | Android | Fires when the ad's current time value changes. Calling getAdData() on this event will return an AdProgressData object. | +| `ALL_ADS_COMPLETED` | Android, iOS | Fires when the ads manager is done playing all the valid ads in the ads response, or when the response doesn't return any valid ads. | +| `CLICK` | Android, iOS | Fires when the ad is clicked. | +| `COMPLETE` | Android, iOS | Fires when the ad completes playing. | +| `CONTENT_PAUSE_REQUESTED` | Android | Fires when content should be paused. This usually happens right before an ad is about to cover the content. | +| `CONTENT_RESUME_REQUESTED` | Android | Fires when content should be resumed. This usually happens when an ad finishes or collapses. | +| `CUEPOINTS_CHANGED` | iOS | Cuepoints changed for VOD stream (only used for dynamic ad insertion). | +| `DURATION_CHANGE` | Android | Fires when the ad's duration changes. | +| `FIRST_QUARTILE` | Android, iOS | Fires when the ad playhead crosses first quartile. | +| `IMPRESSION` | Android | Fires when the impression URL has been pinged. | +| `INTERACTION` | Android | Fires when an ad triggers the interaction callback. Ad interactions contain an interaction ID string in the ad data. | +| `LINEAR_CHANGED` | Android | Fires when the displayed ad changes from linear to nonlinear, or the reverse. | +| `LOADED` | Android, iOS | Fires when ad data is available. | +| `LOG` | Android, iOS | Fires when a non-fatal error is encountered. The user need not take any action since the SDK will continue with the same or next ad playback depending on the error situation. | +| `MIDPOINT` | Android, iOS | Fires when the ad playhead crosses midpoint. | +| `PAUSED` | Android, iOS | Fires when the ad is paused. | +| `RESUMED` | Android, iOS | Fires when the ad is resumed. | +| `SKIPPABLE_STATE_CHANGED` | Android | Fires when the displayed ads skippable state is changed. | +| `SKIPPED` | Android, iOS | Fires when the ad is skipped by the user. | +| `STARTED` | Android, iOS | Fires when the ad starts playing. | +| `STREAM_LOADED` | iOS | Stream request has loaded (only used for dynamic ad insertion). | +| `TAPPED` | iOS | Fires when the ad is tapped. | +| `THIRD_QUARTILE` | Android, iOS | Fires when the ad playhead crosses third quartile. | +| `UNKNOWN` | iOS | An unknown event has fired | +| `USER_CLOSE` | Android | Fires when the ad is closed by the user. | +| `VIDEO_CLICKED` | Android | Fires when the non-clickthrough portion of a video ad is clicked. | +| `VIDEO_ICON_CLICKED` | Android | Fires when a user clicks a video icon. | +| `VOLUME_CHANGED` | Android | Fires when the ad volume has changed. | +| `VOLUME_MUTED` | Android | Fires when the ad volume has been muted. | + +Payload: + +| Property | Type | Description | +|----------|---------|-----------------------| +| event | AdEvent | The ad event received | + +Example: +``` +{ + "event": "LOADED" +} +``` + +Platforms: Android, iOS + +#### onRestoreUserInterfaceForPictureInPictureStop +Callback function that corresponds to Apple's [`restoreUserInterfaceForPictureInPictureStopWithCompletionHandler`](https://developer.apple.com/documentation/avkit/avpictureinpicturecontrollerdelegate/1614703-pictureinpicturecontroller?language=objc). Call `restoreUserInterfaceForPictureInPictureStopCompleted` inside of this function when done restoring the user interface. + +Payload: none + +Platforms: iOS + #### onSeek Callback function that is called when a seek completes. @@ -1247,13 +1325,6 @@ Both the currentTime & seekTime are reported because the video player may not se Platforms: Android, iOS, Windows UWP -#### onRestoreUserInterfaceForPictureInPictureStop -Callback function that corresponds to Apple's [`restoreUserInterfaceForPictureInPictureStopWithCompletionHandler`](https://developer.apple.com/documentation/avkit/avpictureinpicturecontrollerdelegate/1614703-pictureinpicturecontroller?language=objc). Call `restoreUserInterfaceForPictureInPictureStopCompleted` inside of this function when done restoring the user interface. - -Payload: none - -Platforms: iOS - #### onTimedMetadata Callback function that is called when timed metadata becomes available @@ -1630,4 +1701,4 @@ allprojects { } ``` If you encounter an error `Could not find com.android.support:support-annotations:27.0.0.` reinstall your Android Support Repository. - + diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5008a7..7ae8411c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fix regression when fullscreen prop is used combined with controls [#2911](https://github.com/react-native-video/react-native-video/pull/2911) - Fix: memory leak issue on iOS [#2907](https://github.com/react-native-video/react-native-video/pull/2907) - Fix setting text tracks before player is initialized on iOS [#2935](https://github.com/react-native-video/react-native-video/pull/2935) +- Feature: Add VAST support for AVOD [#2923](https://github.com/react-native-video/react-native-video/pull/2923) ### Version 6.0.0-alpha.3 diff --git a/Video.js b/Video.js index 0a449e43..e1374725 100644 --- a/Video.js +++ b/Video.js @@ -261,6 +261,13 @@ export default class Video extends Component { } } } + + _onReceiveAdEvent = (event) => { + if (this.props.onReceiveAdEvent) { + this.props.onReceiveAdEvent(event.nativeEvent); + } + }; + getViewManagerConfig = viewManagerName => { if (!UIManager.getViewManagerConfig) { return UIManager[viewManagerName]; @@ -343,6 +350,7 @@ export default class Video extends Component { onGetLicense: nativeProps.drm && nativeProps.drm.getLicense && this._onGetLicense, onPictureInPictureStatusChanged: this._onPictureInPictureStatusChanged, onRestoreUserInterfaceForPictureInPictureStop: this._onRestoreUserInterfaceForPictureInPictureStop, + onReceiveAdEvent: this._onReceiveAdEvent, }); const posterStyle = { @@ -527,6 +535,8 @@ Video.propTypes = { onPictureInPictureStatusChanged: PropTypes.func, needsToRestoreUserInterfaceForPictureInPictureStop: PropTypes.func, onExternalPlaybackChange: PropTypes.func, + adTagUrl: PropTypes.string, + onReceiveAdEvent: PropTypes.func, /* Required by react-native */ scaleX: PropTypes.number, diff --git a/android/build.gradle b/android/build.gradle index ebacc8e9..fdd257bf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -45,5 +45,7 @@ dependencies { implementation('com.google.android.exoplayer:extension-okhttp:2.18.1') { exclude group: 'com.squareup.okhttp3', module: 'okhttp' } + implementation 'com.google.android.exoplayer:extension-ima:2.18.1' + implementation "com.squareup.okhttp3:okhttp:" + '$OKHTTP_VERSION' } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java index 462d610c..10cecff3 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -22,13 +22,15 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AdViewProvider; import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.video.VideoSize; import java.util.List; @TargetApi(16) -public final class ExoPlayerView extends FrameLayout { +public final class ExoPlayerView extends FrameLayout implements AdViewProvider { private View surfaceView; private final View shutterView; @@ -38,6 +40,7 @@ public final class ExoPlayerView extends FrameLayout { private ExoPlayer player; private Context context; private ViewGroup.LayoutParams layoutParams; + private final FrameLayout adOverlayFrameLayout; private boolean useTextureView = true; private boolean useSecureView = false; @@ -80,8 +83,11 @@ public final class ExoPlayerView extends FrameLayout { updateSurfaceView(); + adOverlayFrameLayout = new FrameLayout(context); + layout.addView(shutterView, 1, layoutParams); layout.addView(subtitleLayout, 2, layoutParams); + layout.addView(adOverlayFrameLayout, 3, layoutParams); addViewInLayout(layout, 0, aspectRatioParams); } @@ -139,6 +145,19 @@ public final class ExoPlayerView extends FrameLayout { shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE); } + @Override + public void requestLayout() { + super.requestLayout(); + post(measureAndLayout); + } + + // AdsLoader.AdViewProvider implementation. + + @Override + public ViewGroup getAdViewGroup() { + return Assertions.checkNotNull(adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback"); + } + /** * Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the * player will be called and previous diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 8850610f..27a79782 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -31,6 +31,7 @@ import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.util.RNLog; +import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.DefaultRenderersFactory; @@ -75,11 +76,11 @@ import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; import com.google.android.exoplayer2.trackselection.TrackSelectionOverride; import com.google.android.exoplayer2.ui.PlayerControlView; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.upstream.HttpDataSource; @@ -93,6 +94,11 @@ import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.Descriptor; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; + +import com.google.common.collect.ImmutableList; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; @@ -119,7 +125,8 @@ class ReactExoplayerView extends FrameLayout implements BandwidthMeter.EventListener, BecomingNoisyListener, AudioManager.OnAudioFocusChangeListener, - DrmSessionEventListener { + DrmSessionEventListener, + AdEvent.AdEventListener { public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1; public static final double DEFAULT_MIN_BACK_BUFFER_MEMORY_RESERVE = 0; @@ -144,6 +151,7 @@ class ReactExoplayerView extends FrameLayout implements private ExoPlayerView exoPlayerView; private FullScreenPlayerView fullScreenPlayerView; + private ImaAdsLoader adsLoader; private DataSource.Factory mediaDataSourceFactory; private ExoPlayer player; @@ -203,6 +211,7 @@ class ReactExoplayerView extends FrameLayout implements private String drmLicenseUrl = null; private String[] drmLicenseHeader = null; private boolean controls; + private Uri adTagUrl; // \ End props // React @@ -221,6 +230,9 @@ class ReactExoplayerView extends FrameLayout implements switch (msg.what) { case SHOW_PROGRESS: if (player != null) { + if (playerControlView != null && isPlayingAd() && controls) { + playerControlView.hide(); + } long pos = player.getCurrentPosition(); long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100; long duration = player.getDuration(); @@ -263,6 +275,9 @@ class ReactExoplayerView extends FrameLayout implements audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); } + private boolean isPlayingAd() { + return player != null && player.isPlayingAd(); + } @Override public void setId(int id) { @@ -389,7 +404,9 @@ class ReactExoplayerView extends FrameLayout implements exoPlayerView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { - togglePlayerControlVisibility(); + if (!isPlayingAd()) { + togglePlayerControlVisibility(); + } } }); @@ -607,13 +624,22 @@ class ReactExoplayerView extends FrameLayout implements DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(getContext()) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF); + + // Create an AdsLoader. + adsLoader = new ImaAdsLoader.Builder(themedReactContext).setAdEventListener(this).build(); + + MediaSource.Factory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory) + .setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView); + player = new ExoPlayer.Builder(getContext(), renderersFactory) .setTrackSelector(self.trackSelector) .setBandwidthMeter(bandwidthMeter) .setLoadControl(loadControl) + .setMediaSourceFactory(mediaSourceFactory) .build(); player.addListener(self); exoPlayerView.setPlayer(player); + adsLoader.setPlayer(player); audioBecomingNoisyReceiver.setListener(self); bandwidthMeter.addEventListener(new Handler(), self); setPlayWhenReady(!isPaused); @@ -643,11 +669,26 @@ class ReactExoplayerView extends FrameLayout implements private void initializePlayerSource(ReactExoplayerView self, DrmSessionManager drmSessionManager) { ArrayList mediaSourceList = buildTextSources(); MediaSource videoSource = buildMediaSource(self.srcUri, self.extension, drmSessionManager); + MediaSource mediaSourceWithAds = null; + if (adTagUrl != null) { + MediaSource.Factory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory) + .setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView); + DataSpec adTagDataSpec = new DataSpec(adTagUrl); + mediaSourceWithAds = new AdsMediaSource(videoSource, adTagDataSpec, ImmutableList.of(srcUri, adTagUrl), mediaSourceFactory, adsLoader, exoPlayerView); + } MediaSource mediaSource; if (mediaSourceList.size() == 0) { - mediaSource = videoSource; + if (mediaSourceWithAds != null) { + mediaSource = mediaSourceWithAds; + } else { + mediaSource = videoSource; + } } else { - mediaSourceList.add(0, videoSource); + if (mediaSourceWithAds != null) { + mediaSourceList.add(0, mediaSourceWithAds); + } else { + mediaSourceList.add(0, videoSource); + } MediaSource[] textSourceArray = mediaSourceList.toArray( new MediaSource[mediaSourceList.size()] ); @@ -729,7 +770,17 @@ class ReactExoplayerView extends FrameLayout implements int type = Util.inferContentType(!TextUtils.isEmpty(overrideExtension) ? "." + overrideExtension : uri.getLastPathSegment()); config.setDisableDisconnectError(this.disableDisconnectError); - MediaItem mediaItem = new MediaItem.Builder().setUri(uri).build(); + + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(uri); + + if (adTagUrl != null) { + mediaItemBuilder.setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(adTagUrl).build() + ); + } + + MediaItem mediaItem = mediaItemBuilder.build(); + DrmSessionManagerProvider drmProvider = null; if (drmSessionManager != null) { drmProvider = new DrmSessionManagerProvider() { @@ -813,12 +864,15 @@ class ReactExoplayerView extends FrameLayout implements private void releasePlayer() { if (player != null) { + adsLoader.setPlayer(null); updateResumePosition(); player.release(); player.removeListener(this); trackSelector = null; player = null; } + adsLoader.release(); + adsLoader = null; progressHandler.removeMessages(SHOW_PROGRESS); themedReactContext.removeLifecycleEventListener(this); audioBecomingNoisyReceiver.removeListener(); @@ -1049,7 +1103,7 @@ class ReactExoplayerView extends FrameLayout implements } private void videoLoaded() { - if (loadVideoStarted) { + if (!player.isPlayingAd() && loadVideoStarted) { loadVideoStarted = false; if (audioTrackType != null) { setSelectedAudioTrack(audioTrackType, audioTrackValue); @@ -1419,6 +1473,10 @@ class ReactExoplayerView extends FrameLayout implements mReportBandwidth = reportBandwidth; } + public void setAdTagUrl(final Uri uri) { + adTagUrl = uri; + } + public void setRawSrc(final Uri uri, final String extension) { if (uri != null) { boolean isSourceEqual = uri.equals(srcUri); @@ -1917,4 +1975,9 @@ class ReactExoplayerView extends FrameLayout implements public void setSubtitleStyle(SubtitleStyle style) { exoPlayerView.setSubtitleStyle(style); } + + @Override + public void onAdEvent(AdEvent adEvent) { + eventEmitter.receiveAdEvent(adEvent.getType().name()); + } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java index a4b03b58..7a7d8822 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java @@ -31,6 +31,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager IMAAdsLoader? { + return adsLoader + } + + func getAdsManager() -> IMAAdsManager? { + return adsManager + } + + // MARK: - IMAAdsLoaderDelegate + + func adsLoader(_ loader: IMAAdsLoader, adsLoadedWith adsLoadedData: IMAAdsLoadedData) { + // Grab the instance of the IMAAdsManager and set yourself as the delegate. + adsManager = adsLoadedData.adsManager + adsManager?.delegate = self + + + // Create ads rendering settings and tell the SDK to use the in-app browser. + let adsRenderingSettings: IMAAdsRenderingSettings = IMAAdsRenderingSettings(); + adsRenderingSettings.linkOpenerPresentingController = _video.reactViewController(); + + adsManager.initialize(with: adsRenderingSettings) + } + + func adsLoader(_ loader: IMAAdsLoader, failedWith adErrorData: IMAAdLoadingErrorData) { + if adErrorData.adError.message != nil { + print("Error loading ads: " + adErrorData.adError.message!) + } + + _video.setPaused(false) + } + + // MARK: - IMAAdsManagerDelegate + + func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) { + // Play each ad once it has been loaded + if event.type == IMAAdEventType.LOADED { + adsManager.start() + } + + if _video.onReceiveAdEvent != nil { + let type = convertEventToString(event: event.type) + + _video.onReceiveAdEvent?([ + "event": type, + "target": _video.reactTag! + ]); + } + } + + func adsManager(_ adsManager: IMAAdsManager, didReceive error: IMAAdError) { + if error.message != nil { + print("AdsManager error: " + error.message!) + } + + // Fall back to playing content + _video.setPaused(false) + } + + func adsManagerDidRequestContentPause(_ adsManager: IMAAdsManager) { + // Pause the content for the SDK to play ads. + _video.setPaused(true) + _video.setAdPlaying(true) + } + + func adsManagerDidRequestContentResume(_ adsManager: IMAAdsManager) { + // Resume the content since the SDK is done playing ads (at least for now). + _video.setAdPlaying(false) + _video.setPaused(false) + } + + // MARK: - Helpers + + func convertEventToString(event: IMAAdEventType!) -> String { + var result = "UNKNOWN"; + + switch(event) { + case .AD_BREAK_READY: + result = "AD_BREAK_READY"; + break; + case .AD_BREAK_ENDED: + result = "AD_BREAK_ENDED"; + break; + case .AD_BREAK_STARTED: + result = "AD_BREAK_STARTED"; + break; + case .AD_PERIOD_ENDED: + result = "AD_PERIOD_ENDED"; + break; + case .AD_PERIOD_STARTED: + result = "AD_PERIOD_STARTED"; + break; + case .ALL_ADS_COMPLETED: + result = "ALL_ADS_COMPLETED"; + break; + case .CLICKED: + result = "CLICK"; + break; + case .COMPLETE: + result = "COMPLETE"; + break; + case .CUEPOINTS_CHANGED: + result = "CUEPOINTS_CHANGED"; + break; + case .FIRST_QUARTILE: + result = "FIRST_QUARTILE"; + break; + case .LOADED: + result = "LOADED"; + break; + case .LOG: + result = "LOG"; + break; + case .MIDPOINT: + result = "MIDPOINT"; + break; + case .PAUSE: + result = "PAUSED"; + break; + case .RESUME: + result = "RESUMED"; + break; + case .SKIPPED: + result = "SKIPPED"; + break; + case .STARTED: + result = "STARTED"; + break; + case .STREAM_LOADED: + result = "STREAM_LOADED"; + break; + case .TAPPED: + result = "TAPPED"; + break; + case .THIRD_QUARTILE: + result = "THIRD_QUARTILE"; + break; + default: + result = "UNKNOWN"; + } + + return result; + } +} diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index d80e86c8..8e25e158 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -1,6 +1,7 @@ import AVFoundation import AVKit import Foundation +import GoogleInteractiveMediaAds import React import Promises @@ -11,31 +12,31 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _source:VideoSource? private var _playerBufferEmpty:Bool = true private var _playerLayer:AVPlayerLayer? - + private var _playerViewController:RCTVideoPlayerViewController? private var _videoURL:NSURL? - + /* DRM */ private var _drm:DRMParams? - + private var _localSourceEncryptionKeyScheme:String? - + /* Required to publish events */ private var _eventDispatcher:RCTEventDispatcher? private var _videoLoadStarted:Bool = false - + private var _pendingSeek:Bool = false private var _pendingSeekTime:Float = 0.0 private var _lastSeekTime:Float = 0.0 - + /* For sending videoProgress events */ private var _controls:Bool = false - + /* Keep track of any modifiers, need to be applied after each play */ private var _volume:Float = 1.0 private var _rate:Float = 1.0 private var _maxBitRate:Float? - + private var _automaticallyWaitsToMinimizeStalling:Bool = true private var _muted:Bool = false private var _paused:Bool = false @@ -59,18 +60,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _filterName:String! private var _filterEnabled:Bool = false private var _presentingViewController:UIViewController? - + + /* IMA Ads */ + private var _adTagUrl:String? + private var _imaAdsManager: RCTIMAAdsManager! + private var _didRequestAds:Bool = false + private var _adPlaying:Bool = false + /* Playhead used by the SDK to track content video progress and insert mid-rolls. */ + private var _contentPlayhead: IMAAVPlayerContentPlayhead? + private var _resouceLoaderDelegate: RCTResourceLoaderDelegate? private var _playerObserver: RCTPlayerObserver = RCTPlayerObserver() - + #if canImport(RCTVideoCache) private let _videoCache:RCTVideoCachingHandler = RCTVideoCachingHandler() #endif - + #if TARGET_OS_IOS private let _pip:RCTPictureInPicture = RCTPictureInPicture(self.onPictureInPictureStatusChanged, self.onRestoreUserInterfaceForPictureInPictureStop) #endif - + // Events @objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoLoad: RCTDirectEventBlock? @@ -94,33 +103,36 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc var onPictureInPictureStatusChanged: RCTDirectEventBlock? @objc var onRestoreUserInterfaceForPictureInPictureStop: RCTDirectEventBlock? @objc var onGetLicense: RCTDirectEventBlock? - + @objc var onReceiveAdEvent: RCTDirectEventBlock? + init(eventDispatcher:RCTEventDispatcher!) { super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) - + + _imaAdsManager = RCTIMAAdsManager(video: self) + _eventDispatcher = eventDispatcher - + NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActive(notification:)), name: UIApplication.willResignActiveNotification, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(applicationDidEnterBackground(notification:)), name: UIApplication.didEnterBackgroundNotification, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(applicationWillEnterForeground(notification:)), name: UIApplication.willEnterForegroundNotification, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(audioRouteChanged(notification:)), @@ -132,26 +144,28 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _videoCache.playerItemPrepareText = playerItemPrepareText #endif } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) + + _imaAdsManager = RCTIMAAdsManager(video: self) } - + deinit { NotificationCenter.default.removeObserver(self) self.removePlayerLayer() _playerObserver.clearPlayer() } - + // MARK: - App lifecycle handlers - + @objc func applicationWillResignActive(notification:NSNotification!) { if _playInBackground || _playWhenInactive || _paused {return} - + _player?.pause() _player?.rate = 0.0 } - + @objc func applicationDidEnterBackground(notification:NSNotification!) { if _playInBackground { // Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html @@ -159,7 +173,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerViewController?.player = nil } } - + @objc func applicationWillEnterForeground(notification:NSNotification!) { self.applyModifiers() if _playInBackground { @@ -167,9 +181,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerViewController?.player = _player } } - + // MARK: - Audio events - + @objc func audioRouteChanged(notification:NSNotification!) { if let userInfo = notification.userInfo { let reason:AVAudioSession.RouteChangeReason! = userInfo[AVAudioSessionRouteChangeReasonKey] as? AVAudioSession.RouteChangeReason @@ -179,30 +193,34 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } } - + // MARK: - Progress - + func sendProgressUpdate() { if let video = _player?.currentItem, video == nil || video.status != AVPlayerItem.Status.readyToPlay { return } - + let playerDuration:CMTime = RCTVideoUtils.playerItemDuration(_player) if CMTIME_IS_INVALID(playerDuration) { return } - + let currentTime = _player?.currentTime() let currentPlaybackTime = _player?.currentItem?.currentDate() let duration = CMTimeGetSeconds(playerDuration) let currentTimeSecs = CMTimeGetSeconds(currentTime ?? .zero) - + NotificationCenter.default.post(name: NSNotification.Name("RCTVideo_progress"), object: nil, userInfo: [ "progress": NSNumber(value: currentTimeSecs / duration) ]) - + if currentTimeSecs >= 0 { + if !_didRequestAds && currentTimeSecs >= 0.0001 && _adTagUrl != nil { + _imaAdsManager.requestAds() + _didRequestAds = true + } onVideoProgress?([ "currentTime": NSNumber(value: Float(currentTimeSecs)), "playableDuration": RCTVideoUtils.calculatePlayableDuration(_player), @@ -213,9 +231,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH ]) } } - + // MARK: - Player and source - + @objc func setSrc(_ source:NSDictionary!) { _source = VideoSource(source) @@ -228,7 +246,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH removePlayerLayer() _playerObserver.player = nil _playerObserver.playerItem = nil - + // perform on next run loop, otherwise other passed react-props may not be set RCTVideoUtils.delay() .then{ [weak self] in @@ -250,13 +268,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH DebugLog("Could not find video URL in source '\(self._source)'") throw NSError(domain: "", code: 0, userInfo: nil) } - + #if canImport(RCTVideoCache) if self._videoCache.shouldCache(source:source, textTracks:self._textTracks) { return self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions:assetOptions) } #endif - + if self._drm != nil || self._localSourceEncryptionKeyScheme != nil { self._resouceLoaderDelegate = RCTResourceLoaderDelegate( asset: asset, @@ -270,7 +288,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH return Promise{self.playerItemPrepareText(asset: asset, assetOptions:assetOptions)} }.then{[weak self] (playerItem:AVPlayerItem!) in guard let self = self else {throw NSError(domain: "", code: 0, userInfo: nil)} - + self._player?.pause() self._playerItem = playerItem self._playerObserver.playerItem = self._playerItem @@ -279,7 +297,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if let maxBitRate = self._maxBitRate { self._playerItem?.preferredPeakBitRate = Double(maxBitRate) } - + self._player = self._player ?? AVPlayer() DispatchQueue.global(qos: .default).async { self._player?.replaceCurrentItem(with: playerItem) @@ -287,11 +305,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self._playerObserver.player = self._player self.applyModifiers() self._player?.actionAtItemEnd = .none - + if #available(iOS 10.0, *) { self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) } - + + if self._adTagUrl != nil { + // Set up your content playhead and contentComplete callback. + self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!) + + self._imaAdsManager.setUpAdsLoader() + } + //Perform on next run loop, otherwise onVideoLoadStart is nil self.onVideoLoadStart?([ "src": [ @@ -305,22 +330,22 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH }.catch{_ in } _videoLoadStarted = true } - + @objc func setDrm(_ drm:NSDictionary) { _drm = DRMParams(drm) } - + @objc func setLocalSourceEncryptionKeyScheme(_ keyScheme:String) { _localSourceEncryptionKeyScheme = keyScheme } - + func playerItemPrepareText(asset:AVAsset!, assetOptions:NSDictionary?) -> AVPlayerItem { if (_textTracks == nil) || _textTracks?.count==0 { return AVPlayerItem(asset: asset) } - + // AVPlayer can't airplay AVMutableCompositions _allowsExternalPlayback = false let mixComposition = RCTVideoUtils.generateMixComposition(asset) @@ -332,12 +357,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if validTextTracks.count != _textTracks?.count { setTextTracks(validTextTracks) } - + return AVPlayerItem(asset: mixComposition) } - + // MARK: - Prop setters - + @objc func setResizeMode(_ mode: String?) { if _controls { @@ -347,76 +372,84 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } _resizeMode = mode } - + @objc func setPlayInBackground(_ playInBackground:Bool) { _playInBackground = playInBackground } - + @objc func setPreventsDisplaySleepDuringVideoPlayback(_ preventsDisplaySleepDuringVideoPlayback:Bool) { _preventsDisplaySleepDuringVideoPlayback = preventsDisplaySleepDuringVideoPlayback self.applyModifiers() } - + @objc func setAllowsExternalPlayback(_ allowsExternalPlayback:Bool) { _allowsExternalPlayback = allowsExternalPlayback _player?.allowsExternalPlayback = _allowsExternalPlayback } - + @objc func setPlayWhenInactive(_ playWhenInactive:Bool) { _playWhenInactive = playWhenInactive } - + @objc func setPictureInPicture(_ pictureInPicture:Bool) { #if TARGET_OS_IOS _pip.setPictureInPicture(pictureInPicture) #endif } - + @objc func setRestoreUserInterfaceForPIPStopCompletionHandler(_ restore:Bool) { #if TARGET_OS_IOS _pip.setRestoreUserInterfaceForPIPStopCompletionHandler(restore) #endif } - + @objc func setIgnoreSilentSwitch(_ ignoreSilentSwitch:String?) { _ignoreSilentSwitch = ignoreSilentSwitch RCTPlayerOperations.configureAudio(ignoreSilentSwitch:_ignoreSilentSwitch, mixWithOthers:_mixWithOthers) applyModifiers() } - + @objc func setMixWithOthers(_ mixWithOthers:String?) { _mixWithOthers = mixWithOthers applyModifiers() } - + @objc func setPaused(_ paused:Bool) { if paused { - _player?.pause() - _player?.rate = 0.0 + if _adPlaying { + _imaAdsManager.getAdsManager()?.pause() + } else { + _player?.pause() + _player?.rate = 0.0 + } } else { RCTPlayerOperations.configureAudio(ignoreSilentSwitch:_ignoreSilentSwitch, mixWithOthers:_mixWithOthers) - - if #available(iOS 10.0, *), !_automaticallyWaitsToMinimizeStalling { - _player?.playImmediately(atRate: _rate) + + if _adPlaying { + _imaAdsManager.getAdsManager()?.resume() } else { - _player?.play() + if #available(iOS 10.0, *), !_automaticallyWaitsToMinimizeStalling { + _player?.playImmediately(atRate: _rate) + } else { + _player?.play() + _player?.rate = _rate + } _player?.rate = _rate } - _player?.rate = _rate } - + _paused = paused } - + @objc func setCurrentTime(_ currentTime:Float) { let info:NSDictionary = [ @@ -425,7 +458,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH ] setSeek(info) } - + @objc func setSeek(_ info:NSDictionary!) { let seekTime:NSNumber! = info["time"] as! NSNumber @@ -437,7 +470,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH return } let wasPaused = _paused - + RCTPlayerOperations.seek( player:player, playerItem:item, @@ -446,7 +479,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH seekTolerance:seekTolerance.floatValue) .then{ [weak self] (finished:Bool) in guard let self = self else { return } - + self._playerObserver.addTimeObserverIfNotSet() if !wasPaused { self.setPaused(false) @@ -455,34 +488,34 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH "seekTime": seekTime, "target": self.reactTag]) }.catch{_ in } - + _pendingSeek = false } - + @objc func setRate(_ rate:Float) { _rate = rate applyModifiers() } - + @objc func setMuted(_ muted:Bool) { _muted = muted applyModifiers() } - + @objc func setVolume(_ volume:Float) { _volume = volume applyModifiers() } - + @objc func setMaxBitRate(_ maxBitRate:Float) { _maxBitRate = maxBitRate _playerItem?.preferredPeakBitRate = Double(maxBitRate) } - + @objc func setPreferredForwardBufferDuration(_ preferredForwardBufferDuration:Float) { _preferredForwardBufferDuration = preferredForwardBufferDuration @@ -492,7 +525,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // Fallback on earlier versions } } - + @objc func setAutomaticallyWaitsToMinimizeStalling(_ waits:Bool) { _automaticallyWaitsToMinimizeStalling = waits @@ -502,8 +535,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // Fallback on earlier versions } } - - + + func applyModifiers() { if _muted { if !_controls { @@ -514,17 +547,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _player?.volume = _volume _player?.isMuted = false } - + if #available(iOS 12.0, *) { _player?.preventsDisplaySleepDuringVideoPlayback = _preventsDisplaySleepDuringVideoPlayback } else { // Fallback on earlier versions } - + if let _maxBitRate = _maxBitRate { setMaxBitRate(_maxBitRate) } - + setSelectedAudioTrack(_selectedAudioTrackCriteria) setSelectedTextTrack(_selectedTextTrackCriteria) setResizeMode(_resizeMode) @@ -533,30 +566,30 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH setPaused(_paused) setAllowsExternalPlayback(_allowsExternalPlayback) } - + @objc func setRepeat(_ `repeat`: Bool) { _repeat = `repeat` } - - - + + + @objc func setSelectedAudioTrack(_ selectedAudioTrack:NSDictionary?) { setSelectedAudioTrack(SelectedTrackCriteria(selectedAudioTrack)) } - + func setSelectedAudioTrack(_ selectedAudioTrack:SelectedTrackCriteria?) { _selectedAudioTrackCriteria = selectedAudioTrack RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player:_player, characteristic: AVMediaCharacteristic.audible, criteria:_selectedAudioTrackCriteria) } - + @objc func setSelectedTextTrack(_ selectedTextTrack:NSDictionary?) { setSelectedTextTrack(SelectedTrackCriteria(selectedTextTrack)) } - + func setSelectedTextTrack(_ selectedTextTrack:SelectedTrackCriteria?) { _selectedTextTrackCriteria = selectedTextTrack if (_textTracks != nil) { // sideloaded text tracks @@ -566,19 +599,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH criteria:_selectedTextTrackCriteria) } } - + @objc func setTextTracks(_ textTracks:[NSDictionary]?) { setTextTracks(textTracks?.map { TextTrack($0) }) } - + func setTextTracks(_ textTracks:[TextTrack]?) { _textTracks = textTracks - + // in case textTracks was set after selectedTextTrack if (_selectedTextTrackCriteria != nil) {setSelectedTextTrack(_selectedTextTrackCriteria)} } - + @objc func setFullscreen(_ fullscreen:Bool) { if fullscreen && !_fullscreenPlayerPresented && _player != nil { @@ -586,10 +619,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if _playerViewController == nil { self.usePlayerViewController() } - + // Set presentation style to fullscreen _playerViewController?.modalPresentationStyle = .fullScreen - + // Find the nearest view controller var viewController:UIViewController! = self.firstAvailableUIViewController() if (viewController == nil) { @@ -602,16 +635,16 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } if viewController != nil { _presentingViewController = viewController - + self.onVideoFullscreenPlayerWillPresent?(["target": reactTag as Any]) - + viewController.present(viewController, animated:true, completion:{ self._playerViewController?.showsPlaybackControls = true self._fullscreenPlayerPresented = fullscreen self._playerViewController?.autorotate = self._fullscreenAutorotate - + self.onVideoFullscreenPlayerDidPresent?(["target": self.reactTag]) - + }) } } else if !fullscreen && _fullscreenPlayerPresented, let _playerViewController = _playerViewController { @@ -621,7 +654,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH }) } } - + @objc func setFullscreenAutorotate(_ autorotate:Bool) { _fullscreenAutorotate = autorotate @@ -629,7 +662,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerViewController?.autorotate = autorotate } } - + @objc func setFullscreenOrientation(_ orientation:String?) { _fullscreenOrientation = orientation @@ -637,50 +670,50 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerViewController?.preferredOrientation = orientation } } - + func usePlayerViewController() { guard let _player = _player, let _playerItem = _playerItem else { return } - + if _playerViewController == nil { _playerViewController = createPlayerViewController(player:_player, withPlayerItem:_playerItem) } // to prevent video from being animated when resizeMode is 'cover' // resize mode must be set before subview is added setResizeMode(_resizeMode) - + guard let _playerViewController = _playerViewController else { return } - + if _controls { let viewController:UIViewController! = self.reactViewController() viewController?.addChild(_playerViewController) self.addSubview(_playerViewController.view) } - + _playerObserver.playerViewController = _playerViewController } - + func createPlayerViewController(player:AVPlayer, withPlayerItem playerItem:AVPlayerItem) -> RCTVideoPlayerViewController { let viewController = RCTVideoPlayerViewController() viewController.showsPlaybackControls = true viewController.rctDelegate = self viewController.preferredOrientation = _fullscreenOrientation - + viewController.view.frame = self.bounds viewController.player = player return viewController } - + func usePlayerLayer() { if let _player = _player { _playerLayer = AVPlayerLayer(player: _player) _playerLayer?.frame = self.bounds _playerLayer?.needsDisplayOnBoundsChange = true - + // to prevent video from being animated when resizeMode is 'cover' // resize mode must be set before layer is added setResizeMode(_resizeMode) _playerObserver.playerLayer = _playerLayer - + if let _playerLayer = _playerLayer { self.layer.addSublayer(_playerLayer) } @@ -690,7 +723,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH #endif } } - + @objc func setControls(_ controls:Bool) { if _controls != controls || ((_playerLayer == nil) && (_playerViewController == nil)) @@ -711,29 +744,29 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } } - + @objc func setProgressUpdateInterval(_ progressUpdateInterval:Float) { _playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval)) } - + func removePlayerLayer() { _resouceLoaderDelegate = nil _playerLayer?.removeFromSuperlayer() _playerLayer = nil _playerObserver.playerLayer = nil } - + // MARK: - RCTVideoPlayerViewControllerDelegate - + func videoPlayerViewControllerWillDismiss(playerViewController:AVPlayerViewController) { if _playerViewController == playerViewController && _fullscreenPlayerPresented, let onVideoFullscreenPlayerWillDismiss = onVideoFullscreenPlayerWillDismiss { _playerObserver.removePlayerViewControllerObservers() onVideoFullscreenPlayerWillDismiss(["target": reactTag as Any]) } } - - + + func videoPlayerViewControllerDidDismiss(playerViewController:AVPlayerViewController) { if _playerViewController == playerViewController && _fullscreenPlayerPresented { _fullscreenPlayerPresented = false @@ -741,15 +774,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerViewController = nil _playerObserver.playerViewController = nil self.applyModifiers() - + onVideoFullscreenPlayerDidDismiss?(["target": reactTag as Any]) } } - + @objc func setFilter(_ filterName:String!) { _filterName = filterName - + if !_filterEnabled { return } else if let uri = _source?.uri, uri.contains("m3u8") { @@ -757,7 +790,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } else if _playerItem?.asset == nil { return } - + let filter:CIFilter! = CIFilter(name: filterName) if #available(iOS 9.0, *), let _playerItem = _playerItem { self._playerItem?.videoComposition = AVVideoComposition( @@ -776,21 +809,40 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // Fallback on earlier versions } } - + @objc func setFilterEnabled(_ filterEnabled:Bool) { _filterEnabled = filterEnabled } - + + // MARK: - RCTIMAAdsManager + + func getAdTagUrl() -> String? { + return _adTagUrl + } + + @objc + func setAdTagUrl(_ adTagUrl:String!) { + _adTagUrl = adTagUrl + } + + func getContentPlayhead() -> IMAAVPlayerContentPlayhead? { + return _contentPlayhead + } + + func setAdPlaying(_ adPlaying:Bool) { + _adPlaying = adPlaying + } + // MARK: - React View Management - + func insertReactSubview(view:UIView!, atIndex:Int) { // We are early in the game and somebody wants to set a subview. // That can only be in the context of playerViewController. if !_controls && (_playerLayer == nil) && (_playerViewController == nil) { setControls(true) } - + if _controls { view.frame = self.bounds _playerViewController?.contentOverlayView?.insertSubview(view, at:atIndex) @@ -799,7 +851,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } return } - + func removeReactSubview(subview:UIView!) { if _controls { subview.removeFromSuperview() @@ -808,12 +860,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } return } - + override func layoutSubviews() { super.layoutSubviews() if _controls, let _playerViewController = _playerViewController { _playerViewController.view.frame = bounds - + // also adjust all subviews of contentOverlayView for subview in _playerViewController.contentOverlayView?.subviews ?? [] { subview.frame = bounds @@ -825,16 +877,16 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH CATransaction.commit() } } - + // MARK: - Lifecycle - + override func removeFromSuperview() { _player?.pause() _player = nil _playerObserver.clearPlayer() - + self.removePlayerLayer() - + if let _playerViewController = _playerViewController { _playerViewController.view.removeFromSuperview() _playerViewController.removeFromParent() @@ -843,15 +895,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self._playerViewController = nil _playerObserver.playerViewController = nil } - + _eventDispatcher = nil NotificationCenter.default.removeObserver(self) - + super.removeFromSuperview() } - + // MARK: - Export - + @objc func save(options:NSDictionary!, resolve: @escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) { RCTVideoSave.save( @@ -861,80 +913,80 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH playerItem:_playerItem ) } - + func setLicenseResult(_ license:String!) { _resouceLoaderDelegate?.setLicenseResult(license) } - + func setLicenseResultError(_ error:String!) { _resouceLoaderDelegate?.setLicenseResultError(error) } - + // MARK: - RCTPlayerObserverHandler - + func handleTimeUpdate(time:CMTime) { sendProgressUpdate() } - + func handleReadyForDisplay(changeObject: Any, change:NSKeyValueObservedChange) { onReadyForDisplay?([ "target": reactTag ]) } - + // When timeMetadata is read the event onTimedMetadata is triggered func handleTimeMetadataChange(playerItem:AVPlayerItem, change:NSKeyValueObservedChange<[AVMetadataItem]?>) { guard let newValue = change.newValue, let _items = newValue, _items.count > 0 else { return } - + var metadata: [[String:String?]?] = [] for item in _items { let value = item.value as? String let identifier = item.identifier?.rawValue - + if let value = value { metadata.append(["value":value, "identifier":identifier]) } } - + onTimedMetadata?([ "target": reactTag, "metadata": metadata ]) } - + // Handle player item status change. func handlePlayerItemStatusChange(playerItem:AVPlayerItem, change:NSKeyValueObservedChange) { guard let _playerItem = _playerItem else { return } - + if _playerItem.status == .readyToPlay { handleReadyToPlay() } else if _playerItem.status == .failed { handlePlaybackFailed() } } - + func handleReadyToPlay() { guard let _playerItem = _playerItem else { return } var duration:Float = Float(CMTimeGetSeconds(_playerItem.asset.duration)) - + if duration.isNaN { duration = 0.0 } - + var width: Float? = nil var height: Float? = nil var orientation = "undefined" - + if _playerItem.asset.tracks(withMediaType: AVMediaType.video).count > 0 { let videoTrack = _playerItem.asset.tracks(withMediaType: .video)[0] width = Float(videoTrack.naturalSize.width) height = Float(videoTrack.naturalSize.height) let preferredTransform = videoTrack.preferredTransform - + if (videoTrack.naturalSize.width == preferredTransform.tx && videoTrack.naturalSize.height == preferredTransform.ty) || (preferredTransform.tx == 0 && preferredTransform.ty == 0) @@ -948,12 +1000,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH height = Float(_playerItem.presentationSize.height) orientation = _playerItem.presentationSize.width > _playerItem.presentationSize.height ? "landscape" : "portrait" } - + if _pendingSeek { setCurrentTime(_pendingSeekTime) _pendingSeek = false } - + if _videoLoadStarted { onVideoLoad?(["duration": NSNumber(value: duration), "currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))), @@ -976,7 +1028,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerObserver.attachPlayerEventListeners() applyModifiers() } - + func handlePlaybackFailed() { guard let _playerItem = _playerItem else { return } onVideoError?( @@ -991,12 +1043,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH "target": reactTag ]) } - + func handlePlaybackBufferKeyEmpty(playerItem:AVPlayerItem, change:NSKeyValueObservedChange) { _playerBufferEmpty = true onVideoBuffer?(["isBuffering": true, "target": reactTag as Any]) } - + // Continue playing (or not if paused) after being paused due to hitting an unbuffered zone. func handlePlaybackLikelyToKeepUp(playerItem:AVPlayerItem, change:NSKeyValueObservedChange) { if (!(_controls || _fullscreenPlayerPresented) || _playerBufferEmpty) && ((_playerItem?.isPlaybackLikelyToKeepUp) != nil) { @@ -1005,7 +1057,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playerBufferEmpty = false onVideoBuffer?(["isBuffering": false, "target": reactTag as Any]) } - + func handlePlaybackRateChange(player: AVPlayer, change: NSKeyValueObservedChange) { guard let _player = _player else { return } onPlaybackRateChange?(["playbackRate": NSNumber(value: _player.rate), @@ -1016,26 +1068,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _playbackStalled = false } } - + func handleExternalPlaybackActiveChange(player: AVPlayer, change: NSKeyValueObservedChange) { guard let _player = _player else { return } onVideoExternalPlaybackChange?(["isExternalPlaybackActive": NSNumber(value: _player.isExternalPlaybackActive), "target": reactTag as Any]) } - + func handleViewControllerOverlayViewFrameChange(overlayView:UIView, change:NSKeyValueObservedChange) { let oldRect = change.oldValue let newRect = change.newValue if !oldRect!.equalTo(newRect!) { if newRect!.equalTo(UIScreen.main.bounds) { NSLog("in fullscreen") - + self.reactViewController().view.frame = UIScreen.main.bounds self.reactViewController().view.setNeedsLayout() } else {NSLog("not fullscreen")} } } - + @objc func handleDidFailToFinishPlaying(notification:NSNotification!) { let error:NSError! = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError onVideoError?( @@ -1050,25 +1102,29 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH "target": reactTag ]) } - + @objc func handlePlaybackStalled(notification:NSNotification!) { onPlaybackStalled?(["target": reactTag as Any]) _playbackStalled = true } - + @objc func handlePlayerItemDidReachEnd(notification:NSNotification!) { onVideoEnd?(["target": reactTag as Any]) - + + if notification.object as? AVPlayerItem == _player?.currentItem { + _imaAdsManager.getAdsLoader()?.contentComplete() + } + if _repeat { let item:AVPlayerItem! = notification.object as? AVPlayerItem - item.seek(to: CMTime.zero) + item.seek(to: CMTime.zero, completionHandler: nil) self.applyModifiers() } else { self.setPaused(true); _playerObserver.removePlayerTimeObserver() } } - + //unused // @objc func handleAVPlayerAccess(notification:NSNotification!) { // let accessLog:AVPlayerItemAccessLog! = (notification.object as! AVPlayerItem).accessLog() diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 9979b440..2781656b 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -5,6 +5,7 @@ RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(drm, NSDictionary); +RCT_EXPORT_VIEW_PROPERTY(adTagUrl, NSString); RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float); RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString); RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL); @@ -59,6 +60,7 @@ RCT_EXPORT_VIEW_PROPERTY(onVideoExternalPlaybackChange, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onGetLicense, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onPictureInPictureStatusChanged, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onRestoreUserInterfaceForPictureInPictureStop, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onReceiveAdEvent, RCTDirectEventBlock); RCT_EXTERN_METHOD(save:(NSDictionary *)options reactTag:(nonnull NSNumber *)reactTag diff --git a/react-native-video.podspec b/react-native-video.podspec index 1e2cd112..c0c4a86a 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -18,6 +18,9 @@ Pod::Spec.new do |s| s.subspec "Video" do |ss| ss.source_files = "ios/Video/**/*.{h,m,swift}" ss.dependency "PromisesSwift" + + ss.ios.dependency 'GoogleAds-IMA-iOS-SDK', '~> 3.18.1' + ss.tvos.dependency 'GoogleAds-IMA-tvOS-SDK', '~> 4.2' end s.subspec "VideoCaching" do |ss|