diff --git a/android-exoplayer/build.gradle b/android-exoplayer/build.gradle index 502fa599..ac7acd85 100644 --- a/android-exoplayer/build.gradle +++ b/android-exoplayer/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation('com.google.android.exoplayer:exoplayer:2.10.5') { exclude group: 'com.android.support' } + implementation 'com.google.android.exoplayer:extension-ima:2.10.5' // All support libs must use the same version implementation "androidx.annotation:annotation:1.1.0" diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java index 9f3d09be..a8d6311a 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -18,16 +18,19 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.exoplayer2.util.Assertions; import java.util.List; +import java.util.ArrayList; @TargetApi(16) -public final class ExoPlayerView extends FrameLayout { +public final class ExoPlayerView extends FrameLayout implements AdsLoader.AdViewProvider { private View surfaceView; private final View shutterView; @@ -37,6 +40,7 @@ public final class ExoPlayerView extends FrameLayout { private SimpleExoPlayer player; private Context context; private ViewGroup.LayoutParams layoutParams; + private final FrameLayout adOverlayFrameLayout; private boolean useTextureView = true; private boolean hideShutterView = false; @@ -81,7 +85,11 @@ public final class ExoPlayerView extends FrameLayout { layout.addView(shutterView, 1, layoutParams); layout.addView(subtitleLayout, 2, layoutParams); + adOverlayFrameLayout = new FrameLayout(context); + addViewInLayout(layout, 0, aspectRatioParams); + addViewInLayout(adOverlayFrameLayout, 1, layoutParams); + } private void setVideoView() { @@ -111,6 +119,31 @@ 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"); + } + + @Override + public View[] getAdOverlayViews() { + ArrayList overlayViews = new ArrayList<>(); + if (adOverlayFrameLayout != null) { + overlayViews.add(adOverlayFrameLayout); + } + // if (controller != null) { + // overlayViews.add(controller); + // } + return overlayViews.toArray(new View[0]); + } + /** * Set the {@link SimpleExoPlayer} to use. The {@link SimpleExoPlayer#setTextOutput} and * {@link SimpleExoPlayer#setVideoListener} method of the player will be called and previous 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 4802a5a4..0572f66a 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -63,9 +63,13 @@ import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; + import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; +import java.net.URI; import java.util.ArrayList; import java.util.Locale; import java.util.Map; @@ -77,7 +81,8 @@ class ReactExoplayerView extends FrameLayout implements BandwidthMeter.EventListener, BecomingNoisyListener, AudioManager.OnAudioFocusChangeListener, - MetadataOutput { + MetadataOutput, + AdsMediaSource.MediaSourceFactory { private static final String TAG = "ReactExoplayerView"; @@ -97,6 +102,7 @@ class ReactExoplayerView extends FrameLayout implements private Player.EventListener eventListener; private ExoPlayerView exoPlayerView; + private ImaAdsLoader adsLoader; private DataSource.Factory mediaDataSourceFactory; private SimpleExoPlayer player; @@ -139,6 +145,7 @@ class ReactExoplayerView extends FrameLayout implements private Map requestHeaders; private boolean mReportBandwidth = false; private boolean controls; + private Uri adTagUrl; // \ End props // React @@ -155,6 +162,9 @@ class ReactExoplayerView extends FrameLayout implements && player.getPlaybackState() == Player.STATE_READY && player.getPlayWhenReady() ) { + if (isPlayingAd()) { + playerControlView.hide(); + } long pos = player.getCurrentPosition(); long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100; eventEmitter.progressChanged(pos, bufferedDuration, player.getDuration()); @@ -173,6 +183,8 @@ class ReactExoplayerView extends FrameLayout implements this.config = config; this.bandwidthMeter = config.getBandwidthMeter(); + adsLoader = new ImaAdsLoader(this.themedReactContext, Uri.EMPTY); + createViews(); audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); @@ -182,6 +194,10 @@ class ReactExoplayerView extends FrameLayout implements initializePlayer(); } + private boolean isPlayingAd() { + return player != null && player.isPlayingAd() && player.getPlayWhenReady(); + } + @Override public void setId(int id) { @@ -288,7 +304,9 @@ class ReactExoplayerView extends FrameLayout implements exoPlayerView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { - togglePlayerControlVisibility(); + if (!isPlayingAd()) { + togglePlayerControlVisibility(); + } } }); @@ -360,6 +378,7 @@ class ReactExoplayerView extends FrameLayout implements trackSelector, defaultLoadControl, null, bandwidthMeter); player.addListener(self); player.addMetadataOutput(self); + adsLoader.setPlayer(player); exoPlayerView.setPlayer(player); audioBecomingNoisyReceiver.setListener(self); bandwidthMeter.addEventListener(new Handler(), self); @@ -372,11 +391,12 @@ class ReactExoplayerView extends FrameLayout implements if (playerNeedsSource && srcUri != null) { ArrayList mediaSourceList = buildTextSources(); MediaSource videoSource = buildMediaSource(srcUri, extension); + MediaSource mediaSourceWithAds = new AdsMediaSource(videoSource, mediaDataSourceFactory, adsLoader, exoPlayerView); MediaSource mediaSource; if (mediaSourceList.size() == 0) { - mediaSource = videoSource; + mediaSource = mediaSourceWithAds; } else { - mediaSourceList.add(0, videoSource); + mediaSourceList.add(0, mediaSourceWithAds); MediaSource[] textSourceArray = mediaSourceList.toArray( new MediaSource[mediaSourceList.size()] ); @@ -402,6 +422,19 @@ class ReactExoplayerView extends FrameLayout implements }, 1); } + // AdsMediaSource.MediaSourceFactory implementation. + + @Override + public MediaSource createMediaSource(Uri uri) { + return buildMediaSource(uri, extension); + } + + @Override + public int[] getSupportedTypes() { + // IMA does not support Smooth Streaming ads. + return new int[] {C.TYPE_DASH, C.TYPE_HLS, C.TYPE_OTHER}; + } + private MediaSource buildMediaSource(Uri uri, String overrideExtension) { int type = Util.inferContentType(!TextUtils.isEmpty(overrideExtension) ? "." + overrideExtension : uri.getLastPathSegment()); @@ -473,6 +506,7 @@ class ReactExoplayerView extends FrameLayout implements trackSelector = null; player = null; } + adsLoader.release(); progressHandler.removeMessages(SHOW_PROGRESS); themedReactContext.removeLifecycleEventListener(this); audioBecomingNoisyReceiver.removeListener(); @@ -914,6 +948,11 @@ class ReactExoplayerView extends FrameLayout implements mReportBandwidth = reportBandwidth; } + public void setAdTagUrl(final Uri uri) { + adTagUrl = uri; + adsLoader = new ImaAdsLoader(this.themedReactContext, adTagUrl); + } + public void setRawSrc(final Uri uri, final String extension) { if (uri != null) { boolean isOriginalSourceNull = srcUri == null; 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 c7c96346..b793a882 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java @@ -25,6 +25,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager diff --git a/examples/basic/index.ios.js b/examples/basic/index.ios.js index 1bc0ac47..ed2675f6 100644 --- a/examples/basic/index.ios.js +++ b/examples/basic/index.ios.js @@ -15,6 +15,11 @@ import { import Video,{FilterType} from 'react-native-video'; +const adTagUrl = "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/" ++ "ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp" ++ "&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite" ++ "%26sample_ar%3Dpremidpost&cmsid=496&vid=short_onecue&correlator="; + const filterTypes = [ FilterType.NONE, FilterType.INVERT, @@ -266,6 +271,7 @@ class VideoPlayer extends Component { controls={this.state.controls} filter={this.state.filter} filterEnabled={this.state.filterEnabled} + adTagUrl={adTagUrl} /> diff --git a/examples/basic/ios/Podfile b/examples/basic/ios/Podfile new file mode 100644 index 00000000..0b90640f --- /dev/null +++ b/examples/basic/ios/Podfile @@ -0,0 +1,48 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '9.0' + +target 'VideoPlayer' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + pod 'FBLazyVector', :path => "../node_modules/react-native/Libraries/FBLazyVector" + pod 'FBReactNativeSpec', :path => "../node_modules/react-native/Libraries/FBReactNativeSpec" + pod 'RCTRequired', :path => "../node_modules/react-native/Libraries/RCTRequired" + pod 'RCTTypeSafety', :path => "../node_modules/react-native/Libraries/TypeSafety" + pod 'React', :path => '../node_modules/react-native/' + pod 'React-Core', :path => '../node_modules/react-native/' + pod 'React-CoreModules', :path => '../node_modules/react-native/React/CoreModules' + pod 'React-Core/DevSupport', :path => '../node_modules/react-native/' + pod 'React-RCTActionSheet', :path => '../node_modules/react-native/Libraries/ActionSheetIOS' + pod 'React-RCTAnimation', :path => '../node_modules/react-native/Libraries/NativeAnimation' + pod 'React-RCTBlob', :path => '../node_modules/react-native/Libraries/Blob' + pod 'React-RCTImage', :path => '../node_modules/react-native/Libraries/Image' + pod 'React-RCTLinking', :path => '../node_modules/react-native/Libraries/LinkingIOS' + pod 'React-RCTNetwork', :path => '../node_modules/react-native/Libraries/Network' + pod 'React-RCTSettings', :path => '../node_modules/react-native/Libraries/Settings' + pod 'React-RCTText', :path => '../node_modules/react-native/Libraries/Text' + pod 'React-RCTVibration', :path => '../node_modules/react-native/Libraries/Vibration' + pod 'React-Core/RCTWebSocket', :path => '../node_modules/react-native/' + + pod 'React-cxxreact', :path => '../node_modules/react-native/ReactCommon/cxxreact' + pod 'React-jsi', :path => '../node_modules/react-native/ReactCommon/jsi' + pod 'React-jsiexecutor', :path => '../node_modules/react-native/ReactCommon/jsiexecutor' + pod 'React-jsinspector', :path => '../node_modules/react-native/ReactCommon/jsinspector' + pod 'ReactCommon/jscallinvoker', :path => "../node_modules/react-native/ReactCommon" + pod 'ReactCommon/turbomodule/core', :path => "../node_modules/react-native/ReactCommon" + pod 'Yoga', :path => '../node_modules/react-native/ReactCommon/yoga' + + pod 'DoubleConversion', :podspec => '../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec' + pod 'glog', :podspec => '../node_modules/react-native/third-party-podspecs/glog.podspec' + pod 'Folly', :podspec => '../node_modules/react-native/third-party-podspecs/Folly.podspec' + + # Pods for VideoPlayer + pod 'react-native-video', :path => '../node_modules/react-native-video' +end + +target 'VideoPlayer-tvOS' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for VideoPlayer-tvOS + +end diff --git a/examples/basic/package.json b/examples/basic/package.json index 057dcac7..83a991ac 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "react": "16.9.0", - "react-native": "0.60.5", + "react-native": "0.61.5", "react-native-video": "file:../.." }, "devDependencies": { diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index 26d436c2..bf9eac1f 100644 --- a/ios/Video/RCTVideo.h +++ b/ios/Video/RCTVideo.h @@ -5,6 +5,7 @@ #import "RCTVideoPlayerViewControllerDelegate.h" #import #import +@import GoogleInteractiveMediaAds; #if __has_include() #import @@ -14,11 +15,11 @@ @class RCTEventDispatcher; #if __has_include() -@interface RCTVideo : UIView +@interface RCTVideo : UIView #elif TARGET_OS_TV @interface RCTVideo : UIView #else -@interface RCTVideo : UIView +@interface RCTVideo : UIView #endif @property (nonatomic, copy) RCTDirectEventBlock onVideoLoadStart; @@ -42,6 +43,12 @@ @property (nonatomic, copy) RCTDirectEventBlock onVideoExternalPlaybackChange; @property (nonatomic, copy) RCTDirectEventBlock onPictureInPictureStatusChanged; @property (nonatomic, copy) RCTDirectEventBlock onRestoreUserInterfaceForPictureInPictureStop; +/// Playhead used by the SDK to track content video progress and insert mid-rolls. +@property(nonatomic, strong) IMAAVPlayerContentPlayhead *contentPlayhead; +/// Entry point for the SDK. Used to make ad requests. +@property(nonatomic, strong) IMAAdsLoader *adsLoader; +/// Main point of interaction with the SDK. Created by the SDK as the result of an ad request. +@property(nonatomic, strong) IMAAdsManager *adsManager; - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER; diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m index 113d9f23..46ab23d0 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -39,6 +39,7 @@ static int const RCTVideoUnset = -1; BOOL _playbackRateObserverRegistered; BOOL _isExternalPlaybackActiveObserverRegistered; BOOL _videoLoadStarted; + BOOL _isRequestAds; bool _pendingSeek; float _pendingSeekTime; @@ -73,6 +74,7 @@ static int const RCTVideoUnset = -1; NSString * _fullscreenOrientation; BOOL _fullscreenPlayerPresented; NSString *_filterName; + NSString * _adTagUrl; BOOL _filterEnabled; UIViewController * _presentingViewController; #if __has_include() @@ -107,6 +109,7 @@ static int const RCTVideoUnset = -1; _allowsExternalPlayback = YES; _playWhenInactive = false; _pictureInPicture = false; + _isRequestAds = false; _ignoreSilentSwitch = @"inherit"; // inherit, ignore, obey #if TARGET_OS_IOS _restoreUserInterfaceForPIPStopCompletionHandler = NULL; @@ -144,6 +147,8 @@ static int const RCTVideoUnset = -1; viewController.showsPlaybackControls = YES; viewController.rctDelegate = self; viewController.preferredOrientation = _fullscreenOrientation; + self.contentPlayhead = [[IMAAVPlayerContentPlayhead alloc] initWithAVPlayer:player]; + [self setupAdsLoader]; viewController.view.frame = self.bounds; viewController.player = player; @@ -267,8 +272,12 @@ static int const RCTVideoUnset = -1; const Float64 currentTimeSecs = CMTimeGetSeconds(currentTime); [[NSNotificationCenter defaultCenter] postNotificationName:@"RCTVideo_progress" object:nil userInfo:@{@"progress": [NSNumber numberWithDouble: currentTimeSecs / duration]}]; - + if( currentTimeSecs >= 0 && self.onVideoProgress) { + if(!_isRequestAds && !_paused) { + [self requestAds]; + _isRequestAds = true; + } self.onVideoProgress(@{ @"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(currentTime)], @"playableDuration": [self calculatePlayableDuration], @@ -659,7 +668,6 @@ static int const RCTVideoUnset = -1; @"target": self.reactTag}); } _videoLoadStarted = NO; - [self attachListeners]; [self applyModifiers]; } else if (_playerItem.status == AVPlayerItemStatusFailed && self.onVideoError) { @@ -719,6 +727,74 @@ static int const RCTVideoUnset = -1; } } +- (void)setupAdsLoader { + // Re-use this IMAAdsLoader instance for the entire lifecycle of your app. + self.adsLoader = [[IMAAdsLoader alloc] initWithSettings:nil]; + // NOTE: This line will cause a warning until the next step, "Get the Ads Manager". + self.adsLoader.delegate = self; +} + +- (void)requestAds { + // Create an ad display container for ad rendering. + IMAAdDisplayContainer *adDisplayContainer = + [[IMAAdDisplayContainer alloc] initWithAdContainer:self companionSlots:nil]; + // Create an ad request with our ad tag, display container, and optional user context. + IMAAdsRequest *request = [[IMAAdsRequest alloc] initWithAdTagUrl:_adTagUrl + adDisplayContainer:adDisplayContainer + contentPlayhead:self.contentPlayhead + userContext:nil]; + [self.adsLoader requestAdsWithRequest:request]; +} + +#pragma mark AdsLoader Delegates + +- (void)adsLoader:(IMAAdsLoader *)loader adsLoadedWithData:(IMAAdsLoadedData *)adsLoadedData { + // Grab the instance of the IMAAdsManager and set ourselves as the delegate. + self.adsManager = adsLoadedData.adsManager; + + // NOTE: This line will cause a warning until the next step, "Display Ads". + self.adsManager.delegate = self; + + // Create ads rendering settings and tell the SDK to use the in-app browser. + IMAAdsRenderingSettings *adsRenderingSettings = [[IMAAdsRenderingSettings alloc] init]; + adsRenderingSettings.webOpenerPresentingController = _playerViewController; + + // Initialize the ads manager. + [self.adsManager initializeWithAdsRenderingSettings:adsRenderingSettings]; +} + +- (void)adsLoader:(IMAAdsLoader *)loader failedWithErrorData:(IMAAdLoadingErrorData *)adErrorData { + // Something went wrong loading ads. Log the error and play the content. + NSLog(@"Error loading ads: %@", adErrorData.adError.message); + [_player play]; +} + +#pragma mark AdsManager Delegates + +- (void)adsManager:(IMAAdsManager *)adsManager didReceiveAdEvent:(IMAAdEvent *)event { + if (event.type == kIMAAdEvent_LOADED) { + // When the SDK notifies us that ads have been loaded, play them. + [adsManager start]; + } +} + +- (void)adsManager:(IMAAdsManager *)adsManager didReceiveAdError:(IMAAdError *)error { + // Something went wrong with the ads manager after ads were loaded. Log the error and play the + // content. + NSLog(@"AdsManager error: %@", error.message); + [_player play]; +} + +- (void)adsManagerDidRequestContentPause:(IMAAdsManager *)adsManager { + // The SDK is going to play ads, so pause the content. + [_player pause]; +} + +- (void)adsManagerDidRequestContentResume:(IMAAdsManager *)adsManager { + // The SDK is done playing ads (at least for now), so resume the content. + [_player play]; +} + - (void)attachListeners { // listen for end of file @@ -769,6 +845,9 @@ static int const RCTVideoUnset = -1; - (void)playerItemDidReachEnd:(NSNotification *)notification { + if (notification.object == _player.currentItem) { + [self.adsLoader contentComplete]; + } if(self.onVideoEnd) { self.onVideoEnd(@{@"target": self.reactTag}); } @@ -868,7 +947,7 @@ static int const RCTVideoUnset = -1; } else if([_ignoreSilentSwitch isEqualToString:@"obey"]) { [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil]; } - + if (@available(iOS 10.0, *) && !_automaticallyWaitsToMinimizeStalling) { [_player playImmediatelyAtRate:_rate]; } else { @@ -1434,6 +1513,10 @@ static int const RCTVideoUnset = -1; _filterEnabled = filterEnabled; } +- (void)setAdTagUrl:(NSString *)adTagUrl { + _adTagUrl = adTagUrl; +} + #pragma mark - React View Management - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 7233646f..a817ec8d 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(adTagUrl, NSString); RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float); RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString); RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL); diff --git a/react-native-video.podspec b/react-native-video.podspec index 98ba5537..6a522748 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -12,10 +12,12 @@ Pod::Spec.new do |s| s.homepage = 'https://github.com/react-native-community/react-native-video' s.source = { :git => "https://github.com/react-native-community/react-native-video.git", :tag => "#{s.version}" } - s.ios.deployment_target = "8.0" + s.ios.deployment_target = "9.0" s.tvos.deployment_target = "9.0" s.subspec "Video" do |ss| + ss.dependency "GoogleAds-IMA-iOS-SDK", "~> 3.9" + ss.source_files = "ios/Video/*.{h,m}" s.static_framework = true end @@ -29,7 +31,6 @@ Pod::Spec.new do |s| s.static_framework = true end - s.dependency "React" - + s.dependency 'React' s.default_subspec = "Video" end