diff --git a/Video.js b/Video.js index e8203b8bf3..73cc91646c 100644 --- a/Video.js +++ b/Video.js @@ -1,6 +1,16 @@ -import React, {Component} from 'react'; +import React, { + Component +} from 'react'; import PropTypes from 'prop-types'; -import {StyleSheet, requireNativeComponent, NativeModules, View, ViewPropTypes, Image, Platform} from 'react-native'; +import { + StyleSheet, + requireNativeComponent, + NativeModules, + View, + ViewPropTypes, + Image, + Platform +} from 'react-native'; import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; import TextTrackType from './TextTrackType'; import VideoResizeMode from './VideoResizeMode.js'; @@ -11,7 +21,14 @@ const styles = StyleSheet.create({ }, }); -export { TextTrackType }; +const { + ExoPlayerCache +} = NativeModules; + +export { + TextTrackType, + ExoPlayerCache, +}; export default class Video extends Component { @@ -26,17 +43,17 @@ export default class Video extends Component { setNativeProps(nativeProps) { this._root.setNativeProps(nativeProps); } - + toTypeString(x) { switch (typeof x) { case "object": return x instanceof Date - ? x.toISOString() + ? x.toISOString() : JSON.stringify(x); // object, null case "undefined": return ""; default: // boolean, number, string - return x.toString(); + return x.toString(); } } @@ -59,16 +76,22 @@ export default class Video extends Component { } }); } else { - this.setNativeProps({ seek: time }); + this.setNativeProps({ + seek: time + }); } }; presentFullscreenPlayer = () => { - this.setNativeProps({ fullscreen: true }); + this.setNativeProps({ + fullscreen: true + }); }; dismissFullscreenPlayer = () => { - this.setNativeProps({ fullscreen: false }); + this.setNativeProps({ + fullscreen: false + }); }; _assignRoot = (component) => { @@ -101,7 +124,9 @@ export default class Video extends Component { _onSeek = (event) => { if (this.state.showPoster && !this.props.audioOnly) { - this.setState({showPoster: false}); + this.setState({ + showPoster: false + }); } if (this.props.onSeek) { @@ -165,7 +190,9 @@ export default class Video extends Component { _onPlaybackRateChange = (event) => { if (this.state.showPoster && event.nativeEvent.playbackRate !== 0 && !this.props.audioOnly) { - this.setState({showPoster: false}); + this.setState({ + showPoster: false + }); } if (this.props.onPlaybackRateChange) { diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java new file mode 100644 index 0000000000..cc318a1ba6 --- /dev/null +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java @@ -0,0 +1,153 @@ +package com.brentvatne.exoplayer; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +import java.io.IOException; +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; + +public class ExoPlayerCache extends ReactContextBaseJavaModule { + + private static SimpleCache instance = null; + private static final String CACHE_KEY_PREFIX = "exoPlayerCacheKeyPrefix"; + private static int maxCacheSizeBytes = -1; // Default no maximum size + private static String cacheSubDirectory = ""; + + public ExoPlayerCache(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "ExoPlayerCache"; + } + + @ReactMethod + public void setMaxCacheSize(final int bytes, final Promise promise) { + maxCacheSizeBytes = bytes; + promise.resolve(maxCacheSizeBytes); + } + + @ReactMethod + public void setCacheSubDirectory(final String directory, final Promise promise) { + cacheSubDirectory = directory; + promise.resolve(cacheSubDirectory); + } + + @ReactMethod + public void exportVideo(final String url, final Promise promise) { + Log.d(getName(), "exportVideo"); + + Thread exportThread = new Thread(new Runnable() { + @Override + public void run() { + Log.d(getName(), "Exporting..."); + Log.d(getName(), url); + final Uri uri = Uri.parse(url); + final DataSpec dataSpec = new DataSpec(uri, 0, 100 * 1024 * 1024, null); // TODO won't work for video's over 100 MB + final SimpleCache downloadCache = ExoPlayerCache.getInstance(getReactApplicationContext()); + CacheKeyFactory cacheKeyFactory = ds -> CACHE_KEY_PREFIX + "." + CacheUtil.generateKey(ds.uri);; + + try { + CacheUtil.getCached( + dataSpec, + downloadCache, + cacheKeyFactory + ); + + DataSourceInputStream inputStream = new DataSourceInputStream(createDataSource(downloadCache), dataSpec); + + File targetFile = new File(ExoPlayerCache.getCacheDir(getReactApplicationContext()) + "/" + uri.getLastPathSegment()); + OutputStream outStream = new FileOutputStream(targetFile); + + byte[] buffer = new byte[8 * 1024]; + int bytesRead; + try { + while ((bytesRead = inputStream.read(buffer)) != -1) { + outStream.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + // TODO this exception should not be thrown + Log.d(getName(), "Read error"); + e.printStackTrace(); + + throw e; + } + + CacheUtil.getCached( + dataSpec, + downloadCache, + cacheKeyFactory + ); + + if (!targetFile.exists()) { + throw new Exception("Target file not present after writing bytes"); + } + + Log.d(getName(), "Export succeeded"); + Log.d(getName(), targetFile.getPath()); + + WritableMap result = Arguments.createMap(); + result.putString("path", targetFile.getPath()); + + promise.resolve(result); + } catch (Exception e) { + Log.d(getName(), "Export error"); + e.printStackTrace(); + + String className = e.getClass().getSimpleName(); + promise.reject(className, className + ": " + e.getMessage()); + return; + } + } + }, "export_thread"); + exportThread.start(); + } + + public static SimpleCache getInstance(Context context) { + if(instance == null) { + instance = new SimpleCache( + new File(ExoPlayerCache.getCacheDir(context) + cacheSubDirectory), + maxCacheSizeBytes == -1 + ? new NoOpCacheEvictor() + : new LeastRecentlyUsedCacheEvictor(maxCacheSizeBytes) + ); + } + return instance; + } + + private static String getCacheDir(Context context) { + return context.getCacheDir().toString() + "/video"; + } + + private DataSource createDataSource(Cache cache) { + return new CacheDataSourceFactory(cache, DataSourceUtil.getDefaultDataSourceFactory( + getReactApplicationContext(), + null, + null + )).createDataSource(); + } + +} diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 0c1a178f0b..7d41eacce7 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -35,7 +35,6 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.metadata.Metadata; @@ -60,7 +59,9 @@ 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.util.MimeTypes; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.Util; import java.net.CookieHandler; @@ -96,6 +97,7 @@ class ReactExoplayerView extends FrameLayout implements private Handler mainHandler; private ExoPlayerView exoPlayerView; + private SimpleCache downloadCache; private DataSource.Factory mediaDataSourceFactory; private SimpleExoPlayer player; private DefaultTrackSelector trackSelector; @@ -109,6 +111,7 @@ class ReactExoplayerView extends FrameLayout implements private boolean isPaused; private boolean isBuffering; private float rate = 1f; + private int codecRetries = 0; private int minBufferMs = DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; private int maxBufferMs = DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; @@ -179,6 +182,8 @@ public void setId(int id) { private void createViews() { clearResumePosition(); mediaDataSourceFactory = buildDataSourceFactory(true); + downloadCache = ExoPlayerCache.getInstance(getContext()); + mainHandler = new Handler(); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); @@ -292,8 +297,8 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension) { case C.TYPE_HLS: return new HlsMediaSource(uri, mediaDataSourceFactory, mainHandler, null); case C.TYPE_OTHER: - return new ExtractorMediaSource(uri, mediaDataSourceFactory, new DefaultExtractorsFactory(), - mainHandler, null); + return new ExtractorMediaSource.Factory(new CacheDataSourceFactory(downloadCache, mediaDataSourceFactory)) + .createMediaSource(uri); default: { throw new IllegalStateException("Unsupported type: " + type); } @@ -654,6 +659,28 @@ else if (e.type == ExoPlaybackException.TYPE_SOURCE) { ex = e.getSourceException(); errorString = getResources().getString(R.string.unrecognized_media_format); } + + // Assumption: If we have a codec error it could be that it is still busy because another component has not yet unmounted. + // We retry this max 3 times with some delay and see if the error decreases. + int MAX_CODEC_RETRIES = 3; + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + if (this.codecRetries < MAX_CODEC_RETRIES) { + this.codecRetries += 1; + + // Release the current player. + releasePlayer(); + + // Retry init after some delay. + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + initializePlayer(); + } + }, 500); + return; + } + } + if (errorString != null) { eventEmitter.error(errorString, ex); } diff --git a/android-exoplayer/src/main/java/com/brentvatne/react/ReactVideoPackage.java b/android-exoplayer/src/main/java/com/brentvatne/react/ReactVideoPackage.java index 286f972c8c..67906d5eac 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/react/ReactVideoPackage.java +++ b/android-exoplayer/src/main/java/com/brentvatne/react/ReactVideoPackage.java @@ -1,12 +1,14 @@ package com.brentvatne.react; import com.brentvatne.exoplayer.ReactExoplayerViewManager; +import com.brentvatne.exoplayer.ExoPlayerCache; import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -14,7 +16,11 @@ public class ReactVideoPackage implements ReactPackage { @Override public List createNativeModules(ReactApplicationContext reactContext) { - return Collections.emptyList(); + List modules = new ArrayList<>(); + + modules.add(new ExoPlayerCache(reactContext)); + + return modules; } // Deprecated RN 0.47