diff --git a/README.md b/README.md index 986312e4..3675f158 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ -# react-native-video +## react-native-video-inc-ads +an addon property (**adTagUrl**) to support google ima on react-native-video. +Thanks to https://github.com/RobbyWH for his great work. I just merged his ima branch with latest react-native-video branch. + +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="; + +In case of any issue, one can follow RobbyWH's comments on the issue : https://github.com/react-native-video/react-native-video/issues/488 :) + +A new function property `onReceiveAdEvent` added. this is used to notify ad events from native component to react component. + +## react-native-video > :warning: **Version 6 Alpha**: The following documentation may refer to features only available through the v6.0.0 alpha releases, [please see version 5.2.x](https://github.com/react-native-video/react-native-video/blob/v5.2.0/README.md) for the current documentation! diff --git a/Video.js b/Video.js index 0a449e43..5a794b56 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,7 @@ Video.propTypes = { onPictureInPictureStatusChanged: PropTypes.func, needsToRestoreUserInterfaceForPictureInPictureStop: PropTypes.func, onExternalPlaybackChange: PropTypes.func, + onReceiveAdEvents: PropTypes.func, /* Required by react-native */ scaleX: PropTypes.number, diff --git a/android-exoplayer/build.gradle b/android-exoplayer/build.gradle new file mode 100644 index 00000000..13eb889d --- /dev/null +++ b/android-exoplayer/build.gradle @@ -0,0 +1,77 @@ +apply plugin: 'com.android.library' + +def safeExtGet(prop, fallback) { + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +} + +android { + compileSdkVersion safeExtGet('compileSdkVersion', 28) + buildToolsVersion safeExtGet('buildToolsVersion', '28.0.3') + + compileOptions { + targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion safeExtGet('minSdkVersion', 16) + targetSdkVersion safeExtGet('targetSdkVersion', 28) + versionCode 1 + versionName "1.0" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" + implementation('com.google.android.exoplayer:exoplayer:2.11.4') { + exclude group: 'com.android.support' + } + + // All support libs must use the same version + implementation "androidx.annotation:annotation:1.1.0" + implementation "androidx.core:core:1.1.0" + implementation "androidx.media:media:1.1.0" + + implementation('com.google.android.exoplayer:extension-okhttp:2.11.4') { + exclude group: 'com.squareup.okhttp3', module: 'okhttp' + } + implementation 'com.google.android.exoplayer:extension-ima:2.11.4' + + implementation 'com.squareup.okhttp3:okhttp:${OKHTTP_VERSION}' + +} + + +/* If one wants to open this module in Android studio. Uncomment these repositories and buildscript parts*/ + +/* +repositories { + maven { + url 'https://maven.google.com/' + name 'Google' + } + jcenter() + google() + mavenCentral() +} + +buildscript { + repositories { + maven { + url 'https://maven.google.com/' + name 'Google' + } + jcenter() + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.5.3' + } +} +*/ \ No newline at end of file diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java new file mode 100644 index 00000000..75512628 --- /dev/null +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -0,0 +1,319 @@ +package com.brentvatne.exoplayer; + +import android.annotation.TargetApi; +import android.content.Context; +import androidx.core.content.ContextCompat; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +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.text.TextOutput; +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 implements AdsLoader.AdViewProvider { + + private View surfaceView; + private final View shutterView; + private final SubtitleView subtitleLayout; + private final AspectRatioFrameLayout layout; + private final ComponentListener componentListener; + private SimpleExoPlayer player; + private Context context; + private ViewGroup.LayoutParams layoutParams; + private final FrameLayout adOverlayFrameLayout; + + private boolean useTextureView = true; + private boolean hideShutterView = false; + + public ExoPlayerView(Context context) { + this(context, null); + } + + public ExoPlayerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + this.context = context; + + layoutParams = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + + componentListener = new ComponentListener(); + + FrameLayout.LayoutParams aspectRatioParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT); + aspectRatioParams.gravity = Gravity.CENTER; + layout = new AspectRatioFrameLayout(context); + layout.setLayoutParams(aspectRatioParams); + + shutterView = new View(getContext()); + shutterView.setLayoutParams(layoutParams); + shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black)); + + subtitleLayout = new SubtitleView(context); + subtitleLayout.setLayoutParams(layoutParams); + subtitleLayout.setUserDefaultStyle(); + subtitleLayout.setUserDefaultTextSize(); + + updateSurfaceView(); + + 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() { + if (surfaceView instanceof TextureView) { + player.setVideoTextureView((TextureView) surfaceView); + } else if (surfaceView instanceof SurfaceView) { + player.setVideoSurfaceView((SurfaceView) surfaceView); + } + } + + private void updateSurfaceView() { + View view = useTextureView ? new TextureView(context) : new SurfaceView(context); + view.setLayoutParams(layoutParams); + + surfaceView = view; + if (layout.getChildAt(0) != null) { + layout.removeViewAt(0); + } + layout.addView(surfaceView, 0, layoutParams); + + if (this.player != null) { + setVideoView(); + } + } + + private void updateShutterViewVisibility() { + 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); + } + 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 + * assignments are overridden. + * + * @param player The {@link SimpleExoPlayer} to use. + */ + public void setPlayer(SimpleExoPlayer player) { + if (this.player == player) { + return; + } + if (this.player != null) { + this.player.setTextOutput(null); + this.player.setVideoListener(null); + this.player.removeListener(componentListener); + this.player.setVideoSurface(null); + } + this.player = player; + shutterView.setVisibility(VISIBLE); + if (player != null) { + setVideoView(); + player.setVideoListener(componentListener); + player.addListener(componentListener); + player.setTextOutput(componentListener); + } + } + + /** + * Sets the resize mode which can be of value {@link ResizeMode.Mode} + * + * @param resizeMode The resize mode. + */ + public void setResizeMode(@ResizeMode.Mode int resizeMode) { + if (layout.getResizeMode() != resizeMode) { + layout.setResizeMode(resizeMode); + post(measureAndLayout); + } + + } + + /** + * Get the view onto which video is rendered. This is either a {@link SurfaceView} (default) + * or a {@link TextureView} if the {@code use_texture_view} view attribute has been set to true. + * + * @return either a {@link SurfaceView} or a {@link TextureView}. + */ + public View getVideoSurfaceView() { + return surfaceView; + } + + public void setUseTextureView(boolean useTextureView) { + if (useTextureView != this.useTextureView) { + this.useTextureView = useTextureView; + updateSurfaceView(); + } + } + + public void setHideShutterView(boolean hideShutterView) { + this.hideShutterView = hideShutterView; + updateShutterViewVisibility(); + } + + private final Runnable measureAndLayout = new Runnable() { + @Override + public void run() { + measure( + MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY)); + layout(getLeft(), getTop(), getRight(), getBottom()); + } + }; + + private void updateForCurrentTrackSelections() { + if (player == null) { + return; + } + TrackSelectionArray selections = player.getCurrentTrackSelections(); + for (int i = 0; i < selections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_VIDEO && selections.get(i) != null) { + // Video enabled so artwork must be hidden. If the shutter is closed, it will be opened in + // onRenderedFirstFrame(). + return; + } + } + // Video disabled so the shutter must be closed. + shutterView.setVisibility(VISIBLE); + } + + public void invalidateAspectRatio() { + // Resetting aspect ratio will force layout refresh on next video size changed + layout.invalidateAspectRatio(); + } + + private final class ComponentListener implements SimpleExoPlayer.VideoListener, + TextOutput, ExoPlayer.EventListener { + + // TextRenderer.Output implementation + + @Override + public void onCues(List cues) { + subtitleLayout.onCues(cues); + } + + // SimpleExoPlayer.VideoListener implementation + + @Override + public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + boolean isInitialRatio = layout.getAspectRatio() == 0; + layout.setAspectRatio(height == 0 ? 1 : (width * pixelWidthHeightRatio) / height); + + // React native workaround for measuring and layout on initial load. + if (isInitialRatio) { + post(measureAndLayout); + } + } + + @Override + public void onRenderedFirstFrame() { + shutterView.setVisibility(INVISIBLE); + } + + // ExoPlayer.EventListener implementation + + @Override + public void onLoadingChanged(boolean isLoading) { + // Do nothing. + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + // Do nothing. + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + // Do nothing. + } + + @Override + public void onPositionDiscontinuity(int reason) { + // Do nothing. + } + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { + // Do nothing. + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + updateForCurrentTrackSelections(); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters params) { + // Do nothing + } + + @Override + public void onSeekProcessed() { + // Do nothing. + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + // Do nothing. + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + // Do nothing. + } + } + +} diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java new file mode 100644 index 00000000..819f7633 --- /dev/null +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -0,0 +1,1410 @@ +package com.brentvatne.exoplayer; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.accessibility.CaptioningManager; +import android.widget.FrameLayout; +import android.widget.ImageButton; + +import com.brentvatne.react.R; +import com.brentvatne.receiver.AudioBecomingNoisyReceiver; +import com.brentvatne.receiver.BecomingNoisyListener; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Dynamic; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.ThemedReactContext; +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; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Format; +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; +import com.google.android.exoplayer2.metadata.MetadataOutput; +import com.google.android.exoplayer2.source.BehindLiveWindowException; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MergingMediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +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.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +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.DefaultAllocator; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.HttpDataSource; +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.UUID; +import java.util.Map; + +@SuppressLint("ViewConstructor") +class ReactExoplayerView extends FrameLayout implements + LifecycleEventListener, + Player.EventListener, + BandwidthMeter.EventListener, + BecomingNoisyListener, + AudioManager.OnAudioFocusChangeListener, + MetadataOutput, + DefaultDrmSessionEventListener, AdEvent.AdEventListener { + + private static final String TAG = "ReactExoplayerView"; + + private static final CookieManager DEFAULT_COOKIE_MANAGER; + private static final int SHOW_PROGRESS = 1; + + static { + DEFAULT_COOKIE_MANAGER = new CookieManager(); + DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + } + + private final VideoEventEmitter eventEmitter; + private final ReactExoplayerConfig config; + private final DefaultBandwidthMeter bandwidthMeter; + private PlayerControlView playerControlView; + private View playPauseControlContainer; + private Player.EventListener eventListener; + + private ExoPlayerView exoPlayerView; + private ImaAdsLoader adsLoader; + private int initialOrientation; + + private DataSource.Factory mediaDataSourceFactory; + private SimpleExoPlayer player; + private DefaultTrackSelector trackSelector; + private boolean playerNeedsSource; + + private int resumeWindow; + private long resumePosition; + private boolean loadVideoStarted; + private boolean isFullscreen; + private boolean isInBackground; + private boolean isPaused; + private boolean isBuffering; + private boolean muted = false; + private float rate = 1f; + private float audioVolume = 1f; + private int minLoadRetryCount = 3; + private int maxBitRate = 0; + private long seekTime = C.TIME_UNSET; + + private int minBufferMs = DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; + private int maxBufferMs = DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; + 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; + private boolean repeat; + private String audioTrackType; + private Dynamic audioTrackValue; + private String videoTrackType; + private Dynamic videoTrackValue; + private String textTrackType; + private Dynamic textTrackValue; + private ReadableArray textTracks; + private boolean disableFocus; + private boolean preventsDisplaySleepDuringVideoPlayback = true; + private float mProgressUpdateInterval = 250.0f; + 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; + private Uri adTagUrl; + // \ End props + + // React + private final ThemedReactContext themedReactContext; + private final AudioManager audioManager; + private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; + + private final Handler progressHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case SHOW_PROGRESS: + if (player != null + && 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(), getPositionInFirstPeriodMsForCurrentWindow(pos)); + msg = obtainMessage(SHOW_PROGRESS); + sendMessageDelayed(msg, Math.round(mProgressUpdateInterval)); + } + break; + } + } + }; + + public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) { + Timeline.Window window = new Timeline.Window(); + if(!player.getCurrentTimeline().isEmpty()) { + player.getCurrentTimeline().getWindow(player.getCurrentWindowIndex(), window); + } + return window.windowStartTimeMs + currentPosition; + } + + public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig config) { + super(context); + this.themedReactContext = context; + this.eventEmitter = new VideoEventEmitter(context); + this.config = config; + this.bandwidthMeter = config.getBandwidthMeter(); + + adsLoader = new ImaAdsLoader(this.themedReactContext, Uri.EMPTY); + + createViews(); + + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + themedReactContext.addLifecycleEventListener(this); + audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); + } + + private boolean isPlayingAd() { + return player != null && player.isPlayingAd() && player.getPlayWhenReady(); + } + + + @Override + public void setId(int id) { + super.setId(id); + eventEmitter.setViewId(id); + } + + private void createViews() { + clearResumePosition(); + mediaDataSourceFactory = buildDataSourceFactory(true); + if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { + CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); + } + + LayoutParams layoutParams = new LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT); + exoPlayerView = new ExoPlayerView(getContext()); + exoPlayerView.setLayoutParams(layoutParams); + + addView(exoPlayerView, 0, layoutParams); + + mainHandler = new Handler(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + initializePlayer(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + /* We want to be able to continue playing audio when switching tabs. + * Leave this here in case it causes issues. + */ + // stopPlayback(); + } + + // LifecycleEventListener implementation + + @Override + public void onHostResume() { + if (!playInBackground || !isInBackground) { + setPlayWhenReady(!isPaused); + } + isInBackground = false; + } + + @Override + public void onHostPause() { + isInBackground = true; + if (playInBackground) { + return; + } + setPlayWhenReady(false); + } + + @Override + public void onHostDestroy() { + stopPlayback(); + } + + public void cleanUpResources() { + stopPlayback(); + } + + //BandwidthMeter.EventListener implementation + @Override + public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { + if (mReportBandwidth) { + if (player == null) { + eventEmitter.bandwidthReport(bitrate, 0, 0, "-1"); + } else { + Format videoFormat = player.getVideoFormat(); + int width = videoFormat != null ? videoFormat.width : 0; + int height = videoFormat != null ? videoFormat.height : 0; + String trackId = videoFormat != null ? videoFormat.id : "-1"; + eventEmitter.bandwidthReport(bitrate, height, width, trackId); + } + } + } + + // Internal methods + + /** + * Toggling the visibility of the player control view + */ + private void togglePlayerControlVisibility() { + if(player == null) return; + reLayout(playerControlView); + if (playerControlView.isVisible()) { + playerControlView.hide(); + } else { + playerControlView.show(); + } + } + + /** + * Initializing Player control + */ + private void initializePlayerControl() { + if (playerControlView == null) { + playerControlView = new PlayerControlView(getContext()); + } + + // Setting the player for the playerControlView + playerControlView.setPlayer(player); + playerControlView.show(); + playPauseControlContainer = playerControlView.findViewById(R.id.exo_play_pause_container); + + // Invoking onClick event for exoplayerView + exoPlayerView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (!isPlayingAd()) { + togglePlayerControlVisibility(); + } + } + }); + + //Handling the playButton click event + ImageButton playButton = playerControlView.findViewById(R.id.exo_play); + playButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (player != null && player.getPlaybackState() == Player.STATE_ENDED) { + player.seekTo(0); + } + setPausedModifier(false); + } + }); + + //Handling the pauseButton click event + ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause); + pauseButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + setPausedModifier(true); + } + }); + + // Invoking onPlayerStateChanged event for Player + eventListener = new Player.EventListener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + reLayout(playPauseControlContainer); + //Remove this eventListener once its executed. since UI will work fine once after the reLayout is done + player.removeListener(eventListener); + } + }; + player.addListener(eventListener); + } + + /** + * Adding Player control to the frame layout + */ + private void addPlayerControl() { + if(player == null) return; + LayoutParams layoutParams = new LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT); + playerControlView.setLayoutParams(layoutParams); + int indexOfPC = indexOfChild(playerControlView); + if (indexOfPC != -1) { + removeViewAt(indexOfPC); + } + addView(playerControlView, 1, layoutParams); + } + + /** + * Update the layout + * @param view view needs to update layout + * + * This is a workaround for the open bug in react-native: https://github.com/facebook/react-native/issues/17968 + */ + private void reLayout(View view) { + if (view == null) return; + view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); + view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + private void initializePlayer() { + ReactExoplayerView self = this; + // This ensures all props have been settled, to avoid async racing conditions. + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + if (player == null) { + TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); + trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + trackSelector.setParameters(trackSelector.buildUponParameters() + .setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate)); + + DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + DefaultLoadControl.Builder defaultLoadControlBuilder = new DefaultLoadControl.Builder(); + defaultLoadControlBuilder.setAllocator(allocator); + defaultLoadControlBuilder.setBufferDurationsMs(minBufferMs, maxBufferMs, bufferForPlaybackMs, bufferForPlaybackAfterRebufferMs); + defaultLoadControlBuilder.setTargetBufferBytes(-1); + defaultLoadControlBuilder.setPrioritizeTimeOverSizeThresholds(true); + DefaultLoadControl defaultLoadControl = defaultLoadControlBuilder.createDefaultLoadControl(); + DefaultRenderersFactory renderersFactory = + new DefaultRenderersFactory(getContext()) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF); + // 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, drmSessionManager, bandwidthMeter); + player.addListener(self); + player.addMetadataOutput(self); //a random comment here + adsLoader.setPlayer(player); + exoPlayerView.setPlayer(player); + audioBecomingNoisyReceiver.setListener(self); + bandwidthMeter.addEventListener(new Handler(), self); + setPlayWhenReady(!isPaused); + playerNeedsSource = true; + + PlaybackParameters params = new PlaybackParameters(rate, 1f); + player.setPlaybackParameters(params); + } + if (playerNeedsSource && srcUri != null) { + exoPlayerView.invalidateAspectRatio(); + + ArrayList mediaSourceList = buildTextSources(); + MediaSource videoSource = buildMediaSource(srcUri, extension); + MediaSource mediaSourceWithAds = new AdsMediaSource(videoSource, mediaDataSourceFactory, adsLoader, exoPlayerView); + MediaSource mediaSource; + if (mediaSourceList.size() == 0) { + mediaSource = mediaSourceWithAds; + } else { + mediaSourceList.add(0, mediaSourceWithAds); + MediaSource[] textSourceArray = mediaSourceList.toArray( + new MediaSource[mediaSourceList.size()] + ); + mediaSource = new MergingMediaSource(textSourceArray); + } + + boolean haveResumePosition = resumeWindow != C.INDEX_UNSET; + if (haveResumePosition) { + player.seekTo(resumeWindow, resumePosition); + } + player.prepare(mediaSource, !haveResumePosition, false); + playerNeedsSource = false; + + eventEmitter.loadStart(); + loadVideoStarted = true; + } + + // Initializing the playerControlView + initializePlayerControl(); + setControls(controls); + applyModifiers(); + } + }, 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()); + switch (type) { + case C.TYPE_SS: + return new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(mediaDataSourceFactory), + buildDataSourceFactory(false) + ).setLoadErrorHandlingPolicy( + config.buildLoadErrorHandlingPolicy(minLoadRetryCount) + ).createMediaSource(uri); + case C.TYPE_DASH: + return new DashMediaSource.Factory( + new DefaultDashChunkSource.Factory(mediaDataSourceFactory), + buildDataSourceFactory(false) + ).setLoadErrorHandlingPolicy( + config.buildLoadErrorHandlingPolicy(minLoadRetryCount) + ).createMediaSource(uri); + case C.TYPE_HLS: + return new HlsMediaSource.Factory( + mediaDataSourceFactory + ).setLoadErrorHandlingPolicy( + config.buildLoadErrorHandlingPolicy(minLoadRetryCount) + ).createMediaSource(uri); + case C.TYPE_OTHER: + return new ProgressiveMediaSource.Factory( + mediaDataSourceFactory + ).setLoadErrorHandlingPolicy( + config.buildLoadErrorHandlingPolicy(minLoadRetryCount) + ).createMediaSource(uri); + default: { + throw new IllegalStateException("Unsupported type: " + type); + } + } + } + + private ArrayList buildTextSources() { + ArrayList textSources = new ArrayList<>(); + if (textTracks == null) { + return textSources; + } + + for (int i = 0; i < textTracks.size(); ++i) { + ReadableMap textTrack = textTracks.getMap(i); + String language = textTrack.getString("language"); + String title = textTrack.hasKey("title") + ? textTrack.getString("title") : language + " " + i; + Uri uri = Uri.parse(textTrack.getString("uri")); + MediaSource textSource = buildTextSource(title, uri, textTrack.getString("type"), + language); + if (textSource != null) { + textSources.add(textSource); + } + } + return textSources; + } + + private MediaSource buildTextSource(String title, Uri uri, String mimeType, String language) { + Format textFormat = Format.createTextSampleFormat(title, mimeType, Format.NO_VALUE, language); + return new SingleSampleMediaSource.Factory(mediaDataSourceFactory) + .createMediaSource(uri, textFormat, C.TIME_UNSET); + } + + private void releasePlayer() { + if (player != null) { + updateResumePosition(); + player.release(); + player.removeMetadataOutput(this); + trackSelector = null; + player = null; + } + adsLoader.release(); + progressHandler.removeMessages(SHOW_PROGRESS); + themedReactContext.removeLifecycleEventListener(this); + audioBecomingNoisyReceiver.removeListener(); + bandwidthMeter.removeEventListener(this); + } + + private boolean requestAudioFocus() { + if (disableFocus || srcUri == null) { + return true; + } + int result = audioManager.requestAudioFocus(this, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + private void setPlayWhenReady(boolean playWhenReady) { + if (player == null) { + return; + } + + if (playWhenReady) { + boolean hasAudioFocus = requestAudioFocus(); + if (hasAudioFocus) { + player.setPlayWhenReady(true); + } + } else { + player.setPlayWhenReady(false); + } + } + + private void startPlayback() { + if (player != null) { + switch (player.getPlaybackState()) { + case Player.STATE_IDLE: + case Player.STATE_ENDED: + initializePlayer(); + break; + case Player.STATE_BUFFERING: + case Player.STATE_READY: + if (!player.getPlayWhenReady()) { + setPlayWhenReady(true); + } + break; + default: + break; + } + + } else { + initializePlayer(); + } + if (!disableFocus) { + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + } + } + + private void pausePlayback() { + if (player != null) { + if (player.getPlayWhenReady()) { + setPlayWhenReady(false); + } + } + setKeepScreenOn(false); + } + + private void stopPlayback() { + onStopPlayback(); + releasePlayer(); + } + + private void onStopPlayback() { + if (isFullscreen) { + setFullscreen(false); + } + audioManager.abandonAudioFocus(this); + } + + private void updateResumePosition() { + resumeWindow = player.getCurrentWindowIndex(); + resumePosition = player.isCurrentWindowSeekable() ? Math.max(0, player.getCurrentPosition()) + : C.TIME_UNSET; + } + + private void clearResumePosition() { + resumeWindow = C.INDEX_UNSET; + resumePosition = C.TIME_UNSET; + } + + /** + * Returns a new DataSource factory. + * + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * DataSource factory. + * @return A new DataSource factory. + */ + private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) { + return DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, + 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 + public void onAudioFocusChange(int focusChange) { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + eventEmitter.audioFocusChanged(false); + audioManager.abandonAudioFocus(this); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + eventEmitter.audioFocusChanged(false); + break; + case AudioManager.AUDIOFOCUS_GAIN: + eventEmitter.audioFocusChanged(true); + break; + default: + break; + } + + if (player != null) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + // Lower the volume + if (!muted) { + player.setVolume(audioVolume * 0.8f); + } + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + // Raise it back to normal + if (!muted) { + player.setVolume(audioVolume * 1); + } + } + } + } + + // AudioBecomingNoisyListener implementation + + @Override + public void onAudioBecomingNoisy() { + eventEmitter.audioBecomingNoisy(); + } + + // Player.EventListener implementation + + @Override + public void onLoadingChanged(boolean isLoading) { + // Do nothing. + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; + switch (playbackState) { + case Player.STATE_IDLE: + text += "idle"; + eventEmitter.idle(); + clearProgressMessageHandler(); + if (!playWhenReady) { + setKeepScreenOn(false); + } + break; + case Player.STATE_BUFFERING: + text += "buffering"; + onBuffering(true); + clearProgressMessageHandler(); + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + break; + case Player.STATE_READY: + text += "ready"; + eventEmitter.ready(); + onBuffering(false); + startProgressHandler(); + videoLoaded(); + // Setting the visibility for the playerControlView + if (playerControlView != null) { + playerControlView.show(); + } + setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback); + break; + case Player.STATE_ENDED: + text += "ended"; + eventEmitter.end(); + onStopPlayback(); + setKeepScreenOn(false); + break; + default: + text += "unknown"; + break; + } + Log.d(TAG, text); + } + + private void startProgressHandler() { + progressHandler.sendEmptyMessage(SHOW_PROGRESS); + } + + /* + The progress message handler will duplicate recursions of the onProgressMessage handler + on change of player state from any state to STATE_READY with playWhenReady is true (when + the video is not paused). This clears all existing messages. + */ + private void clearProgressMessageHandler() { + progressHandler.removeMessages(SHOW_PROGRESS); + } + + private void videoLoaded() { + if (!player.isPlayingAd() && loadVideoStarted) { + loadVideoStarted = false; + setSelectedAudioTrack(audioTrackType, audioTrackValue); + setSelectedVideoTrack(videoTrackType, videoTrackValue); + setSelectedTextTrack(textTrackType, textTrackValue); + Format videoFormat = player.getVideoFormat(); + int width = videoFormat != null ? videoFormat.width : 0; + int height = videoFormat != null ? videoFormat.height : 0; + String trackId = videoFormat != null ? videoFormat.id : "-1"; + eventEmitter.load(player.getDuration(), player.getCurrentPosition(), width, height, + getAudioTrackInfo(), getTextTrackInfo(), getVideoTrackInfo(), trackId); + } + } + + private WritableArray getAudioTrackInfo() { + WritableArray audioTracks = Arguments.createArray(); + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_AUDIO); + if (info == null || index == C.INDEX_UNSET) { + return audioTracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + WritableMap audioTrack = Arguments.createMap(); + audioTrack.putInt("index", i); + audioTrack.putString("title", format.id != null ? format.id : ""); + audioTrack.putString("type", format.sampleMimeType); + audioTrack.putString("language", format.language != null ? format.language : ""); + audioTrack.putString("bitrate", format.bitrate == Format.NO_VALUE ? "" + : String.format(Locale.US, "%.2fMbps", format.bitrate / 1000000f)); + audioTracks.pushMap(audioTrack); + } + return audioTracks; + } + private WritableArray getVideoTrackInfo() { + WritableArray videoTracks = Arguments.createArray(); + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_VIDEO); + if (info == null || index == C.INDEX_UNSET) { + return videoTracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + for (int i = 0; i < groups.length; ++i) { + TrackGroup group = groups.get(i); + + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getFormat(trackIndex); + WritableMap videoTrack = Arguments.createMap(); + videoTrack.putInt("width", format.width == Format.NO_VALUE ? 0 : format.width); + videoTrack.putInt("height",format.height == Format.NO_VALUE ? 0 : format.height); + videoTrack.putInt("bitrate", format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); + videoTrack.putString("codecs", format.codecs != null ? format.codecs : ""); + videoTrack.putString("trackId", + format.id == null ? String.valueOf(trackIndex) : format.id); + videoTracks.pushMap(videoTrack); + } + } + return videoTracks; + } + + private WritableArray getTextTrackInfo() { + WritableArray textTracks = Arguments.createArray(); + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); + if (info == null || index == C.INDEX_UNSET) { + return textTracks; + } + + TrackGroupArray groups = info.getTrackGroups(index); + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + WritableMap textTrack = Arguments.createMap(); + textTrack.putInt("index", i); + textTrack.putString("title", format.id != null ? format.id : ""); + textTrack.putString("type", format.sampleMimeType); + textTrack.putString("language", format.language != null ? format.language : ""); + textTracks.pushMap(textTrack); + } + return textTracks; + } + + private void onBuffering(boolean buffering) { + if (isBuffering == buffering) { + return; + } + + isBuffering = buffering; + if (buffering) { + eventEmitter.buffering(true); + } else { + eventEmitter.buffering(false); + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + if (playerNeedsSource) { + // This will only occur if the user has performed a seek whilst in the error state. Update the + // resume position so that if the user then retries, playback will resume from the position to + // which they seeked. + updateResumePosition(); + } + // When repeat is turned on, reaching the end of the video will not cause a state change + // so we need to explicitly detect it. + if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + && player.getRepeatMode() == Player.REPEAT_MODE_ONE) { + eventEmitter.end(); + } + } + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { + // Do nothing. + } + + @Override + public void onSeekProcessed() { + eventEmitter.seek(player.getCurrentPosition(), seekTime); + seekTime = C.TIME_UNSET; + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + // Do nothing. + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + // Do nothing. + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do Nothing. + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters params) { + eventEmitter.playbackRateChange(params.speed); + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + String errorString = "ExoPlaybackException type : " + e.type; + Exception ex = e; + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + Exception cause = e.getRendererException(); + if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { + // Special case for decoder initialization failures. + MediaCodecRenderer.DecoderInitializationException decoderInitializationException = + (MediaCodecRenderer.DecoderInitializationException) cause; + if (decoderInitializationException.codecInfo.name == null) { + if (decoderInitializationException.getCause() instanceof MediaCodecUtil.DecoderQueryException) { + errorString = getResources().getString(R.string.error_querying_decoders); + } else if (decoderInitializationException.secureDecoderRequired) { + errorString = getResources().getString(R.string.error_no_secure_decoder, + decoderInitializationException.mimeType); + } else { + errorString = getResources().getString(R.string.error_no_decoder, + decoderInitializationException.mimeType); + } + } else { + errorString = getResources().getString(R.string.error_instantiating_decoder, + decoderInitializationException.codecInfo.name); + } + } + } + else if (e.type == ExoPlaybackException.TYPE_SOURCE) { + errorString = getResources().getString(R.string.unrecognized_media_format); + } + eventEmitter.error(errorString, ex); + playerNeedsSource = true; + if (isBehindLiveWindow(e)) { + clearResumePosition(); + initializePlayer(); + } else { + updateResumePosition(); + } + } + + private static boolean isBehindLiveWindow(ExoPlaybackException e) { + Log.e("ExoPlayer Exception", e.toString()); + if (e.type != ExoPlaybackException.TYPE_SOURCE) { + return false; + } + Throwable cause = e.getSourceException(); + while (cause != null) { + if (cause instanceof BehindLiveWindowException || + cause instanceof HttpDataSource.HttpDataSourceException) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + public int getTrackRendererIndex(int trackType) { + 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; + } + + @Override + public void onMetadata(Metadata metadata) { + eventEmitter.timedMetadata(metadata); + } + + // ReactExoplayerViewManager public api + + public void setSrc(final Uri uri, final String extension, Map headers) { + if (uri != null) { + boolean isOriginalSourceNull = srcUri == null; + boolean isSourceEqual = uri.equals(srcUri); + + this.srcUri = uri; + this.extension = extension; + this.requestHeaders = headers; + this.mediaDataSourceFactory = + DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, + this.requestHeaders); + + if (!isOriginalSourceNull && !isSourceEqual) { + reloadSource(); + } + } + } + + public void setProgressUpdateInterval(final float progressUpdateInterval) { + mProgressUpdateInterval = progressUpdateInterval; + } + + public void setReportBandwidth(boolean reportBandwidth) { + mReportBandwidth = reportBandwidth; + } + + public void setAdTagUrl(final Uri uri) { + adTagUrl = uri; + adsLoader = new ImaAdsLoader(this.themedReactContext, adTagUrl); + adsLoader = new ImaAdsLoader.Builder(this.themedReactContext).setAdEventListener(this).buildForAdTag(adTagUrl); + + } + + public void setRawSrc(final Uri uri, final String extension) { + if (uri != null) { + boolean isOriginalSourceNull = srcUri == null; + boolean isSourceEqual = uri.equals(srcUri); + + this.srcUri = uri; + this.extension = extension; + this.mediaDataSourceFactory = buildDataSourceFactory(true); + + if (!isOriginalSourceNull && !isSourceEqual) { + reloadSource(); + } + } + } + + public void setTextTracks(ReadableArray textTracks) { + this.textTracks = textTracks; + reloadSource(); + } + + private void reloadSource() { + playerNeedsSource = true; + initializePlayer(); + } + + public void setResizeModeModifier(@ResizeMode.Mode int resizeMode) { + exoPlayerView.setResizeMode(resizeMode); + } + + private void applyModifiers() { + setRepeatModifier(repeat); + setMutedModifier(muted); + } + + public void setRepeatModifier(boolean repeat) { + if (player != null) { + if (repeat) { + player.setRepeatMode(Player.REPEAT_MODE_ONE); + } else { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + } + } + this.repeat = repeat; + } + + public void setPreventsDisplaySleepDuringVideoPlayback(boolean preventsDisplaySleepDuringVideoPlayback) { + this.preventsDisplaySleepDuringVideoPlayback = preventsDisplaySleepDuringVideoPlayback; + } + + public void setSelectedTrack(int trackType, String type, Dynamic value) { + if (player == null) return; + int rendererIndex = getTrackRendererIndex(trackType); + if (rendererIndex == C.INDEX_UNSET) { + return; + } + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + if (info == null) { + return; + } + + TrackGroupArray groups = info.getTrackGroups(rendererIndex); + int groupIndex = C.INDEX_UNSET; + int[] tracks = {0} ; + + if (TextUtils.isEmpty(type)) { + type = "default"; + } + + DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters() + .buildUpon() + .setRendererDisabled(rendererIndex, true) + .build(); + + if (type.equals("disabled")) { + trackSelector.setParameters(disableParameters); + return; + } else if (type.equals("language")) { + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + if (format.language != null && format.language.equals(value.asString())) { + groupIndex = i; + break; + } + } + } else if (type.equals("title")) { + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + if (format.id != null && format.id.equals(value.asString())) { + groupIndex = i; + break; + } + } + } else if (type.equals("index")) { + if (value.asInt() < groups.length) { + groupIndex = value.asInt(); + } + } else if (type.equals("resolution")) { + int height = value.asInt(); + for (int i = 0; i < groups.length; ++i) { // Search for the exact height + TrackGroup group = groups.get(i); + for (int j = 0; j < group.length; j++) { + Format format = group.getFormat(j); + if (format.height == height) { + groupIndex = i; + tracks[0] = j; + break; + } + } + } + } else if (rendererIndex == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default + // Use system settings if possible + CaptioningManager captioningManager + = (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE); + if (captioningManager != null && captioningManager.isEnabled()) { + groupIndex = getGroupIndexForDefaultLocale(groups); + } + } else if (rendererIndex == C.TRACK_TYPE_AUDIO) { // Audio default + groupIndex = getGroupIndexForDefaultLocale(groups); + } + + if (groupIndex == C.INDEX_UNSET && trackType == C.TRACK_TYPE_VIDEO && groups.length != 0) { // Video auto + // Add all tracks as valid options for ABR to choose from + TrackGroup group = groups.get(0); + tracks = new int[group.length]; + groupIndex = 0; + for (int j = 0; j < group.length; j++) { + tracks[j] = j; + } + } + + if (groupIndex == C.INDEX_UNSET) { + trackSelector.setParameters(disableParameters); + return; + } + + DefaultTrackSelector.Parameters selectionParameters = trackSelector.getParameters() + .buildUpon() + .setRendererDisabled(rendererIndex, false) + .setSelectionOverride(rendererIndex, groups, + new DefaultTrackSelector.SelectionOverride(groupIndex, tracks)) + .build(); + trackSelector.setParameters(selectionParameters); + } + + private int getGroupIndexForDefaultLocale(TrackGroupArray groups) { + if (groups.length == 0){ + return C.INDEX_UNSET; + } + + int groupIndex = 0; // default if no match + String locale2 = Locale.getDefault().getLanguage(); // 2 letter code + String locale3 = Locale.getDefault().getISO3Language(); // 3 letter code + for (int i = 0; i < groups.length; ++i) { + Format format = groups.get(i).getFormat(0); + String language = format.language; + if (language != null && (language.equals(locale2) || language.equals(locale3))) { + groupIndex = i; + break; + } + } + return groupIndex; + } + + public void setSelectedVideoTrack(String type, Dynamic value) { + videoTrackType = type; + videoTrackValue = value; + setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + } + + public void setSelectedAudioTrack(String type, Dynamic value) { + audioTrackType = type; + audioTrackValue = value; + setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue); + } + + public void setSelectedTextTrack(String type, Dynamic value) { + textTrackType = type; + textTrackValue = value; + setSelectedTrack(C.TRACK_TYPE_TEXT, textTrackType, textTrackValue); + } + + public void setPausedModifier(boolean paused) { + isPaused = paused; + if (player != null) { + if (!paused) { + startPlayback(); + } else { + pausePlayback(); + } + } + } + + public void setMutedModifier(boolean muted) { + this.muted = muted; + audioVolume = muted ? 0.f : 1.f; + if (player != null) { + player.setVolume(audioVolume); + } + } + + + public void setVolumeModifier(float volume) { + audioVolume = volume; + if (player != null) { + player.setVolume(audioVolume); + } + } + + public void seekTo(long positionMs) { + if (player != null) { + seekTime = positionMs; + player.seekTo(positionMs); + } + } + + public void setRateModifier(float newRate) { + rate = newRate; + + if (player != null) { + PlaybackParameters params = new PlaybackParameters(rate, 1f); + player.setPlaybackParameters(params); + } + } + + public void setMaxBitRateModifier(int newMaxBitRate) { + maxBitRate = newMaxBitRate; + if (player != null) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate)); + } + } + + public void setMinLoadRetryCountModifier(int newMinLoadRetryCount) { + minLoadRetryCount = newMinLoadRetryCount; + releasePlayer(); + initializePlayer(); + } + + public void setPlayInBackground(boolean playInBackground) { + this.playInBackground = playInBackground; + } + + public void setDisableFocus(boolean disableFocus) { + this.disableFocus = disableFocus; + } + + public void setFullscreen(boolean fullscreen) { + if (fullscreen == isFullscreen) { + return; // Avoid generating events when nothing is changing + } + isFullscreen = fullscreen; + + Activity activity = themedReactContext.getCurrentActivity(); + if (activity == null) { + return; + } + Window window = activity.getWindow(); + View decorView = window.getDecorView(); + int uiOptions; + if (isFullscreen) { + if (Util.SDK_INT >= 19) { // 4.4+ + uiOptions = SYSTEM_UI_FLAG_HIDE_NAVIGATION + | SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | SYSTEM_UI_FLAG_FULLSCREEN; + } else { + uiOptions = SYSTEM_UI_FLAG_HIDE_NAVIGATION + | SYSTEM_UI_FLAG_FULLSCREEN; + } + eventEmitter.fullscreenWillPresent(); + decorView.setSystemUiVisibility(uiOptions); + eventEmitter.fullscreenDidPresent(); + } else { + uiOptions = View.SYSTEM_UI_FLAG_VISIBLE; + eventEmitter.fullscreenWillDismiss(); + decorView.setSystemUiVisibility(uiOptions); + eventEmitter.fullscreenDidDismiss(); + } + } + + public void setUseTextureView(boolean useTextureView) { + boolean finallyUseTextureView = useTextureView && this.drmUUID == null; + exoPlayerView.setUseTextureView(finallyUseTextureView); + } + + public void setHideShutterView(boolean hideShutterView) { + exoPlayerView.setHideShutterView(hideShutterView); + } + + public void setBufferConfig(int newMinBufferMs, int newMaxBufferMs, int newBufferForPlaybackMs, int newBufferForPlaybackAfterRebufferMs) { + minBufferMs = newMinBufferMs; + maxBufferMs = newMaxBufferMs; + bufferForPlaybackMs = newBufferForPlaybackMs; + bufferForPlaybackAfterRebufferMs = newBufferForPlaybackAfterRebufferMs; + releasePlayer(); + 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 + * + * @param controls Controls prop, if true enable controls, if false disable them + */ + public void setControls(boolean controls) { + this.controls = controls; + if (player == null || exoPlayerView == null) return; + if (controls) { + addPlayerControl(); + } else { + int indexOfPC = indexOfChild(playerControlView); + if (indexOfPC != -1) { + removeViewAt(indexOfPC); + } + } + } + + @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..785b9d85 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 { + this.setState({ duration: data.duration }); + }; + + onProgress = (data) => { + this.setState({ currentTime: data.currentTime }); + }; + + onEnd = () => { + this.setState({ paused: true }) + this.video.seek(0) + }; + + onAudioBecomingNoisy = () => { + this.setState({ paused: true }) + }; + + onAudioFocusChanged = (event: { hasAudioFocus: boolean }) => { + this.setState({ paused: !event.hasAudioFocus }) + }; + + getCurrentTimePercentage() { + if (this.state.currentTime > 0) { + return parseFloat(this.state.currentTime) / parseFloat(this.state.duration); + } + return 0; + }; + + renderRateControl(rate) { + const isSelected = (this.state.rate === rate); + + return ( + { this.setState({ rate }) }}> + + {rate}x + + + ); + } + + renderResizeModeControl(resizeMode) { + const isSelected = (this.state.resizeMode === resizeMode); + + return ( + { this.setState({ resizeMode }) }}> + + {resizeMode} + + + ) + } + + renderVolumeControl(volume) { + const isSelected = (this.state.volume === volume); + + return ( + { this.setState({ volume }) }}> + + {volume * 100}% + + + ) + } + + render() { + const flexCompleted = this.getCurrentTimePercentage() * 100; + const flexRemaining = (1 - this.getCurrentTimePercentage()) * 100; + + return ( + + this.setState({ paused: !this.state.paused })} + > + + + + + + {this.renderRateControl(0.25)} + {this.renderRateControl(0.5)} + {this.renderRateControl(1.0)} + {this.renderRateControl(1.5)} + {this.renderRateControl(2.0)} + + + + {this.renderVolumeControl(0.5)} + {this.renderVolumeControl(1)} + {this.renderVolumeControl(1.5)} + + + + {this.renderResizeModeControl('cover')} + {this.renderResizeModeControl('contain')} + {this.renderResizeModeControl('stretch')} + + + + + + + + + + + + ); + } +} + + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'black', + }, + fullScreen: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + }, + controls: { + backgroundColor: 'transparent', + borderRadius: 5, + position: 'absolute', + bottom: 20, + left: 20, + right: 20, + }, + progress: { + flex: 1, + flexDirection: 'row', + borderRadius: 3, + overflow: 'hidden', + }, + innerProgressCompleted: { + height: 20, + backgroundColor: '#cccccc', + }, + innerProgressRemaining: { + height: 20, + backgroundColor: '#2C2C2C', + }, + generalControls: { + flex: 1, + flexDirection: 'row', + borderRadius: 4, + overflow: 'hidden', + paddingBottom: 10, + }, + rateControl: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + volumeControl: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + resizeModeControl: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + controlOption: { + alignSelf: 'center', + fontSize: 11, + color: 'white', + paddingLeft: 2, + paddingRight: 2, + lineHeight: 12, + }, +}); + +AppRegistry.registerComponent('VideoPlayer', () => VideoPlayer); diff --git a/examples/basic/index.ios.js b/examples/basic/index.ios.js new file mode 100644 index 00000000..fbe2541c --- /dev/null +++ b/examples/basic/index.ios.js @@ -0,0 +1,455 @@ +'use strict'; +import React, { + Component +} from 'react'; + +import { + Alert, + AppRegistry, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +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, + FilterType.MONOCHROME, + FilterType.POSTERIZE, + FilterType.FALSE, + FilterType.MAXIMUMCOMPONENT, + FilterType.MINIMUMCOMPONENT, + FilterType.CHROME, + FilterType.FADE, + FilterType.INSTANT, + FilterType.MONO, + FilterType.NOIR, + FilterType.PROCESS, + FilterType.TONAL, + FilterType.TRANSFER, + FilterType.SEPIA +]; + +class VideoPlayer extends Component { + constructor(props) { + super(props); + this.onLoad = this.onLoad.bind(this); + this.onProgress = this.onProgress.bind(this); + this.onBuffer = this.onBuffer.bind(this); + } + state = { + rate: 1, + volume: 1, + muted: false, + resizeMode: 'contain', + duration: 0.0, + currentTime: 0.0, + controls: false, + paused: true, + skin: 'custom', + ignoreSilentSwitch: null, + mixWithOthers: null, + isBuffering: false, + filter: FilterType.NONE, + filterEnabled: true + }; + + onLoad(data) { + console.log('On load fired!'); + this.setState({duration: data.duration}); + } + + onProgress(data) { + this.setState({currentTime: data.currentTime}); + } + + onBuffer({ isBuffering }: { isBuffering: boolean }) { + this.setState({ isBuffering }); + } + + getCurrentTimePercentage() { + if (this.state.currentTime > 0) { + return parseFloat(this.state.currentTime) / parseFloat(this.state.duration); + } else { + return 0; + } + } + + setFilter(step) { + let index = filterTypes.indexOf(this.state.filter) + step; + + if (index === filterTypes.length) { + index = 0; + } else if (index === -1) { + index = filterTypes.length - 1; + } + + this.setState({ + filter: filterTypes[index] + }) + } + + renderSkinControl(skin) { + const isSelected = this.state.skin == skin; + const selectControls = skin == 'native' || skin == 'embed'; + return ( + { this.setState({ + controls: selectControls, + skin: skin + }) }}> + + {skin} + + + ); + } + + renderRateControl(rate) { + const isSelected = (this.state.rate == rate); + + return ( + { this.setState({rate: rate}) }}> + + {rate}x + + + ) + } + + renderResizeModeControl(resizeMode) { + const isSelected = (this.state.resizeMode == resizeMode); + + return ( + { this.setState({resizeMode: resizeMode}) }}> + + {resizeMode} + + + ) + } + + renderVolumeControl(volume) { + const isSelected = (this.state.volume == volume); + + return ( + { this.setState({volume: volume}) }}> + + {volume * 100}% + + + ) + } + + renderIgnoreSilentSwitchControl(ignoreSilentSwitch) { + const isSelected = (this.state.ignoreSilentSwitch == ignoreSilentSwitch); + + return ( + { this.setState({ignoreSilentSwitch: ignoreSilentSwitch}) }}> + + {ignoreSilentSwitch} + + + ) + } + + renderMixWithOthersControl(mixWithOthers) { + const isSelected = (this.state.mixWithOthers == mixWithOthers); + + return ( + { this.setState({mixWithOthers: mixWithOthers}) }}> + + {mixWithOthers} + + + ) + } + + renderCustomSkin() { + const flexCompleted = this.getCurrentTimePercentage() * 100; + const flexRemaining = (1 - this.getCurrentTimePercentage()) * 100; + + return ( + + {this.setState({paused: !this.state.paused})}}> + + + + + + {this.renderSkinControl('custom')} + {this.renderSkinControl('native')} + {this.renderSkinControl('embed')} + + { + (this.state.filterEnabled) ? + + { + this.setFilter(-1) + }}> + Previous Filter + + { + this.setFilter(1) + }}> + Next Filter + + : null + } + + + + {this.renderRateControl(0.5)} + {this.renderRateControl(1.0)} + {this.renderRateControl(2.0)} + + + + {this.renderVolumeControl(0.5)} + {this.renderVolumeControl(1)} + {this.renderVolumeControl(1.5)} + + + + {this.renderResizeModeControl('cover')} + {this.renderResizeModeControl('contain')} + {this.renderResizeModeControl('stretch')} + + + + { + (Platform.OS === 'ios') ? + <> + + {this.renderIgnoreSilentSwitchControl('ignore')} + {this.renderIgnoreSilentSwitchControl('obey')} + + + {this.renderMixWithOthersControl('mix')} + {this.renderMixWithOthersControl('duck')} + + : null + } + + + + + + + + + + + ); + } + + renderNativeSkin() { + const videoStyle = this.state.skin == 'embed' ? styles.nativeVideoControls : styles.fullScreen; + return ( + + + + + + + {this.renderSkinControl('custom')} + {this.renderSkinControl('native')} + {this.renderSkinControl('embed')} + + { + (this.state.filterEnabled) ? + + { + this.setFilter(-1) + }}> + Previous Filter + + { + this.setFilter(1) + }}> + Next Filter + + : null + } + + + + {this.renderRateControl(0.5)} + {this.renderRateControl(1.0)} + {this.renderRateControl(2.0)} + + + + {this.renderVolumeControl(0.5)} + {this.renderVolumeControl(1)} + {this.renderVolumeControl(1.5)} + + + + {this.renderResizeModeControl('cover')} + {this.renderResizeModeControl('contain')} + {this.renderResizeModeControl('stretch')} + + + + { + (Platform.OS === 'ios') ? + <> + + {this.renderIgnoreSilentSwitchControl('ignore')} + {this.renderIgnoreSilentSwitchControl('obey')} + + + {this.renderMixWithOthersControl('mix')} + {this.renderMixWithOthersControl('duck')} + + : null + } + + + + + ); + } + + render() { + return this.state.controls ? this.renderNativeSkin() : this.renderCustomSkin(); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'black', + }, + fullScreen: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + }, + controls: { + backgroundColor: "transparent", + borderRadius: 5, + position: 'absolute', + bottom: 44, + left: 4, + right: 4, + }, + progress: { + flex: 1, + flexDirection: 'row', + borderRadius: 3, + overflow: 'hidden', + }, + innerProgressCompleted: { + height: 20, + backgroundColor: '#cccccc', + }, + innerProgressRemaining: { + height: 20, + backgroundColor: '#2C2C2C', + }, + generalControls: { + flex: 1, + flexDirection: 'row', + overflow: 'hidden', + paddingBottom: 10, + }, + skinControl: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + rateControl: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + volumeControl: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + resizeModeControl: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + }, + ignoreSilentSwitchControl: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + }, + mixWithOthersControl: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + }, + controlOption: { + alignSelf: 'center', + fontSize: 11, + color: "white", + paddingLeft: 2, + paddingRight: 2, + lineHeight: 12, + }, + nativeVideoControls: { + top: 184, + height: 300 + } +}); + +AppRegistry.registerComponent('VideoPlayer', () => VideoPlayer); diff --git a/examples/basic/ios/VideoPlayer.xcodeproj/project.pbxproj b/examples/basic/ios/VideoPlayer.xcodeproj/project.pbxproj index 3baa0b3e..137ce71c 100644 --- a/examples/basic/ios/VideoPlayer.xcodeproj/project.pbxproj +++ b/examples/basic/ios/VideoPlayer.xcodeproj/project.pbxproj @@ -319,6 +319,7 @@ developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( + English, en, Base, ); diff --git a/examples/basic/react-native.config.js b/examples/basic/react-native.config.js new file mode 100644 index 00000000..7aee24ce --- /dev/null +++ b/examples/basic/react-native.config.js @@ -0,0 +1,15 @@ +const fs = require('fs'); +const path = require('path'); +module.exports = { + reactNativePath: fs.realpathSync(path.resolve(require.resolve('react-native-windows/package.json'), '..')), + dependencies: { + 'react-native-video-inc-ads': { + platforms: { + android: { + sourceDir: + '../node_modules/react-native-video-inc-ads/android-exoplayer', + }, + }, + }, + }, +}; diff --git a/ios/RCTVideo.xcodeproj/project.pbxproj b/ios/RCTVideo.xcodeproj/project.pbxproj index e601ea80..1665197d 100644 --- a/ios/RCTVideo.xcodeproj/project.pbxproj +++ b/ios/RCTVideo.xcodeproj/project.pbxproj @@ -237,7 +237,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 7.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -271,7 +271,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 7.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; VALIDATE_PRODUCT = YES; diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h new file mode 100644 index 00000000..d81aa194 --- /dev/null +++ b/ios/Video/RCTVideo.h @@ -0,0 +1,77 @@ +#import +#import "AVKit/AVKit.h" +#import "UIView+FindUIViewController.h" +#import "RCTVideoPlayerViewController.h" +#import "RCTVideoPlayerViewControllerDelegate.h" +#import +#import +@import GoogleInteractiveMediaAds; + +#if __has_include() +#import +#import +#import +#endif + +@class RCTEventDispatcher; +#if __has_include() +@interface RCTVideo : UIView +#elif TARGET_OS_TV +@interface RCTVideo : UIView +#else +@interface RCTVideo : UIView +#endif + +@property (nonatomic, copy) RCTDirectEventBlock onVideoLoadStart; +@property (nonatomic, copy) RCTDirectEventBlock onVideoLoad; +@property (nonatomic, copy) RCTDirectEventBlock onVideoBuffer; +@property (nonatomic, copy) RCTDirectEventBlock onVideoError; +@property (nonatomic, copy) RCTDirectEventBlock onVideoProgress; +@property (nonatomic, copy) RCTDirectEventBlock onBandwidthUpdate; +@property (nonatomic, copy) RCTDirectEventBlock onVideoSeek; +@property (nonatomic, copy) RCTDirectEventBlock onVideoEnd; +@property (nonatomic, copy) RCTDirectEventBlock onTimedMetadata; +@property (nonatomic, copy) RCTDirectEventBlock onVideoAudioBecomingNoisy; +@property (nonatomic, copy) RCTDirectEventBlock onVideoFullscreenPlayerWillPresent; +@property (nonatomic, copy) RCTDirectEventBlock onVideoFullscreenPlayerDidPresent; +@property (nonatomic, copy) RCTDirectEventBlock onVideoFullscreenPlayerWillDismiss; +@property (nonatomic, copy) RCTDirectEventBlock onVideoFullscreenPlayerDidDismiss; +@property (nonatomic, copy) RCTDirectEventBlock onReadyForDisplay; +@property (nonatomic, copy) RCTDirectEventBlock onPlaybackStalled; +@property (nonatomic, copy) RCTDirectEventBlock onPlaybackResume; +@property (nonatomic, copy) RCTDirectEventBlock onPlaybackRateChange; +@property (nonatomic, copy) RCTDirectEventBlock onVideoExternalPlaybackChange; +@property (nonatomic, copy) RCTDirectEventBlock onPictureInPictureStatusChanged; +@property (nonatomic, copy) RCTDirectEventBlock onRestoreUserInterfaceForPictureInPictureStop; +@property (nonatomic, copy) RCTDirectEventBlock onGetLicense; +@property (nonatomic, copy) RCTDirectEventBlock onReceiveAdEvent; + +typedef NS_ENUM(NSInteger, RCTVideoError) { + RCTVideoErrorFromJSPart, + RCTVideoErrorLicenseRequestNotOk, + RCTVideoErrorNoDataFromLicenseRequest, + RCTVideoErrorNoSPC, + RCTVideoErrorNoDataRequest, + RCTVideoErrorNoCertificateData, + RCTVideoErrorNoCertificateURL, + RCTVideoErrorNoFairplayDRM, + RCTVideoErrorNoDRMData +}; +/// 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; + +- (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; + ++ (NSString *)convertEventToString:(IMAAdEventType)event; + +@end diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m new file mode 100644 index 00000000..d2910ae8 --- /dev/null +++ b/ios/Video/RCTVideo.m @@ -0,0 +1,2174 @@ +#import +#import "RCTVideo.h" +#import +#import +#import +#include +#include + +static NSString *const statusKeyPath = @"status"; +static NSString *const playbackLikelyToKeepUpKeyPath = @"playbackLikelyToKeepUp"; +static NSString *const playbackBufferEmptyKeyPath = @"playbackBufferEmpty"; +static NSString *const readyForDisplayKeyPath = @"readyForDisplay"; +static NSString *const playbackRate = @"rate"; +static NSString *const timedMetadata = @"timedMetadata"; +static NSString *const externalPlaybackActive = @"externalPlaybackActive"; + +static int const RCTVideoUnset = -1; + +#ifdef DEBUG + #define DebugLog(...) NSLog(__VA_ARGS__) +#else + #define DebugLog(...) (void)0 +#endif + +@implementation RCTVideo +{ + AVPlayer *_player; + AVPlayerItem *_playerItem; + NSDictionary *_source; + BOOL _playerItemObserversSet; + BOOL _playerBufferEmpty; + AVPlayerLayer *_playerLayer; + BOOL _playerLayerObserverSet; + RCTVideoPlayerViewController *_playerViewController; + NSURL *_videoURL; + BOOL _requestingCertificate; + BOOL _requestingCertificateErrored; + + /* DRM */ + NSDictionary *_drm; + AVAssetResourceLoadingRequest *_loadingRequest; + + /* Required to publish events */ + RCTEventDispatcher *_eventDispatcher; + BOOL _playbackRateObserverRegistered; + BOOL _isExternalPlaybackActiveObserverRegistered; + BOOL _videoLoadStarted; + BOOL _isRequestAds; + + bool _pendingSeek; + float _pendingSeekTime; + float _lastSeekTime; + + /* For sending videoProgress events */ + Float64 _progressUpdateInterval; + BOOL _controls; + id _timeObserver; + + /* Keep track of any modifiers, need to be applied after each play */ + float _volume; + float _rate; + float _maxBitRate; + + BOOL _automaticallyWaitsToMinimizeStalling; + BOOL _muted; + BOOL _paused; + BOOL _repeat; + BOOL _allowsExternalPlayback; + NSArray * _textTracks; + NSDictionary * _selectedTextTrack; + NSDictionary * _selectedAudioTrack; + BOOL _playbackStalled; + BOOL _playInBackground; + BOOL _preventsDisplaySleepDuringVideoPlayback; + float _preferredForwardBufferDuration; + BOOL _playWhenInactive; + BOOL _pictureInPicture; + NSString * _ignoreSilentSwitch; + NSString * _mixWithOthers; + NSString * _resizeMode; + BOOL _fullscreen; + BOOL _fullscreenAutorotate; + NSString * _fullscreenOrientation; + BOOL _fullscreenPlayerPresented; + NSString *_filterName; + NSString * _adTagUrl; + BOOL _filterEnabled; + UIViewController * _presentingViewController; +#if __has_include() + RCTVideoCache * _videoCache; +#endif +#if TARGET_OS_IOS + void (^__strong _Nonnull _restoreUserInterfaceForPIPStopCompletionHandler)(BOOL); + AVPictureInPictureController *_pipController; +#endif +} + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super init])) { + _eventDispatcher = eventDispatcher; + _automaticallyWaitsToMinimizeStalling = YES; + _playbackRateObserverRegistered = NO; + _isExternalPlaybackActiveObserverRegistered = NO; + _playbackStalled = NO; + _rate = 1.0; + _volume = 1.0; + _resizeMode = @"AVLayerVideoGravityResizeAspectFill"; + _fullscreenAutorotate = YES; + _fullscreenOrientation = @"all"; + _pendingSeek = false; + _pendingSeekTime = 0.0f; + _lastSeekTime = 0.0f; + _progressUpdateInterval = 250; + _controls = NO; + _playerBufferEmpty = YES; + _playInBackground = false; + _preventsDisplaySleepDuringVideoPlayback = true; + _preferredForwardBufferDuration = 0.0f; + _allowsExternalPlayback = YES; + _playWhenInactive = false; + _pictureInPicture = false; + _isRequestAds = false; + _ignoreSilentSwitch = @"inherit"; // inherit, ignore, obey + _mixWithOthers = @"inherit"; // inherit, mix, duck +#if TARGET_OS_IOS + _restoreUserInterfaceForPIPStopCompletionHandler = NULL; +#endif +#if __has_include() + _videoCache = [RCTVideoCache sharedInstance]; +#endif + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationWillResignActive:) + name:UIApplicationWillResignActiveNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(audioRouteChanged:) + name:AVAudioSessionRouteChangeNotification + object:nil]; + } + + return self; +} + +- (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; +} + +/* --------------------------------------------------------- + ** Get the duration for a AVPlayerItem. + ** ------------------------------------------------------- */ + +- (CMTime)playerItemDuration +{ + AVPlayerItem *playerItem = [_player currentItem]; + if (playerItem.status == AVPlayerItemStatusReadyToPlay) + { + return([playerItem duration]); + } + + return(kCMTimeInvalid); +} + +- (CMTimeRange)playerItemSeekableTimeRange +{ + AVPlayerItem *playerItem = [_player currentItem]; + if (playerItem.status == AVPlayerItemStatusReadyToPlay) + { + return [playerItem seekableTimeRanges].firstObject.CMTimeRangeValue; + } + + return (kCMTimeRangeZero); +} + +-(void)addPlayerTimeObserver +{ + const Float64 progressUpdateIntervalMS = _progressUpdateInterval / 1000; + // @see endScrubbing in AVPlayerDemoPlaybackViewController.m + // of https://developer.apple.com/library/ios/samplecode/AVPlayerDemo/Introduction/Intro.html + __weak RCTVideo *weakSelf = self; + _timeObserver = [_player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(progressUpdateIntervalMS, NSEC_PER_SEC) + queue:NULL + usingBlock:^(CMTime time) { [weakSelf sendProgressUpdate]; } + ]; +} + +/* Cancels the previously registered time observer. */ +-(void)removePlayerTimeObserver +{ + if (_timeObserver) + { + [_player removeTimeObserver:_timeObserver]; + _timeObserver = nil; + } +} + +#pragma mark - Progress + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self removePlayerLayer]; + [self removePlayerItemObservers]; + [_player removeObserver:self forKeyPath:playbackRate context:nil]; + [_player removeObserver:self forKeyPath:externalPlaybackActive context: nil]; +} + +#pragma mark - App lifecycle handlers + +- (void)applicationWillResignActive:(NSNotification *)notification +{ + if (_playInBackground || _playWhenInactive || _paused) return; + + [_player pause]; + [_player setRate:0.0]; +} + +- (void)applicationDidEnterBackground:(NSNotification *)notification +{ + if (_playInBackground) { + // Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html + [_playerLayer setPlayer:nil]; + [_playerViewController setPlayer:nil]; + } +} + +- (void)applicationWillEnterForeground:(NSNotification *)notification +{ + [self applyModifiers]; + if (_playInBackground) { + [_playerLayer setPlayer:_player]; + [_playerViewController setPlayer:_player]; + } +} + +#pragma mark - Audio events + +- (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}); + } +} + +#pragma mark - Progress + +- (void)sendProgressUpdate +{ + AVPlayerItem *video = [_player currentItem]; + if (video == nil || video.status != AVPlayerItemStatusReadyToPlay) { + return; + } + + CMTime playerDuration = [self playerItemDuration]; + if (CMTIME_IS_INVALID(playerDuration)) { + return; + } + + CMTime currentTime = _player.currentTime; + NSDate *currentPlaybackTime = _player.currentItem.currentDate; + const Float64 duration = CMTimeGetSeconds(playerDuration); + 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 && currentTimeSecs >= 0.0001) { + [self requestAds]; + _isRequestAds = true; + } + self.onVideoProgress(@{ + @"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(currentTime)], + @"playableDuration": [self calculatePlayableDuration], + @"atValue": [NSNumber numberWithLongLong:currentTime.value], + @"atTimescale": [NSNumber numberWithInt:currentTime.timescale], + @"currentPlaybackTime": [NSNumber numberWithLongLong:[@(floor([currentPlaybackTime timeIntervalSince1970] * 1000)) longLongValue]], + @"target": self.reactTag, + @"seekableDuration": [self calculateSeekableDuration], + }); + } +} + +/*! + * Calculates and returns the playable duration of the current player item using its loaded time ranges. + * + * \returns The playable duration of the current player item in seconds. + */ +- (NSNumber *)calculatePlayableDuration +{ + AVPlayerItem *video = _player.currentItem; + if (video.status == AVPlayerItemStatusReadyToPlay) { + __block CMTimeRange effectiveTimeRange; + [video.loadedTimeRanges enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { + CMTimeRange timeRange = [obj CMTimeRangeValue]; + if (CMTimeRangeContainsTime(timeRange, video.currentTime)) { + effectiveTimeRange = timeRange; + *stop = YES; + } + }]; + Float64 playableDuration = CMTimeGetSeconds(CMTimeRangeGetEnd(effectiveTimeRange)); + if (playableDuration > 0) { + return [NSNumber numberWithFloat:playableDuration]; + } + } + return [NSNumber numberWithInteger:0]; +} + +- (NSNumber *)calculateSeekableDuration +{ + CMTimeRange timeRange = [self playerItemSeekableTimeRange]; + if (CMTIME_IS_NUMERIC(timeRange.duration)) + { + return [NSNumber numberWithFloat:CMTimeGetSeconds(timeRange.duration)]; + } + return [NSNumber numberWithInteger:0]; +} + +- (void)addPlayerItemObservers +{ + [_playerItem addObserver:self forKeyPath:statusKeyPath options:0 context:nil]; + [_playerItem addObserver:self forKeyPath:playbackBufferEmptyKeyPath options:0 context:nil]; + [_playerItem addObserver:self forKeyPath:playbackLikelyToKeepUpKeyPath options:0 context:nil]; + [_playerItem addObserver:self forKeyPath:timedMetadata options:NSKeyValueObservingOptionNew context:nil]; + _playerItemObserversSet = YES; +} + +/* Fixes https://github.com/brentvatne/react-native-video/issues/43 + * Crashes caused when trying to remove the observer when there is no + * observer set */ +- (void)removePlayerItemObservers +{ + if (_playerItemObserversSet) { + [_playerItem removeObserver:self forKeyPath:statusKeyPath]; + [_playerItem removeObserver:self forKeyPath:playbackBufferEmptyKeyPath]; + [_playerItem removeObserver:self forKeyPath:playbackLikelyToKeepUpKeyPath]; + [_playerItem removeObserver:self forKeyPath:timedMetadata]; + _playerItemObserversSet = NO; + } +} + +#pragma mark - Player and source + +- (void)setSrc:(NSDictionary *)source +{ + _source = source; + [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:self->_source withCallback:^(AVPlayerItem * playerItem) { + self->_playerItem = playerItem; + _playerItem = playerItem; + [self setPreferredForwardBufferDuration:_preferredForwardBufferDuration]; + [self addPlayerItemObservers]; + [self setFilter:self->_filterName]; + [self setMaxBitRate:self->_maxBitRate]; + + [_player pause]; + + if (_playbackRateObserverRegistered) { + [_player removeObserver:self forKeyPath:playbackRate context:nil]; + _playbackRateObserverRegistered = NO; + } + if (self->_isExternalPlaybackActiveObserverRegistered) { + [self->_player removeObserver:self forKeyPath:externalPlaybackActive context:nil]; + self->_isExternalPlaybackActiveObserverRegistered = NO; + } + + self->_player = [AVPlayer playerWithPlayerItem:self->_playerItem]; + self->_player.actionAtItemEnd = AVPlayerActionAtItemEndNone; + + self.contentPlayhead = [[IMAAVPlayerContentPlayhead alloc] initWithAVPlayer:_player]; + [self setupAdsLoader]; + + [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]; + } + + //Perform on next run loop, otherwise onVideoLoadStart is nil + if (self.onVideoLoadStart) { + 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)[self->_source objectForKey:@"isNetwork"]]}, + @"drm": self->_drm ? self->_drm : [NSNull null], + @"target": self.reactTag + }); + } + }]; + }); + _videoLoadStarted = YES; +} + +- (void)setDrm:(NSDictionary *)drm { + _drm = drm; +} + +- (NSURL*) urlFilePath:(NSString*) filepath { + if ([filepath containsString:@"file://"]) { + return [NSURL URLWithString:filepath]; + } + + // if no file found, check if the file exists in the Document directory + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString* relativeFilePath = [filepath lastPathComponent]; + // the file may be multiple levels below the documents directory + NSArray* fileComponents = [filepath componentsSeparatedByString:@"Documents/"]; + if (fileComponents.count > 1) { + relativeFilePath = [fileComponents objectAtIndex:1]; + } + + NSString *path = [paths.firstObject stringByAppendingPathComponent:relativeFilePath]; + if ([[NSFileManager defaultManager] fileExistsAtPath:path]) { + return [NSURL fileURLWithPath:path]; + } + return nil; +} + +- (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nullable)assetOptions withCallback:(void(^)(AVPlayerItem *))handler +{ + if (!_textTracks || _textTracks.count==0) { + handler([AVPlayerItem playerItemWithAsset:asset]); + return; + } + + // AVPlayer can't airplay AVMutableCompositions + _allowsExternalPlayback = NO; + + // sideload text tracks + AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init]; + + AVAssetTrack *videoAsset = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject; + AVMutableCompositionTrack *videoCompTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid]; + [videoCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration) + ofTrack:videoAsset + atTime:kCMTimeZero + error:nil]; + + AVAssetTrack *audioAsset = [asset tracksWithMediaType:AVMediaTypeAudio].firstObject; + AVMutableCompositionTrack *audioCompTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid]; + [audioCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration) + ofTrack:audioAsset + atTime:kCMTimeZero + error:nil]; + + NSMutableArray* validTextTracks = [NSMutableArray array]; + for (int i = 0; i < _textTracks.count; ++i) { + AVURLAsset *textURLAsset; + NSString *textUri = [_textTracks objectAtIndex:i][@"uri"]; + if ([[textUri lowercaseString] hasPrefix:@"http"]) { + textURLAsset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:textUri] options:assetOptions]; + } else { + textURLAsset = [AVURLAsset URLAssetWithURL:[self urlFilePath:textUri] options:nil]; + } + AVAssetTrack *textTrackAsset = [textURLAsset tracksWithMediaType:AVMediaTypeText].firstObject; + if (!textTrackAsset) continue; // fix when there's no textTrackAsset + [validTextTracks addObject:[_textTracks objectAtIndex:i]]; + AVMutableCompositionTrack *textCompTrack = [mixComposition + addMutableTrackWithMediaType:AVMediaTypeText + preferredTrackID:kCMPersistentTrackID_Invalid]; + [textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration) + ofTrack:textTrackAsset + atTime:kCMTimeZero + error:nil]; + } + if (validTextTracks.count != _textTracks.count) { + [self setTextTracks:validTextTracks]; + } + + handler([AVPlayerItem playerItemWithAsset:mixComposition]); +} + +- (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlayerItem *))handler +{ + bool isNetwork = [RCTConvert BOOL:[source objectForKey:@"isNetwork"]]; + bool isAsset = [RCTConvert BOOL:[source objectForKey:@"isAsset"]]; + 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]]; + NSMutableDictionary *assetOptions = [[NSMutableDictionary alloc] init]; + + if (isNetwork) { + NSDictionary *headers = [source objectForKey:@"requestHeaders"]; + if ([headers count] > 0) { + [assetOptions setObject:headers forKey:@"AVURLAssetHTTPHeaderFieldsKey"]; + } + 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 + * to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded. + * Until this is fixed, we need to bypass caching when text tracks are specified. + */ + DebugLog(@"Caching is not supported for uri '%@' because text tracks are not compatible with the cache. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md", uri); + [self playerItemForSourceUsingCache:uri assetOptions:assetOptions withCallback:handler]; + return; + } +#endif + + asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; + } else if (isAsset) { + asset = [AVURLAsset URLAssetWithURL:url options:nil]; + } else { + asset = [AVURLAsset URLAssetWithURL:[[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]] options:nil]; + } + // Reset _loadingRequest + if (_loadingRequest != nil) { + [_loadingRequest finishLoading]; + } + _requestingCertificate = NO; + _requestingCertificateErrored = NO; + // End Reset _loadingRequest + if (self->_drm != nil) { + dispatch_queue_t queue = dispatch_queue_create("assetQueue", nil); + [asset.resourceLoader setDelegate:self queue:queue]; + } + + [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; + } + } + + 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 + +- (void)dvAssetLoaderDelegate:(DVAssetLoaderDelegate *)loaderDelegate + didLoadData:(NSData *)data + forURL:(NSURL *)url { + [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { + DebugLog(@"Cache data stored successfully 🎉"); + }]; +} + +#endif + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + + if([keyPath isEqualToString:readyForDisplayKeyPath] && [change objectForKey:NSKeyValueChangeNewKey] && self.onReadyForDisplay) { + self.onReadyForDisplay(@{@"target": self.reactTag}); + return; + } + if (object == _playerItem) { + // When timeMetadata is read the event onTimedMetadata is triggered + if ([keyPath isEqualToString:timedMetadata]) { + NSArray *items = [change objectForKey:@"new"]; + if (items && ![items isEqual:[NSNull null]] && items.count > 0) { + NSMutableArray *array = [NSMutableArray new]; + for (AVMetadataItem *item in items) { + NSString *value = (NSString *)item.value; + NSString *identifier = item.identifier; + + if (![value isEqual: [NSNull null]]) { + NSDictionary *dictionary = [[NSDictionary alloc] initWithObjects:@[value, identifier] forKeys:@[@"value", @"identifier"]]; + + [array addObject:dictionary]; + } + } + + self.onTimedMetadata(@{ + @"target": self.reactTag, + @"metadata": array + }); + } + } + + if ([keyPath isEqualToString:statusKeyPath]) { + // Handle player item status change. + if (_playerItem.status == AVPlayerItemStatusReadyToPlay) { + float duration = CMTimeGetSeconds(_playerItem.asset.duration); + + if (isnan(duration)) { + duration = 0.0; + } + + NSObject *width = @"undefined"; + NSObject *height = @"undefined"; + NSString *orientation = @"undefined"; + + if ([_playerItem.asset tracksWithMediaType:AVMediaTypeVideo].count > 0) { + AVAssetTrack *videoTrack = [[_playerItem.asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0]; + width = [NSNumber numberWithFloat:videoTrack.naturalSize.width]; + height = [NSNumber numberWithFloat:videoTrack.naturalSize.height]; + CGAffineTransform preferredTransform = [videoTrack preferredTransform]; + + if ((videoTrack.naturalSize.width == preferredTransform.tx + && videoTrack.naturalSize.height == preferredTransform.ty) + || (preferredTransform.tx == 0 && preferredTransform.ty == 0)) + { + orientation = @"landscape"; + } else { + orientation = @"portrait"; + } + } else if (_playerItem.presentationSize.height) { + width = [NSNumber numberWithFloat:_playerItem.presentationSize.width]; + height = [NSNumber numberWithFloat:_playerItem.presentationSize.height]; + orientation = _playerItem.presentationSize.width > _playerItem.presentationSize.height ? @"landscape" : @"portrait"; + } + + if (_pendingSeek) { + [self setCurrentTime:_pendingSeekTime]; + _pendingSeek = false; + } + + if (self.onVideoLoad && _videoLoadStarted) { + self.onVideoLoad(@{@"duration": [NSNumber numberWithFloat:duration], + @"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(_playerItem.currentTime)], + @"canPlayReverse": [NSNumber numberWithBool:_playerItem.canPlayReverse], + @"canPlayFastForward": [NSNumber numberWithBool:_playerItem.canPlayFastForward], + @"canPlaySlowForward": [NSNumber numberWithBool:_playerItem.canPlaySlowForward], + @"canPlaySlowReverse": [NSNumber numberWithBool:_playerItem.canPlaySlowReverse], + @"canStepBackward": [NSNumber numberWithBool:_playerItem.canStepBackward], + @"canStepForward": [NSNumber numberWithBool:_playerItem.canStepForward], + @"naturalSize": @{ + @"width": width, + @"height": height, + @"orientation": orientation + }, + @"audioTracks": [self getAudioTrackInfo], + @"textTracks": [self getTextTrackInfo], + @"target": self.reactTag}); + } + _videoLoadStarted = NO; + [self attachListeners]; + [self applyModifiers]; + } else if (_playerItem.status == AVPlayerItemStatusFailed && self.onVideoError) { + self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: _playerItem.error.code], + @"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]) { + _playerBufferEmpty = YES; + self.onVideoBuffer(@{@"isBuffering": @(YES), @"target": self.reactTag}); + } else if ([keyPath isEqualToString:playbackLikelyToKeepUpKeyPath]) { + // Continue playing (or not if paused) after being paused due to hitting an unbuffered zone. + if ((!(_controls || _fullscreenPlayerPresented) || _playerBufferEmpty) && _playerItem.playbackLikelyToKeepUp) { + [self setPaused:_paused]; + } + _playerBufferEmpty = NO; + self.onVideoBuffer(@{@"isBuffering": @(NO), @"target": self.reactTag}); + } + } else if (object == _player) { + if([keyPath isEqualToString:playbackRate]) { + if(self.onPlaybackRateChange) { + self.onPlaybackRateChange(@{@"playbackRate": [NSNumber numberWithFloat:_player.rate], + @"target": self.reactTag}); + } + if(_playbackStalled && _player.rate > 0) { + if(self.onPlaybackResume) { + self.onPlaybackResume(@{@"playbackRate": [NSNumber numberWithFloat:_player.rate], + @"target": self.reactTag}); + } + _playbackStalled = NO; + } + } + else if([keyPath isEqualToString:externalPlaybackActive]) { + 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 + if ([keyPath isEqualToString:@"frame"]) { + + CGRect oldRect = [change[NSKeyValueChangeOldKey] CGRectValue]; + CGRect newRect = [change[NSKeyValueChangeNewKey] CGRectValue]; + + if (!CGRectEqualToRect(oldRect, newRect)) { + if (CGRectEqualToRect(newRect, [UIScreen mainScreen].bounds)) { + NSLog(@"in fullscreen"); + + [self.reactViewController.view setFrame:[UIScreen mainScreen].bounds]; + [self.reactViewController.view setNeedsLayout]; + } else NSLog(@"not fullscreen"); + } + + return; + } + } +} + +- (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]; + } + if (self.onReceiveAdEvent) { + NSString *type = [RCTVideo convertEventToString: event.type]; + self.onReceiveAdEvent(@{@"event": type, + @"target": self.reactTag + }); + } +} + +- (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 + [[NSNotificationCenter defaultCenter] removeObserver:self + name:AVPlayerItemDidPlayToEndTimeNotification + object:[_player currentItem]]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(playerItemDidReachEnd:) + name:AVPlayerItemDidPlayToEndTimeNotification + object:[_player currentItem]]; + + [[NSNotificationCenter defaultCenter] removeObserver:self + name:AVPlayerItemPlaybackStalledNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(playbackStalled:) + name:AVPlayerItemPlaybackStalledNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:self + name:AVPlayerItemNewAccessLogEntryNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + 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]}); + } + */ +} + +- (void)didFailToFinishPlaying:(NSNotification *)notification { + NSError *error = notification.userInfo[AVPlayerItemFailedToPlayToEndTimeErrorKey]; + self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: error.code], + @"localizedDescription": [error localizedDescription] == nil ? @"" : [error localizedDescription], + @"localizedFailureReason": [error localizedFailureReason] == nil ? @"" : [error localizedFailureReason], + @"localizedRecoverySuggestion": [error localizedRecoverySuggestion] == nil ? @"" : [error localizedRecoverySuggestion], + @"domain": error.domain}, + @"target": self.reactTag}); +} + +- (void)playbackStalled:(NSNotification *)notification +{ + if(self.onPlaybackStalled) { + self.onPlaybackStalled(@{@"target": self.reactTag}); + } + _playbackStalled = YES; +} + +- (void)playerItemDidReachEnd:(NSNotification *)notification +{ + if (notification.object == _player.currentItem) { + [self.adsLoader contentComplete]; + } + if(self.onVideoEnd) { + self.onVideoEnd(@{@"target": self.reactTag}); + } + + if (_repeat) { + AVPlayerItem *item = [notification object]; + [item seekToTime:kCMTimeZero]; + [self applyModifiers]; + } else { + [self removePlayerTimeObserver]; + } +} + +#pragma mark - Prop setters + +- (void)setResizeMode:(NSString*)mode +{ + if( _controls ) + { + _playerViewController.videoGravity = mode; + } + else + { + _playerLayer.videoGravity = mode; + } + _resizeMode = mode; +} + +- (void)setPlayInBackground:(BOOL)playInBackground +{ + _playInBackground = playInBackground; +} + +- (void)setPreventsDisplaySleepDuringVideoPlayback:(BOOL)preventsDisplaySleepDuringVideoPlayback +{ + _preventsDisplaySleepDuringVideoPlayback = preventsDisplaySleepDuringVideoPlayback; + [self applyModifiers]; +} + +- (void)setAllowsExternalPlayback:(BOOL)allowsExternalPlayback +{ + _allowsExternalPlayback = allowsExternalPlayback; + _player.allowsExternalPlayback = _allowsExternalPlayback; +} + +- (void)setPlayWhenInactive:(BOOL)playWhenInactive +{ + _playWhenInactive = playWhenInactive; +} + +- (void)setPictureInPicture:(BOOL)pictureInPicture +{ + #if TARGET_OS_IOS + if (_pictureInPicture == pictureInPicture) { + return; + } + + _pictureInPicture = pictureInPicture; + if (_pipController && _pictureInPicture && ![_pipController isPictureInPictureActive]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [_pipController startPictureInPicture]; + }); + } else if (_pipController && !_pictureInPicture && [_pipController isPictureInPictureActive]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [_pipController stopPictureInPicture]; + }); + } + #endif +} + +#if TARGET_OS_IOS +- (void)setRestoreUserInterfaceForPIPStopCompletionHandler:(BOOL)restore +{ + if (_restoreUserInterfaceForPIPStopCompletionHandler != NULL) { + _restoreUserInterfaceForPIPStopCompletionHandler(restore); + _restoreUserInterfaceForPIPStopCompletionHandler = NULL; + } +} + +- (void)setupPipController { + if (!_pipController && _playerLayer && [AVPictureInPictureController isPictureInPictureSupported]) { + // Create new controller passing reference to the AVPlayerLayer + _pipController = [[AVPictureInPictureController alloc] initWithPlayerLayer:_playerLayer]; + _pipController.delegate = self; + } +} +#endif + +- (void)setIgnoreSilentSwitch:(NSString *)ignoreSilentSwitch +{ + _ignoreSilentSwitch = ignoreSilentSwitch; + [self applyModifiers]; +} + +- (void)setMixWithOthers:(NSString *)mixWithOthers +{ + _mixWithOthers = mixWithOthers; + [self applyModifiers]; +} + +- (void)setPaused:(BOOL)paused +{ + if (paused) { + [_player pause]; + [_player setRate:0.0]; + } else { + AVAudioSession *session = [AVAudioSession sharedInstance]; + AVAudioSessionCategory category = nil; + AVAudioSessionCategoryOptions options = nil; + + if([_ignoreSilentSwitch isEqualToString:@"ignore"]) { + category = AVAudioSessionCategoryPlayback; + } else if([_ignoreSilentSwitch isEqualToString:@"obey"]) { + category = AVAudioSessionCategoryAmbient; + } + + if([_mixWithOthers isEqualToString:@"mix"]) { + options = AVAudioSessionCategoryOptionMixWithOthers; + } else if([_mixWithOthers isEqualToString:@"duck"]) { + options = AVAudioSessionCategoryOptionDuckOthers; + } + + if (category != nil && options != nil) { + [session setCategory:category withOptions:options error:nil]; + } else if (category != nil && options == nil) { + [session setCategory:category error:nil]; + } else if (category == nil && options != nil) { + [session setCategory:session.category withOptions:options error:nil]; + } + + if (@available(iOS 10.0, *) && !_automaticallyWaitsToMinimizeStalling) { + [_player playImmediatelyAtRate:_rate]; + } else { + [_player play]; + [_player setRate:_rate]; + } + [_player setRate:_rate]; + } + + _paused = paused; +} + +- (float)getCurrentTime +{ + return _playerItem != NULL ? CMTimeGetSeconds(_playerItem.currentTime) : 0; +} + +- (void)setCurrentTime:(float)currentTime +{ + NSDictionary *info = @{ + @"time": [NSNumber numberWithFloat:currentTime], + @"tolerance": [NSNumber numberWithInt:100] + }; + [self setSeek:info]; +} + +- (void)setSeek:(NSDictionary *)info +{ + NSNumber *seekTime = info[@"time"]; + NSNumber *seekTolerance = info[@"tolerance"]; + + int timeScale = 1000; + + AVPlayerItem *item = _player.currentItem; + if (item && item.status == AVPlayerItemStatusReadyToPlay) { + // TODO check loadedTimeRanges + + CMTime cmSeekTime = CMTimeMakeWithSeconds([seekTime floatValue], timeScale); + CMTime current = item.currentTime; + // TODO figure out a good tolerance level + CMTime tolerance = CMTimeMake([seekTolerance floatValue], timeScale); + BOOL wasPaused = _paused; + + if (CMTimeCompare(current, cmSeekTime) != 0) { + if (!wasPaused) [_player pause]; + [_player seekToTime:cmSeekTime toleranceBefore:tolerance toleranceAfter:tolerance completionHandler:^(BOOL finished) { + if (!_timeObserver) { + [self addPlayerTimeObserver]; + } + if (!wasPaused) { + [self setPaused:false]; + } + if(self.onVideoSeek) { + self.onVideoSeek(@{@"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(item.currentTime)], + @"seekTime": seekTime, + @"target": self.reactTag}); + } + }]; + + _pendingSeek = false; + } + + } else { + _pendingSeek = true; + _pendingSeekTime = [seekTime floatValue]; + } +} + +- (void)setRate:(float)rate +{ + _rate = rate; + [self applyModifiers]; +} + +- (void)setMuted:(BOOL)muted +{ + _muted = muted; + [self applyModifiers]; +} + +- (void)setVolume:(float)volume +{ + _volume = volume; + [self applyModifiers]; +} + +- (void)setMaxBitRate:(float) maxBitRate { + _maxBitRate = maxBitRate; + _playerItem.preferredPeakBitRate = maxBitRate; +} + +- (void)setPreferredForwardBufferDuration:(float) preferredForwardBufferDuration +{ + _preferredForwardBufferDuration = preferredForwardBufferDuration; + _playerItem.preferredForwardBufferDuration = preferredForwardBufferDuration; +} + +- (void)setAutomaticallyWaitsToMinimizeStalling:(BOOL)waits +{ + _automaticallyWaitsToMinimizeStalling = waits; + _player.automaticallyWaitsToMinimizeStalling = waits; +} + + +- (void)applyModifiers +{ + if (_muted) { + if (!_controls) { + [_player setVolume:0]; + } + [_player setMuted:YES]; + } else { + [_player setVolume:_volume]; + [_player setMuted:NO]; + } + + if (@available(iOS 12.0, *)) { + self->_player.preventsDisplaySleepDuringVideoPlayback = _preventsDisplaySleepDuringVideoPlayback; + } else { + // Fallback on earlier versions + } + + [self setMaxBitRate:_maxBitRate]; + [self setSelectedAudioTrack:_selectedAudioTrack]; + [self setSelectedTextTrack:_selectedTextTrack]; + [self setResizeMode:_resizeMode]; + [self setRepeat:_repeat]; + [self setPaused:_paused]; + [self setControls:_controls]; + [self setAllowsExternalPlayback:_allowsExternalPlayback]; +} + +- (void)setRepeat:(BOOL)repeat { + _repeat = repeat; +} + +- (void)setMediaSelectionTrackForCharacteristic:(AVMediaCharacteristic)characteristic + withCriteria:(NSDictionary *)criteria +{ + 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; + } + } + //} 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]; +} + +- (void)setSelectedAudioTrack:(NSDictionary *)selectedAudioTrack { + _selectedAudioTrack = selectedAudioTrack; + [self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicAudible + withCriteria:_selectedAudioTrack]; +} + +- (void)setSelectedTextTrack:(NSDictionary *)selectedTextTrack { + _selectedTextTrack = selectedTextTrack; + if (_textTracks) { // sideloaded text tracks + [self setSideloadedText]; + } else { // text tracks included in the HLS playlist + [self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicLegible + withCriteria:_selectedTextTrack]; + } +} + +- (void) setSideloadedText { + NSString *type = _selectedTextTrack[@"type"]; + NSArray *textTracks = [self getTextTrackInfo]; + + // The first few tracks will be audio & video track + int firstTextIndex = 0; + for (firstTextIndex = 0; firstTextIndex < _player.currentItem.tracks.count; ++firstTextIndex) { + if ([_player.currentItem.tracks[firstTextIndex].assetTrack hasMediaCharacteristic:AVMediaCharacteristicLegible]) { + break; + } + } + + int selectedTrackIndex = RCTVideoUnset; + + if ([type isEqualToString:@"disabled"]) { + // Do nothing. We want to ensure option is nil + } else if ([type isEqualToString:@"language"]) { + NSString *selectedValue = _selectedTextTrack[@"value"]; + for (int i = 0; i < textTracks.count; ++i) { + NSDictionary *currentTextTrack = [textTracks objectAtIndex:i]; + if ([selectedValue isEqualToString:currentTextTrack[@"language"]]) { + selectedTrackIndex = i; + break; + } + } + } else if ([type isEqualToString:@"title"]) { + NSString *selectedValue = _selectedTextTrack[@"value"]; + for (int i = 0; i < textTracks.count; ++i) { + NSDictionary *currentTextTrack = [textTracks objectAtIndex:i]; + if ([selectedValue isEqualToString:currentTextTrack[@"title"]]) { + selectedTrackIndex = i; + break; + } + } + } else if ([type isEqualToString:@"index"]) { + if ([_selectedTextTrack[@"value"] isKindOfClass:[NSNumber class]]) { + int index = [_selectedTextTrack[@"value"] intValue]; + if (textTracks.count > index) { + selectedTrackIndex = index; + } + } + } + + // in the situation that a selected text track is not available (eg. specifies a textTrack not available) + if (![type isEqualToString:@"disabled"] && selectedTrackIndex == RCTVideoUnset) { + CFArrayRef captioningMediaCharacteristics = MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(kMACaptionAppearanceDomainUser); + NSArray *captionSettings = (__bridge NSArray*)captioningMediaCharacteristics; + if ([captionSettings containsObject:AVMediaCharacteristicTranscribesSpokenDialogForAccessibility]) { + selectedTrackIndex = 0; // If we can't find a match, use the first available track + NSString *systemLanguage = [[NSLocale preferredLanguages] firstObject]; + for (int i = 0; i < textTracks.count; ++i) { + NSDictionary *currentTextTrack = [textTracks objectAtIndex:i]; + if ([systemLanguage isEqualToString:currentTextTrack[@"language"]]) { + selectedTrackIndex = i; + break; + } + } + } + } + + for (int i = firstTextIndex; i < _player.currentItem.tracks.count; ++i) { + BOOL isEnabled = NO; + if (selectedTrackIndex != RCTVideoUnset) { + isEnabled = i == selectedTrackIndex + firstTextIndex; + } + [_player.currentItem.tracks[i] setEnabled:isEnabled]; + } +} + +-(void) setStreamingText { + NSString *type = _selectedTextTrack[@"type"]; + AVMediaSelectionGroup *group = [_player.currentItem.asset + mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicLegible]; + 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 = _selectedTextTrack[@"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 ([_selectedTextTrack[@"value"] isKindOfClass:[NSNumber class]]) { + int index = [_selectedTextTrack[@"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]; +} + +- (void)setTextTracks:(NSArray*) textTracks; +{ + _textTracks = textTracks; + + // in case textTracks was set after selectedTextTrack + if (_selectedTextTrack) [self setSelectedTextTrack:_selectedTextTrack]; +} + +- (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]; + } + return audioTracks; +} + +- (NSArray *)getTextTrackInfo +{ + // if sideloaded, textTracks will already be set + if (_textTracks) return _textTracks; + + // if streaming video, we extract the text tracks + NSMutableArray *textTracks = [[NSMutableArray alloc] init]; + AVMediaSelectionGroup *group = [_player.currentItem.asset + mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicLegible]; + 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 *textTrack = @{ + @"index": [NSNumber numberWithInt:i], + @"title": title, + @"language": language + }; + [textTracks addObject:textTrack]; + } + return textTracks; +} + +- (BOOL)getFullscreen +{ + return _fullscreenPlayerPresented; +} + +- (void)setFullscreen:(BOOL) fullscreen { + if( fullscreen && !_fullscreenPlayerPresented && _player ) + { + // Ensure player view controller is not null + if( !_playerViewController ) + { + [self usePlayerViewController]; + } + // Set presentation style to fullscreen + [_playerViewController setModalPresentationStyle:UIModalPresentationFullScreen]; + + // Find the nearest view controller + UIViewController *viewController = [self firstAvailableUIViewController]; + if( !viewController ) + { + UIWindow *keyWindow = [[UIApplication sharedApplication] keyWindow]; + viewController = keyWindow.rootViewController; + if( viewController.childViewControllers.count > 0 ) + { + viewController = viewController.childViewControllers.lastObject; + } + } + if( viewController ) + { + _presentingViewController = viewController; + if(self.onVideoFullscreenPlayerWillPresent) { + self.onVideoFullscreenPlayerWillPresent(@{@"target": self.reactTag}); + } + [viewController presentViewController:_playerViewController animated:true completion:^{ + _playerViewController.showsPlaybackControls = YES; + _fullscreenPlayerPresented = fullscreen; + _playerViewController.autorotate = _fullscreenAutorotate; + if(self.onVideoFullscreenPlayerDidPresent) { + self.onVideoFullscreenPlayerDidPresent(@{@"target": self.reactTag}); + } + }]; + } + } + else if ( !fullscreen && _fullscreenPlayerPresented ) + { + [self videoPlayerViewControllerWillDismiss:_playerViewController]; + [_presentingViewController dismissViewControllerAnimated:true completion:^{ + [self videoPlayerViewControllerDidDismiss:_playerViewController]; + }]; + } +} + +- (void)setFullscreenAutorotate:(BOOL)autorotate { + _fullscreenAutorotate = autorotate; + if (_fullscreenPlayerPresented) { + _playerViewController.autorotate = autorotate; + } +} + +- (void)setFullscreenOrientation:(NSString *)orientation { + _fullscreenOrientation = orientation; + if (_fullscreenPlayerPresented) { + _playerViewController.preferredOrientation = orientation; + } +} + +- (void)usePlayerViewController +{ + if( _player ) + { + if (!_playerViewController) { + _playerViewController = [self createPlayerViewController:_player withPlayerItem:_playerItem]; + } + // to prevent video from being animated when resizeMode is 'cover' + // resize mode must be set before subview is added + [self setResizeMode:_resizeMode]; + + if (_controls) { + UIViewController *viewController = [self reactViewController]; + [viewController addChildViewController:_playerViewController]; + [self addSubview:_playerViewController.view]; + } + + [_playerViewController addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil]; + + [_playerViewController.contentOverlayView addObserver:self forKeyPath:@"frame" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL]; + } +} + +- (void)usePlayerLayer +{ + if( _player ) + { + _playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; + _playerLayer.frame = self.bounds; + _playerLayer.needsDisplayOnBoundsChange = YES; + + // to prevent video from being animated when resizeMode is 'cover' + // resize mode must be set before layer is added + [self setResizeMode:_resizeMode]; + [_playerLayer addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil]; + _playerLayerObserverSet = YES; + + [self.layer addSublayer:_playerLayer]; + self.layer.needsDisplayOnBoundsChange = YES; + #if TARGET_OS_IOS + [self setupPipController]; + #endif + } +} + +- (void)setControls:(BOOL)controls +{ + if( _controls != controls || (!_playerLayer && !_playerViewController) ) + { + _controls = controls; + if( _controls ) + { + [self removePlayerLayer]; + [self usePlayerViewController]; + } + else + { + [_playerViewController.view removeFromSuperview]; + _playerViewController = nil; + [self usePlayerLayer]; + } + } +} + +- (void)setProgressUpdateInterval:(float)progressUpdateInterval +{ + _progressUpdateInterval = progressUpdateInterval; + + if (_timeObserver) { + [self removePlayerTimeObserver]; + [self addPlayerTimeObserver]; + } +} + +- (void)removePlayerLayer +{ + if (_loadingRequest != nil) { + [_loadingRequest finishLoading]; + } + _requestingCertificate = NO; + _requestingCertificateErrored = NO; + [_playerLayer removeFromSuperlayer]; + if (_playerLayerObserverSet) { + [_playerLayer removeObserver:self forKeyPath:readyForDisplayKeyPath]; + _playerLayerObserverSet = NO; + } + _playerLayer = nil; +} + +#pragma mark - RCTVideoPlayerViewControllerDelegate + +- (void)videoPlayerViewControllerWillDismiss:(AVPlayerViewController *)playerViewController +{ + if (_playerViewController == playerViewController && _fullscreenPlayerPresented && self.onVideoFullscreenPlayerWillDismiss) + { + @try{ + [_playerViewController.contentOverlayView removeObserver:self forKeyPath:@"frame"]; + [_playerViewController removeObserver:self forKeyPath:readyForDisplayKeyPath]; + }@catch(id anException){ + } + self.onVideoFullscreenPlayerWillDismiss(@{@"target": self.reactTag}); + } +} + +- (void)videoPlayerViewControllerDidDismiss:(AVPlayerViewController *)playerViewController +{ + if (_playerViewController == playerViewController && _fullscreenPlayerPresented) + { + _fullscreenPlayerPresented = false; + _presentingViewController = nil; + _playerViewController = nil; + [self applyModifiers]; + if(self.onVideoFullscreenPlayerDidDismiss) { + self.onVideoFullscreenPlayerDidDismiss(@{@"target": self.reactTag}); + } + } +} + +- (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]; + } + }]; +} + +- (void)setFilterEnabled:(BOOL)filterEnabled { + _filterEnabled = filterEnabled; +} + +- (void)setAdTagUrl:(NSString *)adTagUrl { + _adTagUrl = adTagUrl; +} + +#pragma mark - React View Management + +- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex +{ + // 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 && !_playerViewController ) + { + [self setControls:true]; + } + + if( _controls ) + { + view.frame = self.bounds; + [_playerViewController.contentOverlayView insertSubview:view atIndex:atIndex]; + } + else + { + RCTLogError(@"video cannot have any subviews"); + } + return; +} + +- (void)removeReactSubview:(UIView *)subview +{ + if( _controls ) + { + [subview removeFromSuperview]; + } + else + { + RCTLogError(@"video cannot have any subviews"); + } + return; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + if( _controls ) + { + _playerViewController.view.frame = self.bounds; + + // also adjust all subviews of contentOverlayView + for (UIView* subview in _playerViewController.contentOverlayView.subviews) { + subview.frame = self.bounds; + } + } + else + { + [CATransaction begin]; + [CATransaction setAnimationDuration:0]; + _playerLayer.frame = self.bounds; + [CATransaction commit]; + } +} + +#pragma mark - Lifecycle + +- (void)removeFromSuperview +{ + [_player pause]; + if (_playbackRateObserverRegistered) { + [_player removeObserver:self forKeyPath:playbackRate context:nil]; + _playbackRateObserverRegistered = NO; + } + if (_isExternalPlaybackActiveObserverRegistered) { + [_player removeObserver:self forKeyPath:externalPlaybackActive context:nil]; + _isExternalPlaybackActiveObserverRegistered = NO; + } + _player = nil; + + [self removePlayerLayer]; + + [_playerViewController.contentOverlayView removeObserver:self forKeyPath:@"frame"]; + [_playerViewController removeObserver:self forKeyPath:readyForDisplayKeyPath]; + [_playerViewController.view removeFromSuperview]; + _playerViewController.rctDelegate = nil; + _playerViewController.player = nil; + _playerViewController = nil; + + [self removePlayerTimeObserver]; + [self removePlayerItemObservers]; + + _eventDispatcher = nil; + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [super removeFromSuperview]; +} + +#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); + + } + + } 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; + } + } + return YES; +} + +- (NSString *)generatePathInDirectory:(NSString *)directory withExtension:(NSString *)extension { + 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]; +} + +#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; + if (self->_drm != nil) { + NSString *contentId; + NSString *contentIdOverride = (NSString *)[self->_drm objectForKey:@"contentId"]; + if (contentIdOverride != nil) { + contentId = contentIdOverride; + } else if (self.onGetLicense) { + contentId = url.host; + } else { + contentId = [url.absoluteString stringByReplacingOccurrencesOfString:@"skd://" withString:@""]; + } + 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; + if(self.onGetLicense) { + contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding]; + } else { + contentIdData = [NSData dataWithBytes: [contentId cStringUsingEncoding:NSUTF8StringEncoding] length:[contentId lengthOfBytesUsingEncoding: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 *base64Encoded = [spcData base64EncodedStringWithOptions:0]; + self->_requestingCertificate = YES; + if (licenseServer == nil) { + licenseServer = @""; + } + self.onGetLicense(@{@"licenseUrl": licenseServer, + @"contentId": contentId, + @"spcBase64": base64Encoded, + @"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]; + } + } + + if(self.onGetLicense) { + [request setHTTPBody: spcData]; + } else { + NSString *spcEncoded = [spcData base64EncodedStringWithOptions:0]; + NSString *spcUrlEncoded = (NSString *) CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, (CFStringRef)spcEncoded, NULL, CFSTR("?=&+"), kCFStringEncodingUTF8)); + NSString *post = [NSString stringWithFormat:@"spc=%@&%@", spcUrlEncoded, contentId]; + NSData *postData = [post dataUsingEncoding:NSUTF8StringEncoding allowLossyConversion:YES]; + [request setHTTPBody: postData]; + } + + 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) { + if(self.onGetLicense) { + [dataRequest respondWithData:data]; + } else { + NSData *decodedData = [[NSData alloc] initWithBase64EncodedData:data options:0]; + [dataRequest respondWithData:decodedData]; + } + [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] + }); + } +} + +- (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { + if (self.onPictureInPictureStatusChanged) { + self.onPictureInPictureStatusChanged(@{ + @"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 { + NSAssert(_restoreUserInterfaceForPIPStopCompletionHandler == NULL, @"restoreUserInterfaceForPIPStopCompletionHandler was not called after picture in picture was exited."); + if (self.onRestoreUserInterfaceForPictureInPictureStop) { + self.onRestoreUserInterfaceForPictureInPictureStop(@{}); + } + _restoreUserInterfaceForPIPStopCompletionHandler = completionHandler; +} +#endif + +#pragma mark - helpers ++ (NSString *)convertEventToString:(IMAAdEventType)event { + NSString *result = nil; + switch(event) { + case kIMAAdEvent_AD_BREAK_READY: + result = @"AD_BREAK_READY"; + break; + case kIMAAdEvent_AD_BREAK_ENDED: + result = @"AD_BREAK_ENDED"; + break; + case kIMAAdEvent_AD_BREAK_STARTED: + result = @"AD_BREAK_STARTED"; + break; + case kIMAAdEvent_AD_PERIOD_ENDED: + result = @"AD_PERIOD_ENDED"; + break; + case kIMAAdEvent_AD_PERIOD_STARTED: + result = @"AD_PERIOD_STARTED"; + break; + case kIMAAdEvent_ALL_ADS_COMPLETED: + result = @"ALL_ADS_COMPLETED"; + break; + case kIMAAdEvent_CLICKED: + result = @"CLICKED"; + break; + case kIMAAdEvent_COMPLETE: + result = @"COMPLETE"; + break; + case kIMAAdEvent_CUEPOINTS_CHANGED: + result = @"CUEPOINTS_CHANGED"; + break; + case kIMAAdEvent_FIRST_QUARTILE: + result = @"FIRST_QUARTILE"; + break; + case kIMAAdEvent_LOADED: + result = @"LOADED"; + break; + case kIMAAdEvent_LOG: + result = @"LOG"; + break; + case kIMAAdEvent_MIDPOINT: + result = @"MIDPOINT"; + break; + case kIMAAdEvent_PAUSE: + result = @"PAUSE"; + break; + case kIMAAdEvent_RESUME: + result = @"RESUME"; + break; + case kIMAAdEvent_SKIPPED: + result = @"SKIPPED"; + break; + case kIMAAdEvent_STARTED: + result = @"STARTED"; + break; + case kIMAAdEvent_STREAM_LOADED: + result = @"STREAM_LOADED"; + break; + case kIMAAdEvent_TAPPED: + result = @"TAPPED"; + break; + case kIMAAdEvent_THIRD_QUARTILE: + result = @"THIRD_QUARTILE"; + break; + default: + result = @"UNKNOWN"; + } + + return result; +} + +@end 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..439fd3be 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -16,6 +16,8 @@ Pod::Spec.new do |s| 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,swift}" ss.dependency "PromisesSwift" end