diff options
54 files changed, 6559 insertions, 121 deletions
diff --git a/app/build.gradle b/app/build.gradle index 9132c52c..31ad2fc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,11 +77,11 @@ dependencies { implementation "com.google.android.exoplayer:exoplayer-dash:$exoplayerVersion" implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayerVersion" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion" - def toroVersion = "3.7.0.2010003" + /*def toroVersion = "3.7.0.2010003" implementation "im.ene.toro3:toro:$toroVersion" implementation("im.ene.toro3:toro-ext-exoplayer:$toroVersion") { exclude module: 'extension-ima' - } + }*/ /** Third-party **/ diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java b/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java index 78e475b8..3f5f51fd 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java @@ -20,15 +20,15 @@ import javax.inject.Singleton; import dagger.Module; import dagger.Provides; -import im.ene.toro.exoplayer.Config; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.exoplayer.MediaSourceBuilder; -import im.ene.toro.exoplayer.ToroExo; import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper; import ml.docilealligator.infinityforreddit.customviews.LoopAvailableExoCreator; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.CustomThemeSharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; +import ml.docilealligator.infinityforreddit.videoautoplay.Config; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.MediaSourceBuilder; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroExo; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; import retrofit2.Retrofit; @@ -119,9 +119,24 @@ class AppModule { @Provides @Named("redgifs") @Singleton - Retrofit provideRedgifsRetrofit() { + Retrofit provideRedgifsRetrofit(@Named("current_account") SharedPreferences currentAccountSharedPreferences) { + OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient.Builder(); + okHttpClientBuilder + .addInterceptor(chain -> chain.proceed( + chain.request() + .newBuilder() + .header("User-Agent", APIUtils.getRedgifsUserAgent(mApplication)) + .build() + )) + .addInterceptor(new RedgifsAccessTokenAuthenticator(currentAccountSharedPreferences)) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .connectionPool(new ConnectionPool(0, 1, TimeUnit.NANOSECONDS)); + return new Retrofit.Builder() .baseUrl(APIUtils.REDGIFS_API_BASE_URI) + .client(okHttpClientBuilder.build()) .addConverterFactory(ScalarsConverterFactory.create()) .build(); } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/FetchGfycatOrRedgifsVideoLinks.java b/app/src/main/java/ml/docilealligator/infinityforreddit/FetchGfycatOrRedgifsVideoLinks.java index 8040d466..d1bfd941 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/FetchGfycatOrRedgifsVideoLinks.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/FetchGfycatOrRedgifsVideoLinks.java @@ -1,5 +1,7 @@ package ml.docilealligator.infinityforreddit; +import android.content.Context; +import android.content.SharedPreferences; import android.os.Handler; import org.json.JSONException; @@ -9,7 +11,10 @@ import java.io.IOException; import java.util.concurrent.Executor; import ml.docilealligator.infinityforreddit.apis.GfycatAPI; +import ml.docilealligator.infinityforreddit.apis.RedgifsAPI; +import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.JSONUtils; +import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import retrofit2.Call; import retrofit2.Response; import retrofit2.Retrofit; @@ -21,18 +26,14 @@ public class FetchGfycatOrRedgifsVideoLinks { void failed(int errorCode); } - public static void fetchGfycatOrRedgifsVideoLinks(Executor executor, Handler handler, Retrofit gfycatRetrofit, - String gfycatId, boolean isGfycatVideo, - FetchGfycatOrRedgifsVideoLinksListener fetchGfycatOrRedgifsVideoLinksListener) { + public static void fetchGfycatVideoLinks(Executor executor, Handler handler, Retrofit gfycatRetrofit, + String gfycatId, + FetchGfycatOrRedgifsVideoLinksListener fetchGfycatOrRedgifsVideoLinksListener) { executor.execute(() -> { try { Response<String> response = gfycatRetrofit.create(GfycatAPI.class).getGfycatData(gfycatId).execute(); if (response.isSuccessful()) { - if (isGfycatVideo) { - parseGfycatVideoLinks(handler, response.body(), fetchGfycatOrRedgifsVideoLinksListener); - } else { - parseRedgifsVideoLinks(handler, response.body(), fetchGfycatOrRedgifsVideoLinksListener); - } + parseGfycatVideoLinks(handler, response.body(), fetchGfycatOrRedgifsVideoLinksListener); } else { handler.post(() -> fetchGfycatOrRedgifsVideoLinksListener.failed(response.code())); } @@ -41,12 +42,31 @@ public class FetchGfycatOrRedgifsVideoLinks { handler.post(() -> fetchGfycatOrRedgifsVideoLinksListener.failed(-1)); } }); + } + public static void fetchRedgifsVideoLinks(Context context, Executor executor, Handler handler, Retrofit redgifsRetrofit, + SharedPreferences currentAccountSharedPreferences, + String gfycatId, + FetchGfycatOrRedgifsVideoLinksListener fetchGfycatOrRedgifsVideoLinksListener) { + executor.execute(() -> { + try { + Response<String> response = redgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(currentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), + gfycatId, APIUtils.getRedgifsUserAgent(context)).execute(); + if (response.isSuccessful()) { + parseRedgifsVideoLinks(handler, response.body(), fetchGfycatOrRedgifsVideoLinksListener); + } else { + handler.post(() -> fetchGfycatOrRedgifsVideoLinksListener.failed(response.code())); + } + } catch (IOException e) { + e.printStackTrace(); + handler.post(() -> fetchGfycatOrRedgifsVideoLinksListener.failed(-1)); + } + }); } public static void fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(Executor executor, Handler handler, Call<String> gfycatCall, - String gfycatId, boolean isGfycatVideo, + boolean isGfycatVideo, boolean automaticallyTryRedgifs, FetchGfycatOrRedgifsVideoLinksListener fetchGfycatOrRedgifsVideoLinksListener) { executor.execute(() -> { @@ -61,7 +81,7 @@ public class FetchGfycatOrRedgifsVideoLinks { } else { if (response.code() == 404 && isGfycatVideo && automaticallyTryRedgifs) { fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(executor, handler, gfycatCall.clone(), - gfycatId, false, false, fetchGfycatOrRedgifsVideoLinksListener); + false, false, fetchGfycatOrRedgifsVideoLinksListener); } else { handler.post(() -> fetchGfycatOrRedgifsVideoLinksListener.failed(response.code())); } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/RedgifsAccessTokenAuthenticator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/RedgifsAccessTokenAuthenticator.java new file mode 100644 index 00000000..a4feec43 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/RedgifsAccessTokenAuthenticator.java @@ -0,0 +1,90 @@ +package ml.docilealligator.infinityforreddit; + +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import ml.docilealligator.infinityforreddit.apis.RedgifsAPI; +import ml.docilealligator.infinityforreddit.utils.APIUtils; +import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; +import okhttp3.Headers; +import okhttp3.Interceptor; +import okhttp3.Response; +import retrofit2.Call; +import retrofit2.Retrofit; +import retrofit2.converter.scalars.ScalarsConverterFactory; + +public class RedgifsAccessTokenAuthenticator implements Interceptor { + private SharedPreferences mCurrentAccountSharedPreferences; + + public RedgifsAccessTokenAuthenticator(SharedPreferences currentAccountSharedPreferences) { + this.mCurrentAccountSharedPreferences = currentAccountSharedPreferences; + } + + private String refreshAccessToken() { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(APIUtils.REDGIFS_API_BASE_URI) + .addConverterFactory(ScalarsConverterFactory.create()) + .build(); + RedgifsAPI api = retrofit.create(RedgifsAPI.class); + + Map<String, String> params = new HashMap<>(); + params.put(APIUtils.GRANT_TYPE_KEY, APIUtils.GRANT_TYPE_CLIENT_CREDENTIALS); + params.put(APIUtils.CLIENT_ID_KEY, APIUtils.REDGIFS_CLIENT_ID); + params.put(APIUtils.CLIENT_SECRET_KEY, APIUtils.REDGIFS_CLIENT_SECRET); + + Call<String> accessTokenCall = api.getRedgifsAccessToken(params); + try { + retrofit2.Response<String> response = accessTokenCall.execute(); + if (response.isSuccessful() && response.body() != null) { + String newAccessToken = new JSONObject(response.body()).getString(APIUtils.ACCESS_TOKEN_KEY); + mCurrentAccountSharedPreferences.edit().putString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, newAccessToken).apply(); + + return newAccessToken; + } + return ""; + } catch (IOException | JSONException e) { + e.printStackTrace(); + } + + return ""; + } + + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + Response response = chain.proceed(chain.request()); + if (response.code() == 401 || response.code() == 400) { + String accessTokenHeader = response.request().header(APIUtils.AUTHORIZATION_KEY); + if (accessTokenHeader == null) { + return response; + } + + String accessToken = accessTokenHeader.substring(APIUtils.AUTHORIZATION_BASE.length() - 1).trim(); + synchronized (this) { + String accessTokenFromSharedPreferences = mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, ""); + if (accessToken.equals(accessTokenFromSharedPreferences)) { + String newAccessToken = refreshAccessToken(); + if (!newAccessToken.equals("")) { + response.close(); + return chain.proceed(response.request().newBuilder().headers(Headers.of(APIUtils.getRedgifsOAuthHeader(newAccessToken))).build()); + } else { + return response; + } + } else { + response.close(); + return chain.proceed(response.request().newBuilder().headers(Headers.of(APIUtils.getRedgifsOAuthHeader(accessTokenFromSharedPreferences))).build()); + } + } + } + return response; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewVideoActivity.java b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewVideoActivity.java index 434390fb..2fab98e7 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewVideoActivity.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewVideoActivity.java @@ -65,7 +65,6 @@ import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoListener; import com.google.android.material.bottomappbar.BottomAppBar; import com.google.android.material.snackbar.Snackbar; @@ -104,6 +103,7 @@ import ml.docilealligator.infinityforreddit.post.FetchPost; import ml.docilealligator.infinityforreddit.post.Post; import ml.docilealligator.infinityforreddit.services.DownloadMediaService; import ml.docilealligator.infinityforreddit.services.DownloadRedditVideoService; +import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; import retrofit2.Call; @@ -221,6 +221,10 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe SharedPreferences mSharedPreferences; @Inject + @Named("current_account") + SharedPreferences mCurrentAccountSharedPreferences; + + @Inject CustomThemeWrapper mCustomThemeWrapper; @Inject @@ -503,8 +507,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe loadStreamableVideo(shortCode, savedInstanceState); } else { dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultDataSourceFactory(ViewVideoActivity.this, - Util.getUserAgent(ViewVideoActivity.this, "Infinity"))); + new DefaultDataSourceFactory(ViewVideoActivity.this, APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); preparePlayer(savedInstanceState); } @@ -535,8 +538,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe } } else { dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultDataSourceFactory(ViewVideoActivity.this, - Util.getUserAgent(ViewVideoActivity.this, "Infinity"))); + new DefaultDataSourceFactory(ViewVideoActivity.this, APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); preparePlayer(savedInstanceState); } @@ -549,7 +551,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe } // Produces DataSource instances through which media data is loaded. dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultHttpDataSourceFactory(Util.getUserAgent(this, "Infinity"))); + new DefaultHttpDataSourceFactory(APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); // Prepare the player with the source. player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); preparePlayer(savedInstanceState); @@ -560,7 +562,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe videoFileName = subredditName + "-" + id + ".mp4"; // Produces DataSource instances through which media data is loaded. dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultHttpDataSourceFactory(Util.getUserAgent(this, "Infinity"))); + new DefaultHttpDataSourceFactory(APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); // Prepare the player with the source. player.prepare(new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); preparePlayer(savedInstanceState); @@ -678,24 +680,22 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe private void loadGfycatOrRedgifsVideo(Retrofit retrofit, String gfycatId, boolean isGfycatVideo, Bundle savedInstanceState, boolean needErrorHandling) { progressBar.setVisibility(View.VISIBLE); - FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinks(mExecutor, new Handler(), retrofit, gfycatId, - isGfycatVideo, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { - @Override - public void success(String webm, String mp4) { - progressBar.setVisibility(View.GONE); - mVideoUri = Uri.parse(webm); - videoDownloadUrl = mp4; - dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultDataSourceFactory(ViewVideoActivity.this, - Util.getUserAgent(ViewVideoActivity.this, "Infinity"))); - preparePlayer(savedInstanceState); - player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); - } + if (isGfycatVideo) { + FetchGfycatOrRedgifsVideoLinks.fetchGfycatVideoLinks(mExecutor, new Handler(), retrofit, gfycatId, + new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { + @Override + public void success(String webm, String mp4) { + progressBar.setVisibility(View.GONE); + mVideoUri = Uri.parse(webm); + videoDownloadUrl = mp4; + dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, + new DefaultDataSourceFactory(ViewVideoActivity.this, APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); + preparePlayer(savedInstanceState); + player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); + } - @Override - public void failed(int errorCode) { - progressBar.setVisibility(View.GONE); - if (videoType == VIDEO_TYPE_GFYCAT) { + @Override + public void failed(int errorCode) { if (errorCode == 404 && needErrorHandling) { if (mSharedPreferences.getBoolean(SharedPreferencesUtils.AUTOMATICALLY_TRY_REDGIFS, true)) { loadGfycatOrRedgifsVideo(redgifsRetrofit, gfycatId, false, savedInstanceState, false); @@ -704,13 +704,32 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe view -> loadGfycatOrRedgifsVideo(redgifsRetrofit, gfycatId, false, savedInstanceState, false)).show(); } } else { + progressBar.setVisibility(View.GONE); Toast.makeText(ViewVideoActivity.this, R.string.fetch_gfycat_video_failed, Toast.LENGTH_SHORT).show(); } - } else { + } + }); + } else { + FetchGfycatOrRedgifsVideoLinks.fetchRedgifsVideoLinks(this, mExecutor, new Handler(), redgifsRetrofit, + mCurrentAccountSharedPreferences, gfycatId, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { + @Override + public void success(String webm, String mp4) { + progressBar.setVisibility(View.GONE); + mVideoUri = Uri.parse(webm); + videoDownloadUrl = mp4; + dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, + new DefaultDataSourceFactory(ViewVideoActivity.this, APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); + preparePlayer(savedInstanceState); + player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); + } + + @Override + public void failed(int errorCode) { + progressBar.setVisibility(View.GONE); Toast.makeText(ViewVideoActivity.this, R.string.fetch_redgifs_video_failed, Toast.LENGTH_SHORT).show(); } - } - }); + }); + } } private void loadVReddItVideo(Bundle savedInstanceState) { @@ -764,7 +783,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe videoFileName = "imgur-" + FilenameUtils.getName(videoDownloadUrl); // Produces DataSource instances through which media data is loaded. dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultHttpDataSourceFactory(Util.getUserAgent(ViewVideoActivity.this, "Infinity"))); + new DefaultHttpDataSourceFactory(APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); // Prepare the player with the source. player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); preparePlayer(savedInstanceState); @@ -779,9 +798,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe videoFileName = subredditName + "-" + id + ".mp4"; // Produces DataSource instances through which media data is loaded. dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultHttpDataSourceFactory( - Util.getUserAgent(ViewVideoActivity.this, - "Infinity"))); + new DefaultHttpDataSourceFactory(APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); // Prepare the player with the source. preparePlayer(savedInstanceState); player.prepare(new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); @@ -823,8 +840,7 @@ public class ViewVideoActivity extends AppCompatActivity implements CustomFontRe videoDownloadUrl = streamableVideo.mp4 == null ? streamableVideo.mp4Mobile.url : streamableVideo.mp4.url; mVideoUri = Uri.parse(videoDownloadUrl); dataSourceFactory = new CacheDataSourceFactory(mSimpleCache, - new DefaultDataSourceFactory(ViewVideoActivity.this, - Util.getUserAgent(ViewVideoActivity.this, "Infinity"))); + new DefaultDataSourceFactory(ViewVideoActivity.this, APIUtils.getRedgifsUserAgent(ViewVideoActivity.this))); preparePlayer(savedInstanceState); player.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mVideoUri)); } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/HistoryPostRecyclerViewAdapter.java b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/HistoryPostRecyclerViewAdapter.java index 201e7930..2eba5f0c 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/HistoryPostRecyclerViewAdapter.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/HistoryPostRecyclerViewAdapter.java @@ -56,14 +56,6 @@ import java.util.concurrent.Executor; import butterknife.BindView; import butterknife.ButterKnife; -import im.ene.toro.CacheManager; -import im.ene.toro.ToroPlayer; -import im.ene.toro.ToroUtil; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.exoplayer.ExoPlayerViewHelper; -import im.ene.toro.exoplayer.Playable; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.widget.Container; import jp.wasabeef.glide.transformations.BlurTransformation; import jp.wasabeef.glide.transformations.RoundedCornersTransformation; import ml.docilealligator.infinityforreddit.FetchGfycatOrRedgifsVideoLinks; @@ -83,18 +75,26 @@ import ml.docilealligator.infinityforreddit.activities.ViewSubredditDetailActivi import ml.docilealligator.infinityforreddit.activities.ViewUserDetailActivity; import ml.docilealligator.infinityforreddit.activities.ViewVideoActivity; import ml.docilealligator.infinityforreddit.apis.GfycatAPI; +import ml.docilealligator.infinityforreddit.apis.RedgifsAPI; import ml.docilealligator.infinityforreddit.apis.StreamableAPI; import ml.docilealligator.infinityforreddit.bottomsheetfragments.ShareLinkBottomSheetFragment; import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper; import ml.docilealligator.infinityforreddit.customviews.AspectRatioGifImageView; import ml.docilealligator.infinityforreddit.events.PostUpdateEventToPostDetailFragment; import ml.docilealligator.infinityforreddit.fragments.HistoryPostFragment; -import ml.docilealligator.infinityforreddit.fragments.PostFragment; import ml.docilealligator.infinityforreddit.post.Post; import ml.docilealligator.infinityforreddit.post.PostPagingSource; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.CacheManager; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoPlayerViewHelper; +import ml.docilealligator.infinityforreddit.videoautoplay.Playable; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; import pl.droidsonroids.gif.GifImageView; import retrofit2.Call; import retrofit2.Retrofit; @@ -124,6 +124,7 @@ public class HistoryPostRecyclerViewAdapter extends PagingDataAdapter<Post, Recy private BaseActivity mActivity; private HistoryPostFragment mFragment; private SharedPreferences mSharedPreferences; + private SharedPreferences mCurrentAccountSharedPreferences; private Executor mExecutor; private Retrofit mOauthRetrofit; private Retrofit mGfycatRetrofit; @@ -209,14 +210,15 @@ public class HistoryPostRecyclerViewAdapter extends PagingDataAdapter<Post, Recy Retrofit gfycatRetrofit, Retrofit redgifsRetrofit, Retrofit streambleRetrofit, CustomThemeWrapper customThemeWrapper, Locale locale, String accessToken, String accountName, int postType, int postLayout, boolean displaySubredditName, - SharedPreferences sharedPreferences, SharedPreferences nsfwAndSpoilerSharedPreferences, - SharedPreferences postHistorySharedPreferences, + SharedPreferences sharedPreferences, SharedPreferences currentAccountSharedPreferences, + SharedPreferences nsfwAndSpoilerSharedPreferences, ExoCreator exoCreator, Callback callback) { super(DIFF_CALLBACK); if (activity != null) { mActivity = activity; mFragment = fragment; mSharedPreferences = sharedPreferences; + mCurrentAccountSharedPreferences = currentAccountSharedPreferences; mExecutor = executor; mOauthRetrofit = oauthRetrofit; mGfycatRetrofit = gfycatRetrofit; @@ -676,9 +678,10 @@ public class HistoryPostRecyclerViewAdapter extends PagingDataAdapter<Post, Recy if ((post.isGfycat() || post.isRedgifs()) && !post.isLoadGfycatOrStreamableVideoSuccess()) { ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall = - (post.isGfycat() ? mGfycatRetrofit : mRedgifsRetrofit).create(GfycatAPI.class).getGfycatData(post.getGfycatId()); + post.isGfycat() ? mGfycatRetrofit.create(GfycatAPI.class).getGfycatData(post.getGfycatId()) : + mRedgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), post.getGfycatId(), APIUtils.getRedgifsUserAgent(mActivity)); FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(mExecutor, new Handler(), - ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.getGfycatId(), + ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.isGfycat(), mAutomaticallyTryRedgifs, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { @Override @@ -837,9 +840,10 @@ public class HistoryPostRecyclerViewAdapter extends PagingDataAdapter<Post, Recy if ((post.isGfycat() || post.isRedgifs()) && !post.isLoadGfycatOrStreamableVideoSuccess()) { ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall = - (post.isGfycat() ? mGfycatRetrofit : mRedgifsRetrofit).create(GfycatAPI.class).getGfycatData(post.getGfycatId()); + post.isGfycat() ? mGfycatRetrofit.create(GfycatAPI.class).getGfycatData(post.getGfycatId()) : + mRedgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), post.getGfycatId(), APIUtils.getRedgifsUserAgent(mActivity)); FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(mExecutor, new Handler(), - ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.getGfycatId(), + ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.isGfycat(), mAutomaticallyTryRedgifs, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { @Override diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostDetailRecyclerViewAdapter.java b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostDetailRecyclerViewAdapter.java index c4a119ee..4db8166f 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostDetailRecyclerViewAdapter.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostDetailRecyclerViewAdapter.java @@ -54,14 +54,6 @@ import java.util.concurrent.Executor; import butterknife.BindView; import butterknife.ButterKnife; -import im.ene.toro.CacheManager; -import im.ene.toro.ToroPlayer; -import im.ene.toro.ToroUtil; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.exoplayer.ExoPlayerViewHelper; -import im.ene.toro.exoplayer.Playable; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.widget.Container; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.Markwon; import io.noties.markwon.MarkwonConfiguration; @@ -100,6 +92,7 @@ import ml.docilealligator.infinityforreddit.activities.ViewSubredditDetailActivi import ml.docilealligator.infinityforreddit.activities.ViewUserDetailActivity; import ml.docilealligator.infinityforreddit.activities.ViewVideoActivity; import ml.docilealligator.infinityforreddit.apis.GfycatAPI; +import ml.docilealligator.infinityforreddit.apis.RedgifsAPI; import ml.docilealligator.infinityforreddit.apis.StreamableAPI; import ml.docilealligator.infinityforreddit.asynctasks.LoadSubredditIcon; import ml.docilealligator.infinityforreddit.asynctasks.LoadUserData; @@ -118,6 +111,14 @@ import ml.docilealligator.infinityforreddit.post.PostPagingSource; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.CacheManager; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoPlayerViewHelper; +import ml.docilealligator.infinityforreddit.videoautoplay.Playable; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; import pl.droidsonroids.gif.GifImageView; import retrofit2.Call; import retrofit2.Retrofit; @@ -140,6 +141,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler private Retrofit mRedgifsRetrofit; private Retrofit mStreamableRetrofit; private RedditDataRoomDatabase mRedditDataRoomDatabase; + private SharedPreferences mCurrentAccountSharedPreferences; private RequestManager mGlide; private SaveMemoryCenterInisdeDownsampleStrategy mSaveMemoryCenterInsideDownSampleStrategy; private Markwon mPostDetailMarkwon; @@ -221,6 +223,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler boolean separatePostAndComments, String accessToken, String accountName, Post post, Locale locale, SharedPreferences sharedPreferences, + SharedPreferences currentAccountSharedPreferences, SharedPreferences nsfwAndSpoilerSharedPreferences, SharedPreferences postDetailsSharedPreferences, ExoCreator exoCreator, @@ -236,6 +239,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler mRedditDataRoomDatabase = redditDataRoomDatabase; mGlide = glide; mSaveMemoryCenterInsideDownSampleStrategy = new SaveMemoryCenterInisdeDownsampleStrategy(Integer.parseInt(sharedPreferences.getString(SharedPreferencesUtils.POST_FEED_MAX_RESOLUTION, "5000000"))); + mCurrentAccountSharedPreferences = currentAccountSharedPreferences; mSecondaryTextColor = customThemeWrapper.getSecondaryTextColor(); int markdownColor = customThemeWrapper.getPostContentColor(); int postSpoilerBackgroundColor = markdownColor | 0xFF000000; @@ -678,9 +682,10 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler if (mPost.isGfycat() || mPost.isRedgifs() && !mPost.isLoadGfycatOrStreamableVideoSuccess()) { ((PostDetailVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall = - (mPost.isGfycat() ? mGfycatRetrofit : mRedgifsRetrofit).create(GfycatAPI.class).getGfycatData(mPost.getGfycatId()); + mPost.isGfycat() ? mGfycatRetrofit.create(GfycatAPI.class).getGfycatData(mPost.getGfycatId()) : + mRedgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), mPost.getGfycatId(), APIUtils.getRedgifsUserAgent(mActivity)); FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(mExecutor, new Handler(), - ((PostDetailVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, mPost.getGfycatId(), + ((PostDetailVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, mPost.isGfycat(), mAutomaticallyTryRedgifs, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { @Override diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostRecyclerViewAdapter.java b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostRecyclerViewAdapter.java index a50fd29b..4bf33ddf 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostRecyclerViewAdapter.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/PostRecyclerViewAdapter.java @@ -56,14 +56,6 @@ import java.util.concurrent.Executor; import butterknife.BindView; import butterknife.ButterKnife; -import im.ene.toro.CacheManager; -import im.ene.toro.ToroPlayer; -import im.ene.toro.ToroUtil; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.exoplayer.ExoPlayerViewHelper; -import im.ene.toro.exoplayer.Playable; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.widget.Container; import jp.wasabeef.glide.transformations.BlurTransformation; import jp.wasabeef.glide.transformations.RoundedCornersTransformation; import ml.docilealligator.infinityforreddit.FetchGfycatOrRedgifsVideoLinks; @@ -84,6 +76,7 @@ import ml.docilealligator.infinityforreddit.activities.ViewSubredditDetailActivi import ml.docilealligator.infinityforreddit.activities.ViewUserDetailActivity; import ml.docilealligator.infinityforreddit.activities.ViewVideoActivity; import ml.docilealligator.infinityforreddit.apis.GfycatAPI; +import ml.docilealligator.infinityforreddit.apis.RedgifsAPI; import ml.docilealligator.infinityforreddit.apis.StreamableAPI; import ml.docilealligator.infinityforreddit.bottomsheetfragments.ShareLinkBottomSheetFragment; import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper; @@ -95,6 +88,14 @@ import ml.docilealligator.infinityforreddit.post.PostPagingSource; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.CacheManager; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoPlayerViewHelper; +import ml.docilealligator.infinityforreddit.videoautoplay.Playable; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; import pl.droidsonroids.gif.GifImageView; import retrofit2.Call; import retrofit2.Retrofit; @@ -128,6 +129,7 @@ public class PostRecyclerViewAdapter extends PagingDataAdapter<Post, RecyclerVie private BaseActivity mActivity; private PostFragment mFragment; private SharedPreferences mSharedPreferences; + private SharedPreferences mCurrentAccountSharedPreferences; private Executor mExecutor; private Retrofit mOauthRetrofit; private Retrofit mGfycatRetrofit; @@ -220,7 +222,8 @@ public class PostRecyclerViewAdapter extends PagingDataAdapter<Post, RecyclerVie Retrofit gfycatRetrofit, Retrofit redgifsRetrofit, Retrofit streambleRetrofit, CustomThemeWrapper customThemeWrapper, Locale locale, String accessToken, String accountName, int postType, int postLayout, boolean displaySubredditName, - SharedPreferences sharedPreferences, SharedPreferences nsfwAndSpoilerSharedPreferences, + SharedPreferences sharedPreferences, SharedPreferences currentAccountSharedPreferences, + SharedPreferences nsfwAndSpoilerSharedPreferences, SharedPreferences postHistorySharedPreferences, ExoCreator exoCreator, Callback callback) { super(DIFF_CALLBACK); @@ -228,6 +231,7 @@ public class PostRecyclerViewAdapter extends PagingDataAdapter<Post, RecyclerVie mActivity = activity; mFragment = fragment; mSharedPreferences = sharedPreferences; + mCurrentAccountSharedPreferences = currentAccountSharedPreferences; mExecutor = executor; mOauthRetrofit = oauthRetrofit; mGfycatRetrofit = gfycatRetrofit; @@ -705,9 +709,10 @@ public class PostRecyclerViewAdapter extends PagingDataAdapter<Post, RecyclerVie if ((post.isGfycat() || post.isRedgifs()) && !post.isLoadGfycatOrStreamableVideoSuccess()) { ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall = - (post.isGfycat() ? mGfycatRetrofit : mRedgifsRetrofit).create(GfycatAPI.class).getGfycatData(post.getGfycatId()); + post.isGfycat() ? mGfycatRetrofit.create(GfycatAPI.class).getGfycatData(post.getGfycatId()) : + mRedgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), post.getGfycatId(), APIUtils.getRedgifsUserAgent(mActivity)); FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(mExecutor, new Handler(), - ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.getGfycatId(), + ((PostVideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.isGfycat(), mAutomaticallyTryRedgifs, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { @Override @@ -870,9 +875,10 @@ public class PostRecyclerViewAdapter extends PagingDataAdapter<Post, RecyclerVie if ((post.isGfycat() || post.isRedgifs()) && !post.isLoadGfycatOrStreamableVideoSuccess()) { ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall = - (post.isGfycat() ? mGfycatRetrofit : mRedgifsRetrofit).create(GfycatAPI.class).getGfycatData(post.getGfycatId()); + post.isGfycat() ? mGfycatRetrofit.create(GfycatAPI.class).getGfycatData(post.getGfycatId()) : + mRedgifsRetrofit.create(RedgifsAPI.class).getRedgifsData(APIUtils.getRedgifsOAuthHeader(mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.REDGIFS_ACCESS_TOKEN, "")), post.getGfycatId(), APIUtils.getRedgifsUserAgent(mActivity)); FetchGfycatOrRedgifsVideoLinks.fetchGfycatOrRedgifsVideoLinksInRecyclerViewAdapter(mExecutor, new Handler(), - ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.getGfycatId(), + ((PostCard2VideoAutoplayViewHolder) holder).fetchGfycatOrStreamableVideoCall, post.isGfycat(), mAutomaticallyTryRedgifs, new FetchGfycatOrRedgifsVideoLinks.FetchGfycatOrRedgifsVideoLinksListener() { @Override diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/apis/RedgifsAPI.java b/app/src/main/java/ml/docilealligator/infinityforreddit/apis/RedgifsAPI.java new file mode 100644 index 00000000..1a4bf809 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/apis/RedgifsAPI.java @@ -0,0 +1,21 @@ +package ml.docilealligator.infinityforreddit.apis; + +import java.util.Map; + +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.HeaderMap; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Query; + +public interface RedgifsAPI { + @GET("/v2/gifs/{id}") + Call<String> getRedgifsData(@HeaderMap Map<String, String> headers, @Path("id") String id, @Query("user-agent") String userAgent); + + @FormUrlEncoded + @POST("/v2/oauth/client") + Call<String> getRedgifsAccessToken(@FieldMap Map<String, String> params); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/CustomToroContainer.java b/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/CustomToroContainer.java index 8d925ae6..74d367b8 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/CustomToroContainer.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/CustomToroContainer.java @@ -5,7 +5,7 @@ import android.util.AttributeSet; import androidx.annotation.Nullable; -import im.ene.toro.widget.Container; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; public class CustomToroContainer extends Container { private OnWindowFocusChangedListener listener; diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/LoopAvailableExoCreator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/LoopAvailableExoCreator.java index 5ccda4be..01fc9a32 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/LoopAvailableExoCreator.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/customviews/LoopAvailableExoCreator.java @@ -7,10 +7,10 @@ import androidx.annotation.NonNull; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; -import im.ene.toro.exoplayer.Config; -import im.ene.toro.exoplayer.DefaultExoCreator; -import im.ene.toro.exoplayer.ToroExo; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; +import ml.docilealligator.infinityforreddit.videoautoplay.Config; +import ml.docilealligator.infinityforreddit.videoautoplay.DefaultExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroExo; public class LoopAvailableExoCreator extends DefaultExoCreator { private final SharedPreferences sharedPreferences; diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/HistoryPostFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/HistoryPostFragment.java index 76f3330c..06ef97e9 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/HistoryPostFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/HistoryPostFragment.java @@ -1,7 +1,7 @@ package ml.docilealligator.infinityforreddit.fragments; -import static im.ene.toro.media.PlaybackInfo.INDEX_UNSET; -import static im.ene.toro.media.PlaybackInfo.TIME_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.INDEX_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.TIME_UNSET; import android.content.Context; import android.content.SharedPreferences; @@ -63,9 +63,6 @@ import javax.inject.Named; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.Unbinder; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.media.VolumeInfo; import ml.docilealligator.infinityforreddit.FetchPostFilterReadPostsAndConcatenatedSubredditNames; import ml.docilealligator.infinityforreddit.FragmentCommunicator; import ml.docilealligator.infinityforreddit.Infinity; @@ -129,6 +126,9 @@ import ml.docilealligator.infinityforreddit.postfilter.PostFilter; import ml.docilealligator.infinityforreddit.postfilter.PostFilterUsage; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; import retrofit2.Retrofit; public class HistoryPostFragment extends Fragment implements FragmentCommunicator { @@ -177,6 +177,9 @@ public class HistoryPostFragment extends Fragment implements FragmentCommunicato @Named("default") SharedPreferences mSharedPreferences; @Inject + @Named("current_account") + SharedPreferences mCurrentAccountSharedPreferences; + @Inject @Named("post_layout") SharedPreferences mPostLayoutSharedPreferences; @Inject @@ -378,7 +381,7 @@ public class HistoryPostFragment extends Fragment implements FragmentCommunicato mAdapter = new HistoryPostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mExoCreator, new HistoryPostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/PostFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/PostFragment.java index 5551d31e..2cd04385 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/PostFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/PostFragment.java @@ -1,8 +1,7 @@ package ml.docilealligator.infinityforreddit.fragments; - -import static im.ene.toro.media.PlaybackInfo.INDEX_UNSET; -import static im.ene.toro.media.PlaybackInfo.TIME_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.INDEX_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.TIME_UNSET; import android.content.Context; import android.content.Intent; @@ -67,9 +66,6 @@ import javax.inject.Named; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.Unbinder; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.media.VolumeInfo; import ml.docilealligator.infinityforreddit.ActivityToolbarInterface; import ml.docilealligator.infinityforreddit.FetchPostFilterReadPostsAndConcatenatedSubredditNames; import ml.docilealligator.infinityforreddit.FragmentCommunicator; @@ -137,6 +133,9 @@ import ml.docilealligator.infinityforreddit.postfilter.PostFilter; import ml.docilealligator.infinityforreddit.postfilter.PostFilterUsage; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; import retrofit2.Retrofit; @@ -195,6 +194,9 @@ public class PostFragment extends Fragment implements FragmentCommunicator { @Named("default") SharedPreferences mSharedPreferences; @Inject + @Named("current_account") + SharedPreferences mCurrentAccountSharedPreferences; + @Inject @Named("sort_type") SharedPreferences mSortTypeSharedPreferences; @Inject @@ -458,7 +460,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -535,7 +537,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, displaySubredditName, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -606,7 +608,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -671,7 +673,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -732,7 +734,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -792,7 +794,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { @@ -849,7 +851,7 @@ public class PostFragment extends Fragment implements FragmentCommunicator { mAdapter = new PostRecyclerViewAdapter(activity, this, mExecutor, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mCustomThemeWrapper, locale, accessToken, accountName, postType, postLayout, true, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostHistorySharedPreferences, mExoCreator, new PostRecyclerViewAdapter.Callback() { @Override public void typeChipClicked(int filter) { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java index 5821b2fd..d1cc9976 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java @@ -1,9 +1,9 @@ package ml.docilealligator.infinityforreddit.fragments; -import static im.ene.toro.media.PlaybackInfo.INDEX_UNSET; -import static im.ene.toro.media.PlaybackInfo.TIME_UNSET; import static ml.docilealligator.infinityforreddit.activities.CommentActivity.RETURN_EXTRA_COMMENT_DATA_KEY; import static ml.docilealligator.infinityforreddit.activities.CommentActivity.WRITE_COMMENT_REQUEST_CODE; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.INDEX_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.TIME_UNSET; import android.annotation.SuppressLint; import android.app.Activity; @@ -63,9 +63,6 @@ import javax.inject.Named; import butterknife.BindView; import butterknife.ButterKnife; -import im.ene.toro.exoplayer.ExoCreator; -import im.ene.toro.media.PlaybackInfo; -import im.ene.toro.media.VolumeInfo; import ml.docilealligator.infinityforreddit.DeleteThing; import ml.docilealligator.infinityforreddit.Flair; import ml.docilealligator.infinityforreddit.FragmentCommunicator; @@ -113,6 +110,9 @@ import ml.docilealligator.infinityforreddit.subreddit.SubredditData; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.Utils; +import ml.docilealligator.infinityforreddit.videoautoplay.ExoCreator; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -556,7 +556,7 @@ public class ViewPostDetailFragment extends Fragment implements FragmentCommunic this, mExecutor, mCustomThemeWrapper, mRetrofit, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mRedditDataRoomDatabase, mGlide, mSeparatePostAndComments, mAccessToken, mAccountName, mPost, mLocale, - mSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostDetailsSharedPreferences, + mSharedPreferences, mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, mPostDetailsSharedPreferences, mExoCreator, post -> EventBus.getDefault().post(new PostUpdateEventToPostList(mPost, postListPosition))); mCommentsAdapter = new CommentsRecyclerViewAdapter(activity, this, mCustomThemeWrapper, mExecutor, mRetrofit, mOauthRetrofit, @@ -1239,8 +1239,8 @@ public class ViewPostDetailFragment extends Fragment implements FragmentCommunic mRetrofit, mOauthRetrofit, mGfycatRetrofit, mRedgifsRetrofit, mStreamableRetrofit, mRedditDataRoomDatabase, mGlide, mSeparatePostAndComments, mAccessToken, mAccountName, mPost, mLocale, mSharedPreferences, - mNsfwAndSpoilerSharedPreferences, mPostDetailsSharedPreferences, - mExoCreator, + mCurrentAccountSharedPreferences, mNsfwAndSpoilerSharedPreferences, + mPostDetailsSharedPreferences, mExoCreator, post1 -> EventBus.getDefault().post(new PostUpdateEventToPostList(mPost, postListPosition))); mCommentsAdapter = new CommentsRecyclerViewAdapter(activity, diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/APIUtils.java b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/APIUtils.java index 927f564a..248c917b 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/APIUtils.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/APIUtils.java @@ -1,5 +1,6 @@ package ml.docilealligator.infinityforreddit.utils; +import android.content.Context; import android.util.Base64; import java.util.HashMap; @@ -19,7 +20,7 @@ public class APIUtils { public static final String API_UPLOAD_MEDIA_URI = "https://reddit-uploaded-media.s3-accelerate.amazonaws.com"; public static final String API_UPLOAD_VIDEO_URI = "https://reddit-uploaded-video.s3-accelerate.amazonaws.com"; public static final String GFYCAT_API_BASE_URI = "https://api.gfycat.com/v1/gfycats/"; - public static final String REDGIFS_API_BASE_URI = "https://api.redgifs.com/v2/gifs/"; + public static final String REDGIFS_API_BASE_URI = "https://api.redgifs.com"; public static final String IMGUR_API_BASE_URI = "https://api.imgur.com/3/"; public static final String PUSHSHIFT_API_BASE_URI = "https://api.pushshift.io/"; public static final String REVEDDIT_API_BASE_URI = "https://api.reveddit.com/"; @@ -27,8 +28,11 @@ public class APIUtils { public static final String STREAMABLE_API_BASE_URI = "https://api.streamable.com"; public static final String CLIENT_ID_KEY = "client_id"; + public static final String CLIENT_SECRET_KEY = "client_secret"; public static final String CLIENT_ID = "NOe2iKrPPzwscA"; public static final String IMGUR_CLIENT_ID = "Client-ID cc671794e0ab397"; + public static final String REDGIFS_CLIENT_ID = "1828d0bcc93-15ac-bde6-0005-d2ecbe8daab3"; + public static final String REDGIFS_CLIENT_SECRET = "TJBlw7jRXW65NAGgFBtgZHu97WlzRXHYybK81sZ9dLM="; public static final String RESPONSE_TYPE_KEY = "response_type"; public static final String RESPONSE_TYPE = "code"; public static final String STATE_KEY = "state"; @@ -48,6 +52,7 @@ public class APIUtils { public static final String GRANT_TYPE_KEY = "grant_type"; public static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + public static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; public static final String REFRESH_TOKEN_KEY = "refresh_token"; public static final String DIR_KEY = "dir"; @@ -111,6 +116,11 @@ public class APIUtils { public static final String REFERER_KEY = "Referer"; public static final String REVEDDIT_REFERER = "https://www.reveddit.com/"; + /*public static final String HOST_KEY = "Host"; + public static final String REDGIFS_HOST = "api.redgifs.com"; + public static final String CONTENT_TYPE_KEY = "Content-Type"; + public static final String */ + public static Map<String, String> getHttpBasicAuthHeader() { Map<String, String> params = new HashMap<>(); String credentials = String.format("%s:%s", APIUtils.CLIENT_ID, ""); @@ -126,6 +136,12 @@ public class APIUtils { return params; } + public static Map<String, String> getRedgifsOAuthHeader(String redgifsAccessToken) { + Map<String, String> params = new HashMap<>(); + params.put(APIUtils.AUTHORIZATION_KEY, APIUtils.AUTHORIZATION_BASE + redgifsAccessToken); + return params; + } + public static RequestBody getRequestBody(String s) { return RequestBody.create(s, MediaType.parse("text/plain")); } @@ -137,4 +153,23 @@ public class APIUtils { params.put(APIUtils.USER_AGENT_KEY, APIUtils.USER_AGENT); return params; } + + public static String getRedgifsUserAgent(Context context) { + return APIUtils.USER_AGENT; + /*String versionName; + try { + String packageName = context.getPackageName(); + PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + versionName = info.versionName; + } catch (Exception e) { + versionName = "?"; + } + return "Toro ExoPlayer Extension, v3.7.0.2010003" + + "/" + + versionName + + " (Linux;Android " + + Build.VERSION.RELEASE + + ") " + + ExoPlayerLibraryInfo.VERSION_SLASHY;*/ + } } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/SharedPreferencesUtils.java b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/SharedPreferencesUtils.java index 3b431a42..29f42e4b 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/SharedPreferencesUtils.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/SharedPreferencesUtils.java @@ -337,6 +337,7 @@ public class SharedPreferencesUtils { public static final String ACCOUNT_NAME = "account_name"; public static final String ACCESS_TOKEN = "access_token"; public static final String ACCOUNT_IMAGE_URL = "account_image_url"; + public static final String REDGIFS_ACCESS_TOKEN = "redgifs_access_token"; public static final String NAVIGATION_DRAWER_SHARED_PREFERENCES_FILE = "ml.docilealligator.infinityforreddit.navigation_drawer"; public static final String COLLAPSE_ACCOUNT_SECTION = "collapse_account_section"; diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsExoPlayerViewHelper.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsExoPlayerViewHelper.java new file mode 100644 index 00000000..22a63afc --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsExoPlayerViewHelper.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.content.Context; +import android.net.Uri; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Beta; + +/** + * A {@link ToroPlayerHelper} to integrate ExoPlayer IMA Extension. Work together with {@link + * AdsPlayable}. + * + * @author eneim (2018/08/22). + * @since 3.6.0.2802 + */ +@SuppressWarnings("unused") @Beta // +public class AdsExoPlayerViewHelper extends ExoPlayerViewHelper { + + static class DefaultAdViewProvider implements AdsLoader.AdViewProvider { + + @NonNull final ViewGroup viewGroup; + + DefaultAdViewProvider(@NonNull ViewGroup viewGroup) { + this.viewGroup = viewGroup; + } + + @Override public ViewGroup getAdViewGroup() { + return this.viewGroup; + } + + @Override public View[] getAdOverlayViews() { + return new View[0]; + } + } + + private static AdsPlayable createPlayable( /// + ToroPlayer player, // + ExoCreator creator, // + Uri contentUri, // + String fileExt, // + AdsLoader adsLoader, // + AdsLoader.AdViewProvider adViewProvider // + ) { + return new AdsPlayable(creator, contentUri, fileExt, player, adsLoader, adViewProvider); + } + + private static AdsPlayable createPlayable( // + ToroPlayer player, // + Config config, // + Uri contentUri, // + String fileExt, // + AdsLoader adsLoader, // + AdsLoader.AdViewProvider adViewProvider // + ) { + Context context = player.getPlayerView().getContext(); + return createPlayable(player, ToroExo.with(context).getCreator(config), contentUri, fileExt, + adsLoader, adViewProvider); + } + + private static AdsPlayable createPlayable( // + ToroPlayer player, // + Uri contentUri, // + String fileExt, // + AdsLoader adsLoader, // + AdsLoader.AdViewProvider adViewProvider // + ) { + Context context = player.getPlayerView().getContext(); + return createPlayable(player, ToroExo.with(context).getDefaultCreator(), contentUri, fileExt, + adsLoader, adViewProvider); + } + + // Neither ExoCreator nor Config are provided. + + /** + * Create new {@link AdsExoPlayerViewHelper} for a {@link ToroPlayer} and {@link AdsLoader}. + * + * @param adContainer if {@code null} then overlay of {@link PlayerView} will be used. + */ + @Deprecated + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer // + ) { + super(player, + createPlayable(player, uri, fileExt, adsLoader, + adContainer != null ? new DefaultAdViewProvider(adContainer) : null)); + } + + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer, // will be ignored + @Nullable AdsLoader.AdViewProvider adViewProvider // + ) { + super(player, createPlayable(player, uri, fileExt, adsLoader, adViewProvider)); + } + + // ExoCreator is provided. + + /** + * Create new {@link AdsExoPlayerViewHelper} for a {@link ToroPlayer} and {@link AdsLoader}. + * + * @param adContainer if {@code null} then overlay of {@link PlayerView} will be used. + */ + @Deprecated + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer, // + @NonNull ExoCreator creator // + ) { + super(player, createPlayable(player, creator, uri, fileExt, adsLoader, + adContainer != null ? new DefaultAdViewProvider(adContainer) : null)); + } + + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer, // will be ignored + @Nullable AdsLoader.AdViewProvider adViewProvider, // + @NonNull ExoCreator creator // + ) { + super(player, createPlayable(player, creator, uri, fileExt, adsLoader, adViewProvider)); + } + // Config is provided. + + /** + * Create new {@link AdsExoPlayerViewHelper} for a {@link ToroPlayer} and {@link AdsLoader}. + * + * @param adContainer if {@code null} then overlay of {@link PlayerView} will be used. + */ + @Deprecated + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer, // + @NonNull Config config // + ) { + super(player, createPlayable(player, config, uri, fileExt, adsLoader, + adContainer != null ? new DefaultAdViewProvider(adContainer) : null)); + } + + public AdsExoPlayerViewHelper( // + @NonNull ToroPlayer player, // + @NonNull Uri uri, // + @Nullable String fileExt, // + @NonNull AdsLoader adsLoader, // + @Nullable ViewGroup adContainer, // will be ignored + @Nullable AdsLoader.AdViewProvider adViewProvider, // + @NonNull Config config // + ) { + super(player, createPlayable(player, config, uri, fileExt, adsLoader, adViewProvider)); + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsPlayable.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsPlayable.java new file mode 100644 index 00000000..666b3815 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/AdsPlayable.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.net.Uri; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Beta; + +/** + * A {@link Playable} that is able to integrate with {@link AdsLoader}. + * + * @author eneim (2018/08/22). + * @since 3.6.0.2802 + */ +@Beta // +public class AdsPlayable extends ExoPlayable { + + static class FactoryImpl implements AdsMediaSource.MediaSourceFactory { + + @NonNull final ExoCreator creator; + @NonNull final ToroPlayer player; + + FactoryImpl(@NonNull ExoCreator creator, @NonNull ToroPlayer player) { + this.creator = creator; + this.player = player; + } + + @Override public MediaSource createMediaSource(Uri uri) { + return this.creator.createMediaSource(uri, null); + } + + @Override public int[] getSupportedTypes() { + // IMA does not support Smooth Streaming ads. + return new int[] { C.TYPE_DASH, C.TYPE_HLS, C.TYPE_OTHER }; + } + } + + @NonNull private final AdsLoader adsLoader; + @NonNull private final FactoryImpl factory; + @Nullable private final AdsLoader.AdViewProvider adViewProvider; + + /** + * @deprecated Use the constructors that use {@link AdsLoader.AdViewProvider} instead. + */ + @Deprecated + public AdsPlayable(ExoCreator creator, Uri uri, String fileExt, ToroPlayer player, + @NonNull AdsLoader adsLoader, @Nullable final ViewGroup adsContainer) { + super(creator, uri, fileExt); + this.adsLoader = adsLoader; + this.adViewProvider = adsContainer == null ? null + : new AdsExoPlayerViewHelper.DefaultAdViewProvider(adsContainer); + this.factory = new FactoryImpl(this.creator, player); + } + + @SuppressWarnings("WeakerAccess") + public AdsPlayable(ExoCreator creator, Uri uri, String fileExt, ToroPlayer player, + @NonNull AdsLoader adsLoader, @Nullable AdsLoader.AdViewProvider adViewProvider) { + super(creator, uri, fileExt); + this.adsLoader = adsLoader; + this.adViewProvider = adViewProvider; + this.factory = new FactoryImpl(this.creator, player); + } + + @CallSuper + @Override public void prepare(boolean prepareSource) { + this.mediaSource = createAdsMediaSource(creator, mediaUri, fileExt, // + factory.player, adsLoader, adViewProvider, factory); + super.prepare(prepareSource); + } + + @Override protected void beforePrepareMediaSource() { + super.beforePrepareMediaSource(); + adsLoader.setPlayer(player); + } + + @Override public void release() { + adsLoader.setPlayer(null); + super.release(); + } + + private static MediaSource createAdsMediaSource(ExoCreator creator, Uri uri, String fileExt, + ToroPlayer player, AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider, + AdsMediaSource.MediaSourceFactory factory) { + MediaSource original = creator.createMediaSource(uri, fileExt); + View playerView = player.getPlayerView(); + if (!(playerView instanceof PlayerView)) { + throw new IllegalArgumentException("Require PlayerView"); + } + + return new AdsMediaSource(original, factory, adsLoader, + adViewProvider == null ? (PlayerView) playerView : adViewProvider); + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/BaseMeter.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/BaseMeter.java new file mode 100644 index 00000000..539919b0 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/BaseMeter.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.TransferListener; + +/** + * Abstract the {@link DefaultBandwidthMeter}, provide a wider use. + * + * @author eneim (2018/01/26). + * @since 3.4.0 + */ + +@SuppressWarnings("WeakerAccess") // +public final class BaseMeter<T extends BandwidthMeter> implements BandwidthMeter, TransferListener { + + @NonNull protected final T bandwidthMeter; + @NonNull protected final TransferListener transferListener; + + /** + * @deprecated use {@link #BaseMeter(BandwidthMeter)} instead. + */ + @SuppressWarnings({ "unused" }) // + @Deprecated // + public BaseMeter(@NonNull T bandwidthMeter, @NonNull TransferListener transferListener) { + this(bandwidthMeter); + } + + public BaseMeter(@NonNull T bandwidthMeter) { + this.bandwidthMeter = ToroUtil.checkNotNull(bandwidthMeter); + this.transferListener = ToroUtil.checkNotNull(this.bandwidthMeter.getTransferListener()); + } + + @Override public long getBitrateEstimate() { + return bandwidthMeter.getBitrateEstimate(); + } + + @Override @Nullable public TransferListener getTransferListener() { + return bandwidthMeter.getTransferListener(); + } + + @Override public void addEventListener(Handler eventHandler, EventListener eventListener) { + bandwidthMeter.addEventListener(eventHandler, eventListener); + } + + @Override public void removeEventListener(EventListener eventListener) { + bandwidthMeter.removeEventListener(eventListener); + } + + @Override + public void onTransferInitializing(DataSource source, DataSpec dataSpec, boolean isNetwork) { + transferListener.onTransferInitializing(source, dataSpec, isNetwork); + } + + @Override public void onTransferStart(DataSource source, DataSpec dataSpec, boolean isNetwork) { + transferListener.onTransferStart(source, dataSpec, isNetwork); + } + + @Override public void onBytesTransferred(DataSource source, DataSpec dataSpec, boolean isNetwork, + int bytesTransferred) { + transferListener.onBytesTransferred(source, dataSpec, isNetwork, bytesTransferred); + } + + @Override public void onTransferEnd(DataSource source, DataSpec dataSpec, boolean isNetwork) { + transferListener.onTransferEnd(source, dataSpec, isNetwork); + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/CacheManager.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/CacheManager.java new file mode 100644 index 00000000..04342641 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/CacheManager.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.LinkedHashMap; + +/** + * {@link CacheManager} is a helper interface used by {@link Container} to manage the + * {@link PlaybackInfo} of {@link ToroPlayer}s. For each {@link ToroPlayer}, + * {@link CacheManager} will ask for a unique key for its {@link PlaybackInfo} cache. + * {@link Container} uses a {@link LinkedHashMap} to implement the caching mechanism, so + * {@link CacheManager} must provide keys which are uniquely distinguished by + * {@link Object#equals(Object)}. + * + * @author eneim (7/5/17). + */ +public interface CacheManager { + + /** + * Get the unique key for the {@link ToroPlayer} of a specific order. Note that this key must + * also be managed by {@link RecyclerView.Adapter} so that it prevents the uniqueness at data + * change events. + * + * @param order order of the {@link ToroPlayer}. + * @return the unique key of the {@link ToroPlayer}. + */ + @Nullable Object getKeyForOrder(int order); + + /** + * Get the order of a specific key value. Returning a {@code null} order value here will tell + * {@link Container} to ignore this key's cache order. + * + * @param key the key value to lookup. + * @return the order of the {@link ToroPlayer} whose unique key value is key. + */ + @Nullable Integer getOrderForKey(@NonNull Object key); + + /** + * A built-in {@link CacheManager} that use the order as the unique key. Note that this is not + * data-changes-proof. Which means that after data change events, the map may need to be + * updated. + */ + CacheManager DEFAULT = new CacheManager() { + @Override public Object getKeyForOrder(int order) { + return order; + } + + @Override public Integer getOrderForKey(@NonNull Object key) { + return key instanceof Integer ? (Integer) key : null; + } + }; +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Config.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Config.java new file mode 100644 index 00000000..7fe095d4 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Config.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static com.google.android.exoplayer2.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; + +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.DefaultRenderersFactory.ExtensionRendererMode; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.cache.Cache; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Beta; + +/** + * Necessary configuration for {@link ExoCreator} to produces {@link SimpleExoPlayer} and + * {@link MediaSource}. Instance of this class must be construct using {@link Builder}. + * + * @author eneim (2018/01/23). + * @since 3.4.0 + */ + +@SuppressWarnings("SimplifiableIfStatement") // +public final class Config { + + @Nullable + private final Context context; + + // primitive flags + @ExtensionRendererMode final int extensionMode; + + // NonNull options + @NonNull final BaseMeter meter; + @NonNull final LoadControl loadControl; + @NonNull final MediaSourceBuilder mediaSourceBuilder; + + // Nullable options + @Nullable final DrmSessionManager<FrameworkMediaCrypto> drmSessionManager; + @Nullable final Cache cache; // null by default + // If null, ExoCreator must come up with a default one. + // This is to help customizing the Data source, for example using OkHttp extension. + @Nullable final DataSource.Factory dataSourceFactory; + + @SuppressWarnings("WeakerAccess") // + Config(@Nullable Context context, int extensionMode, @NonNull BaseMeter meter, + @NonNull LoadControl loadControl, + @Nullable DataSource.Factory dataSourceFactory, + @NonNull MediaSourceBuilder mediaSourceBuilder, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, @Nullable Cache cache) { + this.context = context != null ? context.getApplicationContext() : null; + this.extensionMode = extensionMode; + this.meter = meter; + this.loadControl = loadControl; + this.dataSourceFactory = dataSourceFactory; + this.mediaSourceBuilder = mediaSourceBuilder; + this.drmSessionManager = drmSessionManager; + this.cache = cache; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Config config = (Config) o; + + if (extensionMode != config.extensionMode) return false; + if (!meter.equals(config.meter)) return false; + if (!loadControl.equals(config.loadControl)) return false; + if (!mediaSourceBuilder.equals(config.mediaSourceBuilder)) return false; + if (!ObjectsCompat.equals(drmSessionManager, config.drmSessionManager)) return false; + if (!ObjectsCompat.equals(cache, config.cache)) return false; + return ObjectsCompat.equals(dataSourceFactory, config.dataSourceFactory); + } + + @Override public int hashCode() { + int result = extensionMode; + result = 31 * result + meter.hashCode(); + result = 31 * result + loadControl.hashCode(); + result = 31 * result + mediaSourceBuilder.hashCode(); + result = 31 * result + (drmSessionManager != null ? drmSessionManager.hashCode() : 0); + result = 31 * result + (cache != null ? cache.hashCode() : 0); + result = 31 * result + (dataSourceFactory != null ? dataSourceFactory.hashCode() : 0); + return result; + } + + @SuppressWarnings("unused") public Builder newBuilder() { + return new Builder(context).setCache(this.cache) + .setDrmSessionManager(this.drmSessionManager) + .setExtensionMode(this.extensionMode) + .setLoadControl(this.loadControl) + .setMediaSourceBuilder(this.mediaSourceBuilder) + .setMeter(this.meter); + } + + /// Builder + @SuppressWarnings({ "unused", "WeakerAccess" }) // + public static final class Builder { + + @Nullable // only for backward compatibility + final Context context; + + /** + * @deprecated Use the constructor with nonnull {@link Context} instead. + */ + @Deprecated + public Builder() { + this(null); + } + + public Builder(@Nullable Context context) { + this.context = context != null ? context.getApplicationContext() : null; + DefaultBandwidthMeter bandwidthMeter = + new DefaultBandwidthMeter.Builder(this.context).build(); + meter = new BaseMeter<>(bandwidthMeter); + } + + @ExtensionRendererMode private int extensionMode = EXTENSION_RENDERER_MODE_OFF; + private BaseMeter meter; + private LoadControl loadControl = new DefaultLoadControl(); + private DataSource.Factory dataSourceFactory = null; + private MediaSourceBuilder mediaSourceBuilder = MediaSourceBuilder.DEFAULT; + private DrmSessionManager<FrameworkMediaCrypto> drmSessionManager = null; + private Cache cache = null; + + public Builder setExtensionMode(@ExtensionRendererMode int extensionMode) { + this.extensionMode = extensionMode; + return this; + } + + public Builder setMeter(@NonNull BaseMeter meter) { + this.meter = checkNotNull(meter, "Need non-null BaseMeter"); + return this; + } + + public Builder setLoadControl(@NonNull LoadControl loadControl) { + this.loadControl = checkNotNull(loadControl, "Need non-null LoadControl"); + return this; + } + + // Option is Nullable, but if user customize this, it must be a Nonnull one. + public Builder setDataSourceFactory(@NonNull DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = checkNotNull(dataSourceFactory); + return this; + } + + public Builder setMediaSourceBuilder(@NonNull MediaSourceBuilder mediaSourceBuilder) { + this.mediaSourceBuilder = + checkNotNull(mediaSourceBuilder, "Need non-null MediaSourceBuilder"); + return this; + } + + @Beta // + public Builder setDrmSessionManager( + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + this.drmSessionManager = drmSessionManager; + return this; + } + + public Builder setCache(@Nullable Cache cache) { + this.cache = cache; + return this; + } + + public Config build() { + return new Config(context, extensionMode, meter, loadControl, dataSourceFactory, + mediaSourceBuilder, drmSessionManager, cache); + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/DefaultExoCreator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/DefaultExoCreator.java new file mode 100644 index 00000000..af3bbd84 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/DefaultExoCreator.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroExo.with; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.util.Util; + +import java.io.IOException; + +/** + * Usage: use this as-it or inheritance. + * + * @author eneim (2018/02/04). + * @since 3.4.0 + */ + +@SuppressWarnings({ "unused", "WeakerAccess" }) // +public class DefaultExoCreator implements ExoCreator, MediaSourceEventListener { + + final ToroExo toro; // per application + final Config config; + private final TrackSelector trackSelector; // 'maybe' stateless + private final LoadControl loadControl; // stateless + private final MediaSourceBuilder mediaSourceBuilder; // stateless + private final RenderersFactory renderersFactory; // stateless + private final DataSource.Factory mediaDataSourceFactory; // stateless + private final DataSource.Factory manifestDataSourceFactory; // stateless + + public DefaultExoCreator(@NonNull ToroExo toro, @NonNull Config config) { + this.toro = checkNotNull(toro); + this.config = checkNotNull(config); + trackSelector = new DefaultTrackSelector(); + loadControl = config.loadControl; + mediaSourceBuilder = config.mediaSourceBuilder; + + DefaultRenderersFactory tempFactory = new DefaultRenderersFactory(this.toro.context); + tempFactory.setExtensionRendererMode(config.extensionMode); + renderersFactory = tempFactory; + + DataSource.Factory baseFactory = config.dataSourceFactory; + if (baseFactory == null) { + baseFactory = new DefaultHttpDataSourceFactory(toro.appName, config.meter); + } + DataSource.Factory factory = new DefaultDataSourceFactory(this.toro.context, // + config.meter, baseFactory); + if (config.cache != null) factory = new CacheDataSourceFactory(config.cache, factory); + mediaDataSourceFactory = factory; + manifestDataSourceFactory = new DefaultDataSourceFactory(this.toro.context, this.toro.appName); + } + + public DefaultExoCreator(Context context, Config config) { + this(with(context), config); + } + + @SuppressWarnings("SimplifiableIfStatement") + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DefaultExoCreator that = (DefaultExoCreator) o; + + if (!toro.equals(that.toro)) return false; + if (!trackSelector.equals(that.trackSelector)) return false; + if (!loadControl.equals(that.loadControl)) return false; + if (!mediaSourceBuilder.equals(that.mediaSourceBuilder)) return false; + if (!renderersFactory.equals(that.renderersFactory)) return false; + if (!mediaDataSourceFactory.equals(that.mediaDataSourceFactory)) return false; + return manifestDataSourceFactory.equals(that.manifestDataSourceFactory); + } + + @Override public int hashCode() { + int result = toro.hashCode(); + result = 31 * result + trackSelector.hashCode(); + result = 31 * result + loadControl.hashCode(); + result = 31 * result + mediaSourceBuilder.hashCode(); + result = 31 * result + renderersFactory.hashCode(); + result = 31 * result + mediaDataSourceFactory.hashCode(); + result = 31 * result + manifestDataSourceFactory.hashCode(); + return result; + } + + final TrackSelector getTrackSelector() { + return trackSelector; + } + + @Nullable @Override public Context getContext() { + return toro.context; + } + + @NonNull @Override public SimpleExoPlayer createPlayer() { + return new ToroExoPlayer(toro.context, renderersFactory, trackSelector, loadControl, + new DefaultBandwidthMeter.Builder(toro.context).build(), config.drmSessionManager, + Util.getLooper()); + } + + @NonNull @Override public MediaSource createMediaSource(@NonNull Uri uri, String fileExt) { + return mediaSourceBuilder.buildMediaSource(this.toro.context, uri, fileExt, new Handler(), + manifestDataSourceFactory, mediaDataSourceFactory, this); + } + + @NonNull @Override // + public Playable createPlayable(@NonNull Uri uri, String fileExt) { + return new PlayableImpl(this, uri, fileExt); + } + + /// MediaSourceEventListener + + @Override + public void onLoadStarted(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // no-ops + } + + @Override + public void onLoadCompleted(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // no-ops + } + + @Override + public void onLoadCanceled(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // no-ops + } + + @Override + public void onLoadError(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, + boolean wasCanceled) { + // no-ops + } + + @Override public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + // no-ops + } + + @Override + public void onUpstreamDiscarded(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, + MediaLoadData mediaLoadData) { + // no-ops + } + + @Override public void onDownstreamFormatChanged(int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + // no-ops + } + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + // no-ops + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + // no-ops + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoCreator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoCreator.java new file mode 100644 index 00000000..c4319493 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoCreator.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.MediaSource; + +/** + * A simple interface whose implementation helps Client to easily create {@link SimpleExoPlayer} + * instance, {@link MediaSource} instance or specifically a {@link Playable} instance. + * + * Most of the time, Client just needs to request for a {@link Playable} for a specific Uri. + * + * @author eneim (2018/02/04). + * @since 3.4.0 + */ + +public interface ExoCreator { + + String TAG = "ToroExo:Creator"; + + /** + * Return current Application context used in {@link ToroExo}. An {@link ExoCreator} must be used + * within Application scope. + */ + @Nullable Context getContext(); + + /** + * Create a new {@link SimpleExoPlayer} instance. This method should always create new instance of + * {@link SimpleExoPlayer}, but client should use {@link ExoCreator} indirectly via + * {@link ToroExo}. + * + * @return a new {@link SimpleExoPlayer} instance. + */ + @NonNull SimpleExoPlayer createPlayer(); + + /** + * Create a {@link MediaSource} from media {@link Uri}. + * + * @param uri the media {@link Uri}. + * @param fileExt the optional (File) extension of the media Uri. + * @return a {@link MediaSource} for media {@link Uri}. + */ + @NonNull MediaSource createMediaSource(@NonNull Uri uri, @Nullable String fileExt); + + // Client just needs the method below to work with Toro, but I prepare both 2 above for custom use-cases. + + /** + * Create a {@link Playable} for a media {@link Uri}. Client should always use this method for + * quick and simple setup. Only use {@link #createMediaSource(Uri, String)} and/or + * {@link #createPlayer()} when necessary. + * + * @param uri the media {@link Uri}. + * @param fileExt the optional (File) extension of the media Uri. + * @return the {@link Playable} to manage the media {@link Uri}. + */ + @NonNull + Playable createPlayable(@NonNull Uri uri, @Nullable String fileExt); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayable.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayable.java new file mode 100644 index 00000000..a159ea17 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayable.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroExo.toro; + +import android.net.Uri; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import com.google.android.exoplayer2.source.BehindLiveWindowException; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.R; + +/** + * Making {@link Playable} extensible. This can be used with custom {@link ExoCreator}. Extending + * this class must make sure the re-usability of the implementation. + * + * @author eneim (2018/02/26). + * @since 3.4.0 + */ + +@SuppressWarnings("WeakerAccess") +public class ExoPlayable extends PlayableImpl { + + @SuppressWarnings("unused") private static final String TAG = "ToroExo:Playable"; + + private EventListener listener; + + // Adapt from ExoPlayer demo. + protected boolean inErrorState = false; + protected TrackGroupArray lastSeenTrackGroupArray; + + /** + * Construct an instance of {@link ExoPlayable} from an {@link ExoCreator} and {@link Uri}. The + * {@link ExoCreator} is used to request {@link SimpleExoPlayer} instance, while {@link Uri} + * defines the media to play. + * + * @param creator the {@link ExoCreator} instance. + * @param uri the {@link Uri} of the media. + * @param fileExt the custom extension of the media Uri. + */ + public ExoPlayable(ExoCreator creator, Uri uri, String fileExt) { + super(creator, uri, fileExt); + } + + @Override public void prepare(boolean prepareSource) { + if (listener == null) { + listener = new Listener(); + super.addEventListener(listener); + } + super.prepare(prepareSource); + this.lastSeenTrackGroupArray = null; + this.inErrorState = false; + } + + @Override public void setPlayerView(@Nullable PlayerView playerView) { + // This will also clear these flags + if (playerView != this.playerView) { + this.lastSeenTrackGroupArray = null; + this.inErrorState = false; + } + super.setPlayerView(playerView); + } + + @Override public void reset() { + super.reset(); + this.lastSeenTrackGroupArray = null; + this.inErrorState = false; + } + + @Override public void release() { + if (listener != null) { + super.removeEventListener(listener); + listener = null; + } + super.release(); + this.lastSeenTrackGroupArray = null; + this.inErrorState = false; + } + + @SuppressWarnings({ "unused" }) // + protected void onErrorMessage(@NonNull String message) { + // Sub class can have custom reaction about the error here, including not to show this toast + // (by not calling super.onErrorMessage(message)). + if (this.errorListeners.size() > 0) { + this.errorListeners.onError(new RuntimeException(message)); + } else if (playerView != null) { + Toast.makeText(playerView.getContext(), message, Toast.LENGTH_SHORT).show(); + } + } + + class Listener extends DefaultEventListener { + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + super.onTracksChanged(trackGroups, trackSelections); + if (trackGroups == lastSeenTrackGroupArray) return; + lastSeenTrackGroupArray = trackGroups; + if (!(creator instanceof DefaultExoCreator)) return; + TrackSelector selector = ((DefaultExoCreator) creator).getTrackSelector(); + if (selector instanceof DefaultTrackSelector) { + MappedTrackInfo trackInfo = ((DefaultTrackSelector) selector).getCurrentMappedTrackInfo(); + if (trackInfo != null) { + if (trackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO) == RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + onErrorMessage(toro.getString(R.string.error_unsupported_video)); + } + + if (trackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO) == RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + onErrorMessage(toro.getString(R.string.error_unsupported_audio)); + } + } + } + } + + @Override public void onPlayerError(ExoPlaybackException error) { + /// Adapt from ExoPlayer Demo + String errorString = null; + if (error.type == ExoPlaybackException.TYPE_RENDERER) { + Exception cause = error.getRendererException(); + if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { + // Special case for decoder initialization failures. + MediaCodecRenderer.DecoderInitializationException decoderInitializationException = + (MediaCodecRenderer.DecoderInitializationException) cause; + if (decoderInitializationException.decoderName == null) { + if (decoderInitializationException.getCause() instanceof MediaCodecUtil.DecoderQueryException) { + errorString = toro.getString(R.string.error_querying_decoders); + } else if (decoderInitializationException.secureDecoderRequired) { + errorString = toro.getString(R.string.error_no_secure_decoder, + decoderInitializationException.mimeType); + } else { + errorString = toro.getString(R.string.error_no_decoder, + decoderInitializationException.mimeType); + } + } else { + errorString = toro.getString(R.string.error_instantiating_decoder, + decoderInitializationException.decoderName); + } + } + } + + if (errorString != null) onErrorMessage(errorString); + + inErrorState = true; + if (isBehindLiveWindow(error)) { + ExoPlayable.super.reset(); + } else { + ExoPlayable.super.updatePlaybackInfo(); + } + + super.onPlayerError(error); + } + + @Override public void onPositionDiscontinuity(int reason) { + if (inErrorState) { + // Adapt from ExoPlayer demo. + // "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 seek." - ExoPlayer + ExoPlayable.super.updatePlaybackInfo(); + } + + super.onPositionDiscontinuity(reason); + } + } + + static boolean isBehindLiveWindow(ExoPlaybackException error) { + if (error.type != ExoPlaybackException.TYPE_SOURCE) return false; + Throwable cause = error.getSourceException(); + while (cause != null) { + if (cause instanceof BehindLiveWindowException) return true; + cause = cause.getCause(); + } + return false; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerDispatcher.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerDispatcher.java new file mode 100644 index 00000000..031c1f3a --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerDispatcher.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Beta; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.PressablePlayerSelector; + +/** + * @author eneim (2018/08/18). + * @since 3.6.0.2802 + * + * Work with {@link PressablePlayerSelector} and {@link PlayerView} to handle user's custom playback + * interaction. A common use-case is when user clicks the Play button to manually start a playback. + * We should respect this by putting the {@link ToroPlayer}'s priority to highest, and request a + * refresh for all {@link ToroPlayer}. + * + * The same behaviour should be handled for the case user clicks the Pause button. + * + * All behaviour should be cleared once user scroll the selection out of playable region. This is + * already handled by {@link PressablePlayerSelector}. + */ +@Beta // +public class ExoPlayerDispatcher extends DefaultControlDispatcher { + + private final PressablePlayerSelector playerSelector; + private final ToroPlayer toroPlayer; + + public ExoPlayerDispatcher(PressablePlayerSelector playerSelector, ToroPlayer toroPlayer) { + this.playerSelector = playerSelector; + this.toroPlayer = toroPlayer; + } + + @Override public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { + if (playWhenReady) { + // Container will handle the call to play. + return playerSelector.toPlay(toroPlayer.getPlayerOrder()); + } else { + player.setPlayWhenReady(false); + playerSelector.toPause(toroPlayer.getPlayerOrder()); + return true; + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerViewHelper.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerViewHelper.java new file mode 100644 index 00000000..3ee611a7 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ExoPlayerViewHelper.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroExo.with; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.RemoveIn; +import ml.docilealligator.infinityforreddit.videoautoplay.helper.ToroPlayerHelper; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; + +/** + * An implementation of {@link ToroPlayerHelper} where the actual Player is an {@link ExoPlayer} + * implementation. This is a bridge between ExoPlayer's callback and ToroPlayerHelper behaviors. + * + * @author eneim (2018/01/24). + * @since 3.4.0 + */ + +public class ExoPlayerViewHelper extends ToroPlayerHelper { + + @NonNull private final ExoPlayable playable; + @NonNull private final MyEventListeners listeners; + private final boolean lazyPrepare; + + // Container is no longer required for constructing new instance. + @SuppressWarnings("unused") @RemoveIn(version = "3.6.0") @Deprecated // + public ExoPlayerViewHelper(Container container, @NonNull ToroPlayer player, @NonNull Uri uri) { + this(player, uri); + } + + public ExoPlayerViewHelper(@NonNull ToroPlayer player, @NonNull Uri uri) { + this(player, uri, null); + } + + public ExoPlayerViewHelper(@NonNull ToroPlayer player, @NonNull Uri uri, + @Nullable String fileExt) { + this(player, uri, fileExt, with(player.getPlayerView().getContext()).getDefaultCreator()); + } + + /** Config instance should be kept as global instance. */ + public ExoPlayerViewHelper(@NonNull ToroPlayer player, @NonNull Uri uri, @Nullable String fileExt, + @NonNull Config config) { + this(player, uri, fileExt, + with(player.getPlayerView().getContext()).getCreator(checkNotNull(config))); + } + + public ExoPlayerViewHelper(@NonNull ToroPlayer player, @NonNull Uri uri, @Nullable String fileExt, + @NonNull ExoCreator creator) { + this(player, new ExoPlayable(creator, uri, fileExt)); + } + + public ExoPlayerViewHelper(@NonNull ToroPlayer player, @NonNull ExoPlayable playable) { + super(player); + //noinspection ConstantConditions + if (player.getPlayerView() == null || !(player.getPlayerView() instanceof PlayerView)) { + throw new IllegalArgumentException("Require non-null PlayerView"); + } + + listeners = new MyEventListeners(); + this.playable = playable; + this.lazyPrepare = true; + } + + @Override protected void initialize(@NonNull PlaybackInfo playbackInfo) { + playable.setPlaybackInfo(playbackInfo); + playable.addEventListener(listeners); + playable.addErrorListener(super.getErrorListeners()); + playable.addOnVolumeChangeListener(super.getVolumeChangeListeners()); + playable.prepare(!lazyPrepare); + playable.setPlayerView((PlayerView) player.getPlayerView()); + } + + @Override public void release() { + super.release(); + playable.setPlayerView(null); + playable.removeOnVolumeChangeListener(super.getVolumeChangeListeners()); + playable.removeErrorListener(super.getErrorListeners()); + playable.removeEventListener(listeners); + playable.release(); + } + + @Override public void play() { + playable.play(); + } + + @Override public void pause() { + playable.pause(); + } + + @Override public boolean isPlaying() { + return playable.isPlaying(); + } + + @Override public void setVolume(float volume) { + playable.setVolume(volume); + } + + @Override public float getVolume() { + return playable.getVolume(); + } + + @Override public void setVolumeInfo(@NonNull VolumeInfo volumeInfo) { + playable.setVolumeInfo(volumeInfo); + } + + @Override @NonNull public VolumeInfo getVolumeInfo() { + return playable.getVolumeInfo(); + } + + @NonNull @Override public PlaybackInfo getLatestPlaybackInfo() { + return playable.getPlaybackInfo(); + } + + @Override public void setPlaybackInfo(@NonNull PlaybackInfo playbackInfo) { + this.playable.setPlaybackInfo(playbackInfo); + } + + public void addEventListener(@NonNull Playable.EventListener listener) { + //noinspection ConstantConditions + if (listener != null) this.listeners.add(listener); + } + + public void removeEventListener(Playable.EventListener listener) { + this.listeners.remove(listener); + } + + // A proxy, to also hook into ToroPlayerHelper's state change event. + private class MyEventListeners extends Playable.EventListeners { + + MyEventListeners() { + } + + @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + ExoPlayerViewHelper.super.onPlayerStateUpdated(playWhenReady, playbackState); // important + super.onPlayerStateChanged(playWhenReady, playbackState); + } + + @Override public void onRenderedFirstFrame() { + super.onRenderedFirstFrame(); + internalListener.onFirstFrameRendered(); + for (ToroPlayer.EventListener listener : ExoPlayerViewHelper.super.getEventListeners()) { + listener.onFirstFrameRendered(); + } + } + } +}
\ No newline at end of file diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/MediaSourceBuilder.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/MediaSourceBuilder.java new file mode 100644 index 00000000..7daacc99 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/MediaSourceBuilder.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static android.text.TextUtils.isEmpty; +import static com.google.android.exoplayer2.util.Util.inferContentType; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.ContentType; +import com.google.android.exoplayer2.source.LoopingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +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.upstream.DataSource; + +/** + * @author eneim (2018/01/24). + * @since 3.4.0 + */ + +public interface MediaSourceBuilder { + + @NonNull MediaSource buildMediaSource(@NonNull Context context, @NonNull Uri uri, + @Nullable String fileExt, @Nullable Handler handler, + @NonNull DataSource.Factory manifestDataSourceFactory, + @NonNull DataSource.Factory mediaDataSourceFactory, + @Nullable MediaSourceEventListener listener); + + MediaSourceBuilder DEFAULT = new MediaSourceBuilder() { + @NonNull @Override + public MediaSource buildMediaSource(@NonNull Context context, @NonNull Uri uri, + @Nullable String ext, @Nullable Handler handler, + @NonNull DataSource.Factory manifestDataSourceFactory, + @NonNull DataSource.Factory mediaDataSourceFactory, MediaSourceEventListener listener) { + @ContentType int type = isEmpty(ext) ? inferContentType(uri) : inferContentType("." + ext); + MediaSource result; + switch (type) { + /*case C.TYPE_SS: + result = new SsMediaSource.Factory( // + new DefaultSsChunkSource.Factory(mediaDataSourceFactory), manifestDataSourceFactory)// + .createMediaSource(uri); + break;*/ + case C.TYPE_DASH: + result = new DashMediaSource.Factory( + new DefaultDashChunkSource.Factory(mediaDataSourceFactory), manifestDataSourceFactory) + .createMediaSource(uri); + break; + case C.TYPE_HLS: + result = new HlsMediaSource.Factory(mediaDataSourceFactory) // + .createMediaSource(uri); + break; + case C.TYPE_OTHER: + result = new ProgressiveMediaSource.Factory(mediaDataSourceFactory) // + .createMediaSource(uri); + break; + default: + throw new IllegalStateException("Unsupported type: " + type); + } + + result.addEventListener(handler, listener); + return result; + } + }; + + MediaSourceBuilder LOOPING = new MediaSourceBuilder() { + + @NonNull @Override + public MediaSource buildMediaSource(@NonNull Context context, @NonNull Uri uri, + @Nullable String fileExt, @Nullable Handler handler, + @NonNull DataSource.Factory manifestDataSourceFactory, + @NonNull DataSource.Factory mediaDataSourceFactory, + @Nullable MediaSourceEventListener listener) { + return new LoopingMediaSource( + DEFAULT.buildMediaSource(context, uri, fileExt, handler, manifestDataSourceFactory, + mediaDataSourceFactory, listener)); + } + }; +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Playable.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Playable.java new file mode 100644 index 00000000..a713b5a7 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/Playable.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ExoPlaybackException; +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.metadata.Metadata; +import com.google.android.exoplayer2.metadata.MetadataOutput; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.TextOutput; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.video.VideoListener; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.RemoveIn; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; + +/** + * Define an interface to control a playback, specific for {@link SimpleExoPlayer} and {@link PlayerView}. + * + * This interface is designed to be reused across Config change. Implementation must not hold any + * strong reference to Activity, and if it supports any kind of that, make sure to implicitly clean + * it up. + * + * @author eneim + * @since 3.4.0 + */ + +@SuppressWarnings("unused") // +public interface Playable { + + /** + * Prepare the resource for a {@link SimpleExoPlayer}. This method should: + * - Request for new {@link SimpleExoPlayer} instance if there is not a usable one. + * - Configure {@link EventListener} for it. + * - If there is non-trivial PlaybackInfo, update it to the SimpleExoPlayer. + * - If client request to prepare MediaSource, then prepare it. + * + * This method must be called before {@link #setPlayerView(PlayerView)}. + * + * @param prepareSource if {@code true}, also prepare the MediaSource when preparing the Player, + * if {@code false} just do nothing for the MediaSource. + */ + void prepare(boolean prepareSource); + + /** + * Set the {@link PlayerView} for this Playable. It is expected that a playback doesn't require a + * UI, so this setup is optional. But it must be called after the SimpleExoPlayer is prepared, + * that is after {@link #prepare(boolean)} and before {@link #release()}. + * + * Changing the PlayerView during playback is expected, though not always recommended, especially + * on old Devices with low Android API. + * + * @param playerView the PlayerView to set to the SimpleExoPlayer. + */ + void setPlayerView(@Nullable PlayerView playerView); + + /** + * Get current {@link PlayerView} of this Playable. + * + * @return current PlayerView instance of this Playable. + */ + @Nullable PlayerView getPlayerView(); + + /** + * Start the playback. If the {@link MediaSource} is not prepared, then also prepare it. + */ + void play(); + + /** + * Pause the playback. + */ + void pause(); + + /** + * Reset all resource, so that the playback can start all over again. This is to cleanup the + * playback for reuse. The SimpleExoPlayer instance must be still usable without calling + * {@link #prepare(boolean)}. + */ + void reset(); + + /** + * Release all resource. After this, the SimpleExoPlayer is released to the Player pool and the + * Playable must call {@link #prepare(boolean)} again to use it again. + */ + void release(); + + /** + * Get current {@link PlaybackInfo} of the playback. + * + * @return current PlaybackInfo of the playback. + */ + @NonNull + PlaybackInfo getPlaybackInfo(); + + /** + * Set the custom {@link PlaybackInfo} for this playback. This could suggest a seek. + * + * @param playbackInfo the PlaybackInfo to set for this playback. + */ + void setPlaybackInfo(@NonNull PlaybackInfo playbackInfo); + + /** + * Add a new {@link EventListener} to this Playable. As calling {@link #prepare(boolean)} also + * triggers some internal events, this method should be called before {@link #prepare(boolean)} so + * that Client could received them all. + * + * @param listener the EventListener to add, must be not {@code null}. + */ + void addEventListener(@NonNull EventListener listener); + + /** + * Remove an {@link EventListener} from this Playable. + * + * @param listener the EventListener to be removed. If null, nothing happens. + */ + void removeEventListener(EventListener listener); + + /** + * !This must only work if the Player in use is a {@link ToroExoPlayer}. + */ + void addOnVolumeChangeListener(@NonNull ToroPlayer.OnVolumeChangeListener listener); + + void removeOnVolumeChangeListener(@Nullable ToroPlayer.OnVolumeChangeListener listener); + + /** + * Check if current Playable is playing or not. + * + * @return {@code true} if this Playable is playing, {@code false} otherwise. + */ + boolean isPlaying(); + + /** + * Change the volume of current playback. + * + * @param volume the volume value to be set. Must be a {@code float} of range from 0 to 1. + * @deprecated use {@link #setVolumeInfo(VolumeInfo)} instead. + */ + @RemoveIn(version = "3.6.0") @Deprecated // + void setVolume(@FloatRange(from = 0.0, to = 1.0) float volume); + + /** + * Obtain current volume value. The returned value is a {@code float} of range from 0 to 1. + * + * @return current volume value. + * @deprecated use {@link #getVolumeInfo()} instead. + */ + @RemoveIn(version = "3.6.0") @Deprecated // + @FloatRange(from = 0.0, to = 1.0) float getVolume(); + + /** + * Update playback's volume. + * + * @param volumeInfo the {@link VolumeInfo} to update to. + * @return {@code true} if current Volume info is updated, {@code false} otherwise. + */ + boolean setVolumeInfo(@NonNull VolumeInfo volumeInfo); + + /** + * Get current {@link VolumeInfo}. + */ + @NonNull VolumeInfo getVolumeInfo(); + + /** + * Same as {@link Player#setPlaybackParameters(PlaybackParameters)} + */ + void setParameters(@Nullable PlaybackParameters parameters); + + /** + * Same as {@link Player#getPlaybackParameters()} + */ + @Nullable PlaybackParameters getParameters(); + + void addErrorListener(@NonNull ToroPlayer.OnErrorListener listener); + + void removeErrorListener(@Nullable ToroPlayer.OnErrorListener listener); + + // Combine necessary interfaces. + interface EventListener extends Player.EventListener, VideoListener, TextOutput, MetadataOutput { + + } + + /** Default empty implementation */ + class DefaultEventListener implements EventListener { + + @Override public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { + + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + + } + + @Override public void onLoadingChanged(boolean isLoading) { + + } + + @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + + } + + @Override public void onRepeatModeChanged(int repeatMode) { + + } + + @Override public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + + } + + @Override public void onPlayerError(ExoPlaybackException error) { + + } + + @Override public void onPositionDiscontinuity(int reason) { + + } + + @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + + } + + @Override public void onSeekProcessed() { + + } + + @Override public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + + } + + @Override public void onRenderedFirstFrame() { + + } + + @Override public void onCues(List<Cue> cues) { + + } + + @Override public void onMetadata(Metadata metadata) { + + } + } + + /** List of EventListener */ + class EventListeners extends CopyOnWriteArraySet<EventListener> implements EventListener { + + EventListeners() { + } + + @Override public void onVideoSizeChanged(int width, int height, int unAppliedRotationDegrees, + float pixelWidthHeightRatio) { + for (EventListener eventListener : this) { + eventListener.onVideoSizeChanged(width, height, unAppliedRotationDegrees, + pixelWidthHeightRatio); + } + } + + @Override public void onRenderedFirstFrame() { + for (EventListener eventListener : this) { + eventListener.onRenderedFirstFrame(); + } + } + + @Override public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { + for (EventListener eventListener : this) { + eventListener.onTimelineChanged(timeline, manifest, reason); + } + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + for (EventListener eventListener : this) { + eventListener.onTracksChanged(trackGroups, trackSelections); + } + } + + @Override public void onLoadingChanged(boolean isLoading) { + for (EventListener eventListener : this) { + eventListener.onLoadingChanged(isLoading); + } + } + + @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + for (EventListener eventListener : this) { + eventListener.onPlayerStateChanged(playWhenReady, playbackState); + } + } + + @Override public void onRepeatModeChanged(int repeatMode) { + for (EventListener eventListener : this) { + eventListener.onRepeatModeChanged(repeatMode); + } + } + + @Override public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + for (EventListener eventListener : this) { + eventListener.onShuffleModeEnabledChanged(shuffleModeEnabled); + } + } + + @Override public void onPlayerError(ExoPlaybackException error) { + for (EventListener eventListener : this) { + eventListener.onPlayerError(error); + } + } + + @Override public void onPositionDiscontinuity(int reason) { + for (EventListener eventListener : this) { + eventListener.onPositionDiscontinuity(reason); + } + } + + @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + for (EventListener eventListener : this) { + eventListener.onPlaybackParametersChanged(playbackParameters); + } + } + + @Override public void onSeekProcessed() { + for (EventListener eventListener : this) { + eventListener.onSeekProcessed(); + } + } + + @Override public void onCues(List<Cue> cues) { + for (EventListener eventListener : this) { + eventListener.onCues(cues); + } + } + + @Override public void onMetadata(Metadata metadata) { + for (EventListener eventListener : this) { + eventListener.onMetadata(metadata); + } + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayableImpl.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayableImpl.java new file mode 100644 index 00000000..d1d47c07 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayableImpl.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroExo.with; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.INDEX_UNSET; +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.TIME_UNSET; + +import android.net.Uri; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.ui.PlayerView; + +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; + +/** + * [20180225] + * + * Default implementation of {@link Playable}. + * + * Instance of {@link Playable} should be reusable. Retaining instance of Playable across config + * change must guarantee that all {@link EventListener} are cleaned up on config change. + * + * @author eneim (2018/02/25). + */ +@SuppressWarnings("WeakerAccess") // +class PlayableImpl implements Playable { + + private final PlaybackInfo playbackInfo = new PlaybackInfo(); // never expose to outside. + + protected final EventListeners listeners = new EventListeners(); // original listener. + protected final ToroPlayer.VolumeChangeListeners volumeChangeListeners = new ToroPlayer.VolumeChangeListeners(); + protected final ToroPlayer.ErrorListeners errorListeners = new ToroPlayer.ErrorListeners(); + + protected final Uri mediaUri; // immutable, parcelable + protected final String fileExt; + protected final ExoCreator creator; // required, cached + + protected SimpleExoPlayer player; // on-demand, cached + protected MediaSource mediaSource; // on-demand, since we do not reuse MediaSource now. + protected PlayerView playerView; // on-demand, not always required. + + private boolean sourcePrepared = false; + private boolean listenerApplied = false; + + PlayableImpl(ExoCreator creator, Uri uri, String fileExt) { + this.creator = creator; + this.mediaUri = uri; + this.fileExt = fileExt; + } + + @CallSuper @Override public void prepare(boolean prepareSource) { + if (prepareSource) { + ensureMediaSource(); + ensurePlayerView(); + } + } + + @CallSuper @Override public void setPlayerView(@Nullable PlayerView playerView) { + if (this.playerView == playerView) return; + if (playerView == null) { + this.playerView.setPlayer(null); + } else { + if (this.player != null) { + PlayerView.switchTargetView(this.player, this.playerView, playerView); + } + } + + this.playerView = playerView; + } + + @Override public final PlayerView getPlayerView() { + return this.playerView; + } + + @CallSuper @Override public void play() { + ensureMediaSource(); + ensurePlayerView(); + checkNotNull(player, "Playable#play(): Player is null!"); + player.setPlayWhenReady(true); + } + + @CallSuper @Override public void pause() { + // Player is not required to be non-null here. + if (player != null) player.setPlayWhenReady(false); + } + + @CallSuper @Override public void reset() { + this.playbackInfo.reset(); + if (player != null) { + // reset volume to default + ToroExo.setVolumeInfo(this.player, new VolumeInfo(false, 1.f)); + player.stop(true); + } + this.mediaSource = null; // so it will be re-prepared when play() is called. + this.sourcePrepared = false; + } + + @CallSuper @Override public void release() { + this.setPlayerView(null); + if (this.player != null) { + // reset volume to default + ToroExo.setVolumeInfo(this.player, new VolumeInfo(false, 1.f)); + this.player.stop(true); + if (listenerApplied) { + player.removeListener(listeners); + player.removeVideoListener(listeners); + player.removeTextOutput(listeners); + player.removeMetadataOutput(listeners); + if (this.player instanceof ToroExoPlayer) { + ((ToroExoPlayer) this.player).removeOnVolumeChangeListener(this.volumeChangeListeners); + } + listenerApplied = false; + } + with(checkNotNull(creator.getContext(), "ExoCreator has no Context")) // + .releasePlayer(this.creator, this.player); + } + this.player = null; + this.mediaSource = null; + this.sourcePrepared = false; + } + + @CallSuper @NonNull @Override public PlaybackInfo getPlaybackInfo() { + updatePlaybackInfo(); + return new PlaybackInfo(playbackInfo.getResumeWindow(), playbackInfo.getResumePosition(), + playbackInfo.getVolumeInfo()); + } + + @CallSuper @Override public void setPlaybackInfo(@NonNull PlaybackInfo playbackInfo) { + this.playbackInfo.setResumeWindow(playbackInfo.getResumeWindow()); + this.playbackInfo.setResumePosition(playbackInfo.getResumePosition()); + this.setVolumeInfo(playbackInfo.getVolumeInfo()); + + if (player != null) { + ToroExo.setVolumeInfo(player, this.playbackInfo.getVolumeInfo()); + boolean haveResumePosition = this.playbackInfo.getResumeWindow() != INDEX_UNSET; + if (haveResumePosition) { + player.seekTo(this.playbackInfo.getResumeWindow(), this.playbackInfo.getResumePosition()); + } + } + } + + @Override public final void addEventListener(@NonNull EventListener listener) { + //noinspection ConstantConditions + if (listener != null) this.listeners.add(listener); + } + + @Override public final void removeEventListener(EventListener listener) { + this.listeners.remove(listener); + } + + @CallSuper @Override public void setVolume(float volume) { + checkNotNull(player, "Playable#setVolume(): Player is null!"); + playbackInfo.getVolumeInfo().setTo(volume == 0, volume); + ToroExo.setVolumeInfo(player, this.playbackInfo.getVolumeInfo()); + } + + @CallSuper @Override public float getVolume() { + return checkNotNull(player, "Playable#getVolume(): Player is null!").getVolume(); + } + + @Override public boolean setVolumeInfo(@NonNull VolumeInfo volumeInfo) { + boolean changed = !this.playbackInfo.getVolumeInfo().equals(checkNotNull(volumeInfo)); + if (changed) { + this.playbackInfo.getVolumeInfo().setTo(volumeInfo.isMute(), volumeInfo.getVolume()); + if (player != null) ToroExo.setVolumeInfo(player, this.playbackInfo.getVolumeInfo()); + } + return changed; + } + + @NonNull @Override public VolumeInfo getVolumeInfo() { + return this.playbackInfo.getVolumeInfo(); + } + + @Override public void setParameters(@Nullable PlaybackParameters parameters) { + checkNotNull(player, "Playable#setParameters(PlaybackParameters): Player is null") // + .setPlaybackParameters(parameters); + } + + @Override public PlaybackParameters getParameters() { + return checkNotNull(player, "Playable#getParameters(): Player is null").getPlaybackParameters(); + } + + @Override + public void addOnVolumeChangeListener(@NonNull ToroPlayer.OnVolumeChangeListener listener) { + volumeChangeListeners.add(checkNotNull(listener)); + } + + @Override + public void removeOnVolumeChangeListener(@Nullable ToroPlayer.OnVolumeChangeListener listener) { + volumeChangeListeners.remove(listener); + } + + @Override public boolean isPlaying() { + return player != null && player.getPlayWhenReady(); + } + + @Override public void addErrorListener(@NonNull ToroPlayer.OnErrorListener listener) { + this.errorListeners.add(checkNotNull(listener)); + } + + @Override public void removeErrorListener(@Nullable ToroPlayer.OnErrorListener listener) { + this.errorListeners.remove(listener); + } + + final void updatePlaybackInfo() { + if (player == null || player.getPlaybackState() == Player.STATE_IDLE) return; + playbackInfo.setResumeWindow(player.getCurrentWindowIndex()); + playbackInfo.setResumePosition(player.isCurrentWindowSeekable() ? // + Math.max(0, player.getCurrentPosition()) : TIME_UNSET); + playbackInfo.setVolumeInfo(ToroExo.getVolumeInfo(player)); + } + + private void ensurePlayerView() { + if (playerView != null && playerView.getPlayer() != player) playerView.setPlayer(player); + } + + // TODO [20180822] Double check this. + private void ensureMediaSource() { + if (mediaSource == null) { // Only actually prepare the source when play() is called. + sourcePrepared = false; + mediaSource = creator.createMediaSource(mediaUri, fileExt); + } + + if (!sourcePrepared) { + ensurePlayer(); // sourcePrepared is set to false only when player is null. + beforePrepareMediaSource(); + player.prepare(mediaSource, playbackInfo.getResumeWindow() == C.INDEX_UNSET, false); + sourcePrepared = true; + } + } + + private void ensurePlayer() { + if (player == null) { + sourcePrepared = false; + player = with(checkNotNull(creator.getContext(), "ExoCreator has no Context")) // + .requestPlayer(creator); + listenerApplied = false; + } + + if (!listenerApplied) { + if (player instanceof ToroExoPlayer) { + ((ToroExoPlayer) player).addOnVolumeChangeListener(volumeChangeListeners); + } + player.addListener(listeners); + player.addVideoListener(listeners); + player.addTextOutput(listeners); + player.addMetadataOutput(listeners); + listenerApplied = true; + } + + ToroExo.setVolumeInfo(player, this.playbackInfo.getVolumeInfo()); + boolean haveResumePosition = playbackInfo.getResumeWindow() != C.INDEX_UNSET; + if (haveResumePosition) { + player.seekTo(playbackInfo.getResumeWindow(), playbackInfo.getResumePosition()); + } + } + + // Trick to inject to the Player creation event. + // Required for AdsLoader to set Player. + protected void beforePrepareMediaSource() { + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerDispatcher.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerDispatcher.java new file mode 100644 index 00000000..4f86ccd9 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerDispatcher.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +/** + * This is an addition layer used in PlayerManager. Setting this where + * {@link #getDelayToPlay(ToroPlayer)} returns a positive value will result in a delay in playback + * play(). While returning {@link #DELAY_NONE} will dispatch the action immediately, and returning + * {@link #DELAY_INFINITE} will not dispatch the action. + * + * @author eneim (2018/02/24). + * @since 3.4.0 + */ + +public interface PlayerDispatcher { + + int DELAY_INFINITE = -1; + + int DELAY_NONE = 0; + + /** + * Return the number of milliseconds that a call to {@link ToroPlayer#play()} should be delayed. + * Returning {@link #DELAY_INFINITE} will not start the playback, while returning {@link + * #DELAY_NONE} will start it immediately. + * + * @param player the player that is about to play. + * @return number of milliseconds to delay the play, or one of {@link #DELAY_INFINITE} or + * {@link #DELAY_NONE}. No other negative number should be used. + */ + int getDelayToPlay(ToroPlayer player); + + PlayerDispatcher DEFAULT = new PlayerDispatcher() { + @Override public int getDelayToPlay(ToroPlayer player) { + return DELAY_NONE; + } + }; +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerSelector.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerSelector.java new file mode 100644 index 00000000..e42a55a7 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/PlayerSelector.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.visibleAreaOffset; +import static ml.docilealligator.infinityforreddit.videoautoplay.annotations.Sorted.Order.ASCENDING; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.NavigableMap; +import java.util.TreeMap; + +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Sorted; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; + +/** + * @author eneim | 6/2/17. + * + * PlayerSelector is a convenient class to help selecting the players to start Media + * playback. + * + * On specific event of RecyclerView, such as Child view attached/detached, scroll, the + * Collection of players those are available for a playback will change. PlayerSelector is + * responded to select a specific number of players from that updated Collection to start a + * new playback or pause an old playback if the corresponding Player is not selected + * anymore. + * + * Client should implement a custom PlayerSelecter and set it to the Container for expected + * behaviour. By default, Toro comes with linear selection implementation (the Selector + * that will iterate over the Collection and select the players from top to bottom until a + * certain condition is fullfilled, for example the maximum of player count is reached). + * + * Custom Selector can have more complicated selecting logics, for example: among 2n + 1 + * playable widgets, select n players in the middles ... + */ + +@SuppressWarnings("unused") // +public interface PlayerSelector { + + String TAG = "ToroLib:Selector"; + + /** + * Select a collection of {@link ToroPlayer}s to start a playback (if there is non-playing) item. + * Playing item are also selected. + * + * @param container current {@link Container} that holds the players. + * @param items a mutable collection of candidate {@link ToroPlayer}s, which are the players + * those can start a playback. Items are sorted in order obtained from + * {@link ToroPlayer#getPlayerOrder()}. + * @return the collection of {@link ToroPlayer}s to start a playback. An on-going playback can be + * selected, but it will keep playing. + */ + @NonNull Collection<ToroPlayer> select(@NonNull Container container, + @Sorted(order = ASCENDING) @NonNull List<ToroPlayer> items); + + /** + * The 'reverse' selector of this selector, which can help to select the reversed collection of + * that expected by this selector. + * For example: this selector will select the first playable {@link ToroPlayer} from top, so the + * 'reverse' selector will select the last playable {@link ToroPlayer} from top. + * + * @return The PlayerSelector that has opposite selecting logic. If there is no special one, + * return "this". + */ + @NonNull PlayerSelector reverse(); + + PlayerSelector DEFAULT = new PlayerSelector() { + @NonNull @Override public Collection<ToroPlayer> select(@NonNull Container container, // + @Sorted(order = ASCENDING) @NonNull List<ToroPlayer> items) { + int count = items.size(); + return count > 0 ? singletonList(items.get(0)) : Collections.<ToroPlayer>emptyList(); + } + + @NonNull @Override public PlayerSelector reverse() { + return DEFAULT_REVERSE; + } + }; + + PlayerSelector DEFAULT_REVERSE = new PlayerSelector() { + @NonNull @Override public Collection<ToroPlayer> select(@NonNull Container container, // + @Sorted(order = ASCENDING) @NonNull List<ToroPlayer> items) { + int count = items.size(); + return count > 0 ? singletonList(items.get(count - 1)) : Collections.<ToroPlayer>emptyList(); + } + + @NonNull @Override public PlayerSelector reverse() { + return DEFAULT; + } + }; + + @SuppressWarnings("unused") PlayerSelector BY_AREA = new PlayerSelector() { + + NavigableMap<Float, ToroPlayer> areas = new TreeMap<>(new Comparator<Float>() { + @Override public int compare(Float o1, Float o2) { + return Float.compare(o2, o1); // reverse order, from high to low. + } + }); + + @NonNull @Override public Collection<ToroPlayer> select(@NonNull final Container container, + @Sorted(order = ASCENDING) @NonNull List<ToroPlayer> items) { + areas.clear(); + int count = items.size(); + if (count > 0) { + for (int i = 0; i < count; i++) { + ToroPlayer item = items.get(i); + if (!areas.containsValue(item)) areas.put(visibleAreaOffset(item, container), item); + } + + count = areas.size(); + } + + return count > 0 ? singletonList(areas.firstEntry().getValue()) + : Collections.<ToroPlayer>emptyList(); + } + + @NonNull @Override public PlayerSelector reverse() { + return this; + } + }; + + @SuppressWarnings("unused") PlayerSelector NONE = new PlayerSelector() { + @NonNull @Override public Collection<ToroPlayer> select(@NonNull Container container, // + @Sorted(order = ASCENDING) @NonNull List<ToroPlayer> items) { + return emptyList(); + } + + @NonNull @Override public PlayerSelector reverse() { + return this; + } + }; +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExo.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExo.java new file mode 100644 index 00000000..147157b6 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExo.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static android.widget.Toast.LENGTH_SHORT; +import static com.google.android.exoplayer2.drm.UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME; +import static com.google.android.exoplayer2.util.Util.getDrmUuid; +import static java.lang.Runtime.getRuntime; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; +import android.text.TextUtils; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.StringRes; +import androidx.core.util.Pools; + +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +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.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.util.Util; + +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; + +import ml.docilealligator.infinityforreddit.R; +import ml.docilealligator.infinityforreddit.utils.APIUtils; +import ml.docilealligator.infinityforreddit.videoautoplay.media.DrmMedia; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; + +/** + * Global helper class to manage {@link ExoCreator} and {@link SimpleExoPlayer} instances. + * In this setup, {@link ExoCreator} and SimpleExoPlayer pools are cached. A {@link Config} + * is a key for each {@link ExoCreator}. + * + * A suggested usage is as below: + * <pre><code> + * ExoCreator creator = ToroExo.with(this).getDefaultCreator(); + * Playable playable = creator.createPlayable(uri); + * playable.prepare(); + * // next: setup PlayerView and start the playback. + * </code></pre> + * + * @author eneim (2018/01/26). + * @since 3.4.0 + */ + +public final class ToroExo { + + private static final String TAG = "ToroExo"; + + // Magic number: Build.VERSION.SDK_INT / 6 --> API 16 ~ 18 will set pool size to 2, etc. + @SuppressWarnings("WeakerAccess") // + static final int MAX_POOL_SIZE = Math.max(Util.SDK_INT / 6, getRuntime().availableProcessors()); + @SuppressLint("StaticFieldLeak") // + static volatile ToroExo toro; + + public static ToroExo with(Context context) { + if (toro == null) { + synchronized (ToroExo.class) { + if (toro == null) toro = new ToroExo(context.getApplicationContext()); + } + } + return toro; + } + + @NonNull final String appName; + @NonNull final Context context; // Application context + @NonNull private final Map<Config, ExoCreator> creators; + @NonNull private final Map<ExoCreator, Pools.Pool<SimpleExoPlayer>> playerPools; + + private Config defaultConfig; // will be created on the first time it is used. + + private ToroExo(@NonNull Context context /* Application context */) { + this.context = context; + this.appName = getUserAgent(); + this.playerPools = new HashMap<>(); + this.creators = new HashMap<>(); + + // Adapt from ExoPlayer demo app. Start this on demand. + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + if (CookieHandler.getDefault() != cookieManager) { + CookieHandler.setDefault(cookieManager); + } + } + + /** + * Utility method to produce {@link ExoCreator} instance from a {@link Config}. + */ + public final ExoCreator getCreator(Config config) { + ExoCreator creator = this.creators.get(config); + if (creator == null) { + creator = new DefaultExoCreator(this, config); + this.creators.put(config, creator); + } + + return creator; + } + + @SuppressWarnings("WeakerAccess") public final Config getDefaultConfig() { + if (defaultConfig == null) defaultConfig = new Config.Builder(context).build(); + return defaultConfig; + } + + /** + * Get the default {@link ExoCreator}. This ExoCreator is configured by {@link #defaultConfig}. + */ + public final ExoCreator getDefaultCreator() { + return getCreator(getDefaultConfig()); + } + + /** + * Request an instance of {@link SimpleExoPlayer}. It can be an existing instance cached by Pool + * or new one. + * + * The creator may or may not be the one created by either {@link #getCreator(Config)} or + * {@link #getDefaultCreator()}. + * + * @param creator the {@link ExoCreator} that is scoped to the {@link SimpleExoPlayer} config. + * @return an usable {@link SimpleExoPlayer} instance. + */ + @NonNull // + public final SimpleExoPlayer requestPlayer(@NonNull ExoCreator creator) { + SimpleExoPlayer player = getPool(checkNotNull(creator)).acquire(); + if (player == null) player = creator.createPlayer(); + return player; + } + + /** + * Release player to Pool attached to the creator. + * + * @param creator the {@link ExoCreator} that created the player. + * @param player the {@link SimpleExoPlayer} to be released back to the Pool + * @return true if player is released to relevant Pool, false otherwise. + */ + @SuppressWarnings({ "WeakerAccess", "UnusedReturnValue" }) // + public final boolean releasePlayer(@NonNull ExoCreator creator, @NonNull SimpleExoPlayer player) { + return getPool(checkNotNull(creator)).release(player); + } + + /** + * Release and clear all current cached ExoPlayer instances. This should be called when + * client Application runs out of memory ({@link Application#onTrimMemory(int)} for example). + */ + public final void cleanUp() { + // TODO [2018/03/07] Test this. Ref: https://stackoverflow.com/a/1884916/1553254 + for (Iterator<Map.Entry<ExoCreator, Pools.Pool<SimpleExoPlayer>>> it = + playerPools.entrySet().iterator(); it.hasNext(); ) { + Pools.Pool<SimpleExoPlayer> pool = it.next().getValue(); + SimpleExoPlayer item; + while ((item = pool.acquire()) != null) item.release(); + it.remove(); + } + } + + /// internal APIs + private Pools.Pool<SimpleExoPlayer> getPool(ExoCreator creator) { + Pools.Pool<SimpleExoPlayer> pool = playerPools.get(creator); + if (pool == null) { + pool = new Pools.SimplePool<>(MAX_POOL_SIZE); + playerPools.put(creator, pool); + } + + return pool; + } + + /** + * Get a possibly-non-localized String from existing resourceId. + */ + /* pkg */ String getString(@StringRes int resId, @Nullable Object... params) { + return params == null || params.length < 1 ? // + this.context.getString(resId) : this.context.getString(resId, params); + } + + /** + * Utility method to build a {@link DrmSessionManager} that can be used in {@link Config} + * + * Usage: + * <pre><code> + * DrmSessionManager manager = ToroExo.with(context).createDrmSessionManager(mediaDrm); + * Config config = new Config.Builder().setDrmSessionManager(manager); + * ExoCreator creator = ToroExo.with(context).getCreator(config); + * </code></pre> + */ + @SuppressWarnings("unused") @RequiresApi(18) @Nullable // + public DrmSessionManager<FrameworkMediaCrypto> createDrmSessionManager(@NonNull DrmMedia drm) { + DrmSessionManager<FrameworkMediaCrypto> drmSessionManager = null; + int errorStringId = R.string.error_drm_unknown; + String subString = null; + if (Util.SDK_INT < 18) { + errorStringId = R.string.error_drm_not_supported; + } else { + UUID drmSchemeUuid = getDrmUuid(checkNotNull(drm).getType()); + if (drmSchemeUuid == null) { + errorStringId = R.string.error_drm_unsupported_scheme; + } else { + HttpDataSource.Factory factory = new DefaultHttpDataSourceFactory(appName); + try { + drmSessionManager = buildDrmSessionManagerV18(drmSchemeUuid, drm.getLicenseUrl(), + drm.getKeyRequestPropertiesArray(), drm.multiSession(), factory); + } catch (UnsupportedDrmException e) { + e.printStackTrace(); + errorStringId = e.reason == REASON_UNSUPPORTED_SCHEME ? // + R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown; + if (e.reason == REASON_UNSUPPORTED_SCHEME) { + subString = drm.getType(); + } + } + } + } + + if (drmSessionManager == null) { + String error = TextUtils.isEmpty(subString) ? context.getString(errorStringId) + : context.getString(errorStringId) + ": " + subString; + Toast.makeText(context, error, LENGTH_SHORT).show(); + } + + return drmSessionManager; + } + + @RequiresApi(18) private static DrmSessionManager<FrameworkMediaCrypto> buildDrmSessionManagerV18( + @NonNull UUID uuid, @Nullable String licenseUrl, @Nullable String[] keyRequestPropertiesArray, + boolean multiSession, @NonNull HttpDataSource.Factory httpDataSourceFactory) + throws UnsupportedDrmException { + HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(licenseUrl, httpDataSourceFactory); + 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, multiSession); + } + + // Share the code of setting Volume. For use inside library only. + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + public static void setVolumeInfo(@NonNull SimpleExoPlayer player, + @NonNull VolumeInfo volumeInfo) { + if (player instanceof ToroExoPlayer) { + ((ToroExoPlayer) player).setVolumeInfo(volumeInfo); + } else { + if (volumeInfo.isMute()) { + player.setVolume(0f); + } else { + player.setVolume(volumeInfo.getVolume()); + } + } + } + + @SuppressWarnings("WeakerAccess") @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + public static VolumeInfo getVolumeInfo(SimpleExoPlayer player) { + if (player instanceof ToroExoPlayer) { + return new VolumeInfo(((ToroExoPlayer) player).getVolumeInfo()); + } else { + float volume = player.getVolume(); + return new VolumeInfo(volume == 0, volume); + } + } + + @SuppressWarnings("SameParameterValue") + private static String getUserAgent() { + return APIUtils.USER_AGENT; + } +}
\ No newline at end of file diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExoPlayer.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExoPlayer.java new file mode 100644 index 00000000..2fe92569 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroExoPlayer.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.content.Context; +import android.os.Looper; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.upstream.BandwidthMeter; + +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; + +/** + * A custom {@link SimpleExoPlayer} that also notify the change of Volume. + * + * @author eneim (2018/03/27). + */ +@SuppressWarnings("WeakerAccess") // +public class ToroExoPlayer extends SimpleExoPlayer { + + protected ToroExoPlayer(Context context, RenderersFactory renderersFactory, + TrackSelector trackSelector, LoadControl loadControl, BandwidthMeter bandwidthMeter, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, Looper looper) { + super(context, renderersFactory, trackSelector, loadControl, bandwidthMeter, drmSessionManager, + looper); + } + + private ToroPlayer.VolumeChangeListeners listeners; + + public final void addOnVolumeChangeListener(@NonNull ToroPlayer.OnVolumeChangeListener listener) { + if (this.listeners == null) this.listeners = new ToroPlayer.VolumeChangeListeners(); + this.listeners.add(checkNotNull(listener)); + } + + public final void removeOnVolumeChangeListener(ToroPlayer.OnVolumeChangeListener listener) { + if (this.listeners != null) this.listeners.remove(listener); + } + + public final void clearOnVolumeChangeListener() { + if (this.listeners != null) this.listeners.clear(); + } + + @CallSuper @Override public void setVolume(float audioVolume) { + this.setVolumeInfo(new VolumeInfo(audioVolume == 0, audioVolume)); + } + + private final VolumeInfo volumeInfo = new VolumeInfo(false, 1f); + + @SuppressWarnings("UnusedReturnValue") + public final boolean setVolumeInfo(@NonNull VolumeInfo volumeInfo) { + boolean changed = !this.volumeInfo.equals(volumeInfo); + if (changed) { + this.volumeInfo.setTo(volumeInfo.isMute(), volumeInfo.getVolume()); + super.setVolume(volumeInfo.isMute() ? 0 : volumeInfo.getVolume()); + if (listeners != null) { + for (ToroPlayer.OnVolumeChangeListener listener : this.listeners) { + listener.onVolumeChanged(volumeInfo); + } + } + } + + return changed; + } + + @SuppressWarnings("unused") @NonNull public final VolumeInfo getVolumeInfo() { + return volumeInfo; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroPlayer.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroPlayer.java new file mode 100644 index 00000000..5075b1b7 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroPlayer.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.view.View; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.CopyOnWriteArraySet; + +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; + +/** + * Definition of a Player used in Toro. Besides common playback command ({@link #play()}, {@link + * #pause()}, etc), it provides the library necessary information about the playback and + * components. + * + * @author eneim | 5/31/17. + */ + +public interface ToroPlayer { + + @NonNull View getPlayerView(); + + @NonNull + PlaybackInfo getCurrentPlaybackInfo(); + + /** + * Initialize resource for the incoming playback. After this point, {@link ToroPlayer} should be + * able to start the playback at anytime in the future (This doesn't mean that any call to {@link + * ToroPlayer#play()} will start the playback immediately. It can start buffering enough resource + * before any rendering). + * + * @param container the RecyclerView contains this Player. + * @param playbackInfo initialize info for the preparation. + */ + void initialize(@NonNull Container container, @NonNull PlaybackInfo playbackInfo); + + /** + * Start playback or resume from a pausing state. + */ + void play(); + + /** + * Pause current playback. + */ + void pause(); + + boolean isPlaying(); + + /** + * Tear down all the setup. This should release all player instances. + */ + void release(); + + boolean wantsToPlay(); + + /** + * @return prefer playback order in list. Can be customized. + */ + int getPlayerOrder(); + + /** + * A convenient callback to help {@link ToroPlayer} to listen to different playback states. + */ + interface EventListener { + + void onFirstFrameRendered(); + + void onBuffering(); // ExoPlayer state: 2 + + void onPlaying(); // ExoPlayer state: 3, play flag: true + + void onPaused(); // ExoPlayer state: 3, play flag: false + + void onCompleted(); // ExoPlayer state: 4 + } + + interface OnVolumeChangeListener { + + void onVolumeChanged(@NonNull VolumeInfo volumeInfo); + } + + interface OnErrorListener { + + void onError(Exception error); + } + + class EventListeners extends CopyOnWriteArraySet<EventListener> implements EventListener { + + @Override public void onFirstFrameRendered() { + for (EventListener listener : this) { + listener.onFirstFrameRendered(); + } + } + + @Override public void onBuffering() { + for (EventListener listener : this) { + listener.onBuffering(); + } + } + + @Override public void onPlaying() { + for (EventListener listener : this) { + listener.onPlaying(); + } + } + + @Override public void onPaused() { + for (EventListener listener : this) { + listener.onPaused(); + } + } + + @Override public void onCompleted() { + for (EventListener listener : this) { + listener.onCompleted(); + } + } + } + + class ErrorListeners extends CopyOnWriteArraySet<OnErrorListener> + implements ToroPlayer.OnErrorListener { + + @Override public void onError(Exception error) { + for (ToroPlayer.OnErrorListener listener : this) { + listener.onError(error); + } + } + } + + class VolumeChangeListeners extends CopyOnWriteArraySet<ToroPlayer.OnVolumeChangeListener> + implements ToroPlayer.OnVolumeChangeListener { + + @Override public void onVolumeChanged(@NonNull VolumeInfo volumeInfo) { + for (ToroPlayer.OnVolumeChangeListener listener : this) { + listener.onVolumeChanged(volumeInfo); + } + } + } + + // Adapt from ExoPlayer. + @Retention(RetentionPolicy.SOURCE) // + @IntDef({ State.STATE_IDLE, State.STATE_BUFFERING, State.STATE_READY, State.STATE_END }) // + @interface State { + int STATE_IDLE = 1; + int STATE_BUFFERING = 2; + int STATE_READY = 3; + int STATE_END = 4; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroUtil.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroUtil.java new file mode 100644 index 00000000..ecfc7d1d --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ToroUtil.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay; + +import android.graphics.Point; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; + +/** + * @author eneim | 5/31/17. + */ + +public final class ToroUtil { + + @SuppressWarnings("unused") private static final String TAG = "ToroLib:Util"; + + private ToroUtil() { + throw new RuntimeException("Meh!"); + } + + /** + * Get the ratio in range of 0.0 ~ 1.0 the visible area of a {@link ToroPlayer}'s playerView. + * + * @param player the {@link ToroPlayer} need to investigate. + * @param container the {@link ViewParent} that holds the {@link ToroPlayer}. If {@code null} + * then this method must returns 0.0f; + * @return the value in range of 0.0 ~ 1.0 of the visible area. + */ + @FloatRange(from = 0.0, to = 1.0) // + public static float visibleAreaOffset(@NonNull ToroPlayer player, ViewParent container) { + if (container == null) return 0.0f; + + View playerView = player.getPlayerView(); + Rect drawRect = new Rect(); + playerView.getDrawingRect(drawRect); + int drawArea = drawRect.width() * drawRect.height(); + + Rect playerRect = new Rect(); + boolean visible = playerView.getGlobalVisibleRect(playerRect, new Point()); + + float offset = 0.f; + if (visible && drawArea > 0) { + int visibleArea = playerRect.height() * playerRect.width(); + offset = visibleArea / (float) drawArea; + } + return offset; + } + + /** + * Ensures that an object reference passed as a parameter to the calling + * method is not null. + * + * @param reference an object reference + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static @NonNull <T> T checkNotNull(final T reference) { + if (reference == null) { + throw new NullPointerException(); + } + return reference; + } + + /** + * Ensures that an object reference passed as a parameter to the calling + * method is not null. + * + * @param reference an object reference + * @param errorMessage the exception message to use if the check fails; will + * be converted to a string using {@link String#valueOf(Object)} + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static @NonNull <T> T checkNotNull(final T reference, final Object errorMessage) { + if (reference == null) { + throw new NullPointerException(String.valueOf(errorMessage)); + } + return reference; + } + + @SuppressWarnings("unchecked") // + public static void wrapParamBehavior(@NonNull final Container container, + final Container.BehaviorCallback callback) { + container.setBehaviorCallback(callback); + ViewGroup.LayoutParams params = container.getLayoutParams(); + if (params instanceof CoordinatorLayout.LayoutParams) { + CoordinatorLayout.Behavior temp = ((CoordinatorLayout.LayoutParams) params).getBehavior(); + if (temp != null) { + ((CoordinatorLayout.LayoutParams) params).setBehavior(new Container.Behavior(temp)); + } + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Beta.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Beta.java new file mode 100644 index 00000000..11f9944b --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Beta.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Indicate that the feature is still in Beta testing and may not ready for production. + * + * @author eneim (2018/02/27). + */ + +@Retention(RetentionPolicy.SOURCE) // +public @interface Beta { +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/RemoveIn.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/RemoveIn.java new file mode 100644 index 00000000..504c0ebf --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/RemoveIn.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * This annotation is to mark deprecated objects to be removed from library from a certain version, + * specific by {@link #version()}. This is to help quickly navigate through them, and to make it clear. + * + * @author eneim (2018/05/01). + */ +@Retention(RetentionPolicy.SOURCE) // +public @interface RemoveIn { + + String version(); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Sorted.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Sorted.java new file mode 100644 index 00000000..91b72c85 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/annotations/Sorted.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * @author eneim (2018/02/07). + * + * Annotate that a list of items are sorted in specific {@link Order}. + */ + +@Retention(RetentionPolicy.SOURCE) // +public @interface Sorted { + + Order order() default Order.ASCENDING; + + enum Order { + ASCENDING, DESCENDING, UNSORTED + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/helper/ToroPlayerHelper.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/helper/ToroPlayerHelper.java new file mode 100644 index 00000000..7f2aa825 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/helper/ToroPlayerHelper.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.helper; + +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.CallSuper; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.RemoveIn; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; +import ml.docilealligator.infinityforreddit.videoautoplay.widget.Container; + +/** + * General definition of a helper class for a specific {@link ToroPlayer}. This class helps + * forwarding the playback state to the {@link ToroPlayer} if there is any {@link EventListener} + * registered. It also requests the initialization for the Player. + * + * From 3.4.0, this class can be reused as much as possible. + * + * @author eneim | 6/11/17. + */ +public abstract class ToroPlayerHelper { + + private final Handler handler = new Handler(Looper.getMainLooper(), new Handler.Callback() { + @Override public boolean handleMessage(Message msg) { + boolean playWhenReady = (boolean) msg.obj; + switch (msg.what) { + case ToroPlayer.State.STATE_IDLE: + // TODO: deal with idle state, maybe error handling. + break; + case ToroPlayer.State.STATE_BUFFERING /* Player.STATE_BUFFERING */: + internalListener.onBuffering(); + for (ToroPlayer.EventListener listener : getEventListeners()) { + listener.onBuffering(); + } + break; + case ToroPlayer.State.STATE_READY /* Player.STATE_READY */: + if (playWhenReady) { + internalListener.onPlaying(); + } else { + internalListener.onPaused(); + } + + for (ToroPlayer.EventListener listener : getEventListeners()) { + if (playWhenReady) { + listener.onPlaying(); + } else { + listener.onPaused(); + } + } + break; + case ToroPlayer.State.STATE_END /* Player.STATE_ENDED */: + internalListener.onCompleted(); + for (ToroPlayer.EventListener listener : getEventListeners()) { + listener.onCompleted(); + } + break; + default: + break; + } + return true; + } + }); + + @NonNull protected final ToroPlayer player; + + // This instance should be setup from #initialize and cleared from #release + protected Container container; + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + private ToroPlayer.EventListeners eventListeners; + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + private ToroPlayer.VolumeChangeListeners volumeChangeListeners; + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + private ToroPlayer.ErrorListeners errorListeners; + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // + protected final ToroPlayer.EventListener internalListener = new ToroPlayer.EventListener() { + @Override public void onFirstFrameRendered() { + + } + + @Override public void onBuffering() { + // do nothing + } + + @Override public void onPlaying() { + player.getPlayerView().setKeepScreenOn(true); + } + + @Override public void onPaused() { + player.getPlayerView().setKeepScreenOn(false); + if (container != null) { + container.savePlaybackInfo( // + player.getPlayerOrder(), checkNotNull(player.getCurrentPlaybackInfo())); + } + } + + @Override public void onCompleted() { + if (container != null) { + // Save PlaybackInfo.SCRAP to mark this player to be re-init. + container.savePlaybackInfo(player.getPlayerOrder(), PlaybackInfo.SCRAP); + } + } + }; + + public ToroPlayerHelper(@NonNull ToroPlayer player) { + this.player = player; + } + + public final void addPlayerEventListener(@NonNull ToroPlayer.EventListener listener) { + getEventListeners().add(checkNotNull(listener)); + } + + public final void removePlayerEventListener(ToroPlayer.EventListener listener) { + if (eventListeners != null) eventListeners.remove(listener); + } + + /** + * Initialize the necessary resource for the incoming playback. For example, prepare the + * ExoPlayer instance for SimpleExoPlayerView. The initialization is feed by an initial playback + * info, telling if the playback should start from a specific position or from beginning. + * + * Normally this info can be obtained from cache if there is cache manager, or {@link PlaybackInfo#SCRAP} + * if there is no such cached information. + * + * @param playbackInfo the initial playback info. + */ + protected abstract void initialize(@NonNull PlaybackInfo playbackInfo); + + public final void initialize(@NonNull Container container, @NonNull PlaybackInfo playbackInfo) { + this.container = container; + this.initialize(playbackInfo); + } + + public abstract void play(); + + public abstract void pause(); + + public abstract boolean isPlaying(); + + /** + * @deprecated use {@link #setVolumeInfo(VolumeInfo)} instead. + */ + @RemoveIn(version = "3.6.0") @Deprecated // + public abstract void setVolume(@FloatRange(from = 0.0, to = 1.0) float volume); + + /** + * @deprecated use {@link #getVolumeInfo()} instead. + */ + @RemoveIn(version = "3.6.0") @Deprecated // + public abstract @FloatRange(from = 0.0, to = 1.0) float getVolume(); + + public abstract void setVolumeInfo(@NonNull VolumeInfo volumeInfo); + + @NonNull public abstract VolumeInfo getVolumeInfo(); + + /** + * Get latest playback info. Either on-going playback info if current player is playing, or latest + * playback info available if player is paused. + * + * @return latest {@link PlaybackInfo} of current Player. + */ + @NonNull public abstract PlaybackInfo getLatestPlaybackInfo(); + + public abstract void setPlaybackInfo(@NonNull PlaybackInfo playbackInfo); + + @CallSuper + public void addOnVolumeChangeListener(@NonNull ToroPlayer.OnVolumeChangeListener listener) { + getVolumeChangeListeners().add(checkNotNull(listener)); + } + + @CallSuper public void removeOnVolumeChangeListener(ToroPlayer.OnVolumeChangeListener listener) { + if (volumeChangeListeners != null) volumeChangeListeners.remove(listener); + } + + @CallSuper public void addErrorListener(@NonNull ToroPlayer.OnErrorListener listener) { + getErrorListeners().add(checkNotNull(listener)); + } + + @CallSuper public void removeErrorListener(ToroPlayer.OnErrorListener listener) { + if (errorListeners != null) errorListeners.remove(listener); + } + + @NonNull protected final ToroPlayer.EventListeners getEventListeners() { + if (eventListeners == null) eventListeners = new ToroPlayer.EventListeners(); + return eventListeners; + } + + @NonNull protected final ToroPlayer.VolumeChangeListeners getVolumeChangeListeners() { + if (volumeChangeListeners == null) { + volumeChangeListeners = new ToroPlayer.VolumeChangeListeners(); + } + return volumeChangeListeners; + } + + @NonNull protected final ToroPlayer.ErrorListeners getErrorListeners() { + if (errorListeners == null) errorListeners = new ToroPlayer.ErrorListeners(); + return errorListeners; + } + + // Mimic ExoPlayer + @CallSuper protected final void onPlayerStateUpdated(boolean playWhenReady, + @ToroPlayer.State int playbackState) { + handler.obtainMessage(playbackState, playWhenReady).sendToTarget(); + } + + @CallSuper public void release() { + handler.removeCallbacksAndMessages(null); + this.container = null; + } + + @NonNull @Override public String toString() { + return "ToroLib:Helper{" + "player=" + player + ", container=" + container + '}'; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/DrmMedia.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/DrmMedia.java new file mode 100644 index 00000000..f48f3885 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/DrmMedia.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.media; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * @author eneim | 6/5/17. + * + * A definition of DRM media type. + */ + +public interface DrmMedia { + + // DRM Scheme + @NonNull String getType(); + + @Nullable String getLicenseUrl(); + + @Nullable String[] getKeyRequestPropertiesArray(); + + boolean multiSession(); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/PlaybackInfo.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/PlaybackInfo.java new file mode 100644 index 00000000..445fe80a --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/PlaybackInfo.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.media; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +/** + * @author eneim | 6/6/17. + */ + +public class PlaybackInfo implements Parcelable { + + public static final long TIME_UNSET = Long.MIN_VALUE + 1; + + public static final int INDEX_UNSET = -1; + + private int resumeWindow; + private long resumePosition; + @NonNull private VolumeInfo volumeInfo; + + public PlaybackInfo(int resumeWindow, long resumePosition) { + this.resumeWindow = resumeWindow; + this.resumePosition = resumePosition; + this.volumeInfo = new VolumeInfo(false, 1.f); + } + + public PlaybackInfo(int resumeWindow, long resumePosition, @NonNull VolumeInfo volumeInfo) { + this.resumeWindow = resumeWindow; + this.resumePosition = resumePosition; + this.volumeInfo = volumeInfo; + } + + public PlaybackInfo() { + this(INDEX_UNSET, TIME_UNSET); + } + + public PlaybackInfo(PlaybackInfo other) { + this(other.getResumeWindow(), other.getResumePosition(), other.getVolumeInfo()); + } + + public int getResumeWindow() { + return resumeWindow; + } + + public void setResumeWindow(int resumeWindow) { + this.resumeWindow = resumeWindow; + } + + public long getResumePosition() { + return resumePosition; + } + + public void setResumePosition(long resumePosition) { + this.resumePosition = resumePosition; + } + + @NonNull public VolumeInfo getVolumeInfo() { + return volumeInfo; + } + + public void setVolumeInfo(@NonNull VolumeInfo volumeInfo) { + this.volumeInfo = volumeInfo; + } + + public void reset() { + resumeWindow = INDEX_UNSET; + resumePosition = TIME_UNSET; + volumeInfo = new VolumeInfo(false, 1.f); + } + + @Override public String toString() { + return this == SCRAP ? "Info:SCRAP" : // + "Info{" + + "window=" + + resumeWindow + + ", position=" + + resumePosition + + ", volume=" + + volumeInfo + + '}'; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PlaybackInfo)) return false; + + PlaybackInfo that = (PlaybackInfo) o; + + if (resumeWindow != that.resumeWindow) return false; + return resumePosition == that.resumePosition; + } + + @Override public int hashCode() { + int result = resumeWindow; + result = 31 * result + (int) (resumePosition ^ (resumePosition >>> 32)); + return result; + } + + @Override public int describeContents() { + return 0; + } + + @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(this.resumeWindow); + dest.writeLong(this.resumePosition); + dest.writeParcelable(this.volumeInfo, flags); + } + + protected PlaybackInfo(Parcel in) { + this.resumeWindow = in.readInt(); + this.resumePosition = in.readLong(); + this.volumeInfo = in.readParcelable(VolumeInfo.class.getClassLoader()); + } + + public static final Creator<PlaybackInfo> CREATOR = new Creator<PlaybackInfo>() { + @Override public PlaybackInfo createFromParcel(Parcel source) { + return new PlaybackInfo(source); + } + + @Override public PlaybackInfo[] newArray(int size) { + return new PlaybackInfo[size]; + } + }; + + // A default PlaybackInfo instance, only use this to mark un-initialized players. + public static final PlaybackInfo SCRAP = new PlaybackInfo(); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/VolumeInfo.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/VolumeInfo.java new file mode 100644 index 00000000..34308820 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/media/VolumeInfo.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.media; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.FloatRange; + +/** + * Information about volume of a playback. There are a few state this class could show: + * + * - An expected volume value. + * - State of mute or not. + * + * When {@link #mute} is {@code true}, {@link #volume} value will be ignored. But when {@link #mute} + * is set to {@code false}, actual volume value will be brought to the playback. + * + * This volume information doesn't relate to system Volume. Which means that even if client set + * this to non-mute volume, the device's volume setup wins the actual behavior. + * + * @author eneim (2018/03/14). + */ +public final class VolumeInfo implements Parcelable { + + // Indicate that the playback is in muted state or not. + private boolean mute; + // The actual Volume value if 'mute' is false. + @FloatRange(from = 0, to = 1) private float volume; + + public VolumeInfo(boolean mute, @FloatRange(from = 0, to = 1) float volume) { + this.mute = mute; + this.volume = volume; + } + + public VolumeInfo(VolumeInfo other) { + this(other.isMute(), other.getVolume()); + } + + public boolean isMute() { + return mute; + } + + public void setMute(boolean mute) { + this.mute = mute; + } + + @FloatRange(from = 0, to = 1) public float getVolume() { + return volume; + } + + public void setVolume(@FloatRange(from = 0, to = 1) float volume) { + this.volume = volume; + } + + public void setTo(boolean mute, @FloatRange(from = 0, to = 1) float volume) { + this.mute = mute; + this.volume = volume; + } + + @Override public int describeContents() { + return 0; + } + + @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeByte(this.mute ? (byte) 1 : (byte) 0); + dest.writeFloat(this.volume); + } + + protected VolumeInfo(Parcel in) { + this.mute = in.readByte() != 0; + this.volume = in.readFloat(); + } + + public static final Creator<VolumeInfo> CREATOR = new ClassLoaderCreator<VolumeInfo>() { + @Override public VolumeInfo createFromParcel(Parcel source, ClassLoader loader) { + return new VolumeInfo(source); + } + + @Override public VolumeInfo createFromParcel(Parcel source) { + return new VolumeInfo(source); + } + + @Override public VolumeInfo[] newArray(int size) { + return new VolumeInfo[size]; + } + }; + + @SuppressWarnings("SimplifiableIfStatement") @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + VolumeInfo that = (VolumeInfo) o; + + if (mute != that.mute) return false; + return Float.compare(that.volume, volume) == 0; + } + + @Override public int hashCode() { + int result = (mute ? 1 : 0); + result = 31 * result + (volume != +0.0f ? Float.floatToIntBits(volume) : 0); + return result; + } + + @Override public String toString() { + return "Vol{" + "mute=" + mute + ", volume=" + volume + '}'; + } +}
\ No newline at end of file diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ui/ToroControlView.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ui/ToroControlView.java new file mode 100644 index 00000000..0ab474db --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/ui/ToroControlView.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.ui.TimeBar; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import ml.docilealligator.infinityforreddit.R; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroExo; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroExoPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.media.VolumeInfo; + +/** + * An extension of {@link PlayerControlView} that adds Volume control buttons. It works on-par + * with {@link PlayerView}. Will be automatically inflated when client uses {@link R.layout.toro_exo_player_view} + * for {@link PlayerView} layout. + * + * @author eneim (2018/08/20). + * @since 3.6.0.2802 + */ +public class ToroControlView extends PlayerControlView { + + @SuppressWarnings("unused") static final String TAG = "ToroExo:Control"; + + // Statically obtain from super class. + protected static Method hideAfterTimeoutMethod; // from parent ... + protected static boolean hideMethodFetched; + protected static Field hideActionField; + protected static boolean hideActionFetched; + + final ComponentListener componentListener; + final View volumeUpButton; + final View volumeOffButton; + final TimeBar volumeBar; + final VolumeInfo volumeInfo = new VolumeInfo(false, 1); + + public ToroControlView(Context context) { + this(context, null); + } + + public ToroControlView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ToroControlView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + volumeOffButton = findViewById(R.id.exo_volume_off); + volumeUpButton = findViewById(R.id.exo_volume_up); + volumeBar = findViewById(R.id.volume_bar); + componentListener = new ComponentListener(); + } + + @Override public void onAttachedToWindow() { + super.onAttachedToWindow(); + if (volumeUpButton != null) volumeUpButton.setOnClickListener(componentListener); + if (volumeOffButton != null) volumeOffButton.setOnClickListener(componentListener); + if (volumeBar != null) volumeBar.addListener(componentListener); + + updateVolumeButtons(); + } + + @Override public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (volumeUpButton != null) volumeUpButton.setOnClickListener(null); + if (volumeOffButton != null) volumeOffButton.setOnClickListener(null); + if (volumeBar != null) volumeBar.removeListener(componentListener); + this.setPlayer(null); + } + + @SuppressLint("ClickableViewAccessibility") @Override + public boolean onTouchEvent(MotionEvent event) { + // After processing all children' touch event, this View will just stop it here. + // User can click to PlayerView to show/hide this view, but since this View's height is not + // significantly large, clicking to show/hide may disturb other actions like clicking to button, + // seeking the bars, etc. This extension will stop the touch event here so that PlayerView has + // nothing to do when User touch this View. + return true; + } + + @Override public void setPlayer(Player player) { + Player current = super.getPlayer(); + if (current == player) return; + + if (current instanceof ToroExoPlayer) { + ((ToroExoPlayer) current).removeOnVolumeChangeListener(componentListener); + } + + super.setPlayer(player); + current = super.getPlayer(); + @NonNull final VolumeInfo tempVol; + if (current instanceof ToroExoPlayer) { + tempVol = ((ToroExoPlayer) current).getVolumeInfo(); + ((ToroExoPlayer) current).addOnVolumeChangeListener(componentListener); + } else if (current instanceof SimpleExoPlayer) { + float volume = ((SimpleExoPlayer) current).getVolume(); + tempVol = new VolumeInfo(volume == 0, volume); + } else { + tempVol = new VolumeInfo(false, 1f); + } + + this.volumeInfo.setTo(tempVol.isMute(), tempVol.getVolume()); + updateVolumeButtons(); + } + + @Override protected void onVisibilityChanged(@NonNull View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + if (changedView == this) updateVolumeButtons(); + } + + @SuppressWarnings("ConstantConditions") // + void updateVolumeButtons() { + if (!isVisible() || !ViewCompat.isAttachedToWindow(this)) { + return; + } + boolean requestButtonFocus = false; + // if muted then show volumeOffButton, or else show volumeUpButton + boolean muted = volumeInfo.isMute(); + if (volumeOffButton != null) { + requestButtonFocus |= muted && volumeOffButton.isFocused(); + volumeOffButton.setVisibility(muted ? View.VISIBLE : View.GONE); + } + if (volumeUpButton != null) { + requestButtonFocus |= !muted && volumeUpButton.isFocused(); + volumeUpButton.setVisibility(!muted ? View.VISIBLE : View.GONE); + } + + if (volumeBar != null) { + volumeBar.setDuration(100); + volumeBar.setPosition(muted ? 0 : (long) (volumeInfo.getVolume() * 100)); + } + + if (requestButtonFocus) { + requestButtonFocus(); + } + + // A hack to access PlayerControlView's hideAfterTimeout. Don't want to re-implement it. + // Reflection happens once for all instances, so it should not affect the performance. + if (!hideMethodFetched) { + try { + hideAfterTimeoutMethod = PlayerControlView.class.getDeclaredMethod("hideAfterTimeout"); + hideAfterTimeoutMethod.setAccessible(true); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + hideMethodFetched = true; + } + + if (hideAfterTimeoutMethod != null) { + try { + hideAfterTimeoutMethod.invoke(this); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } + } + } + + private void requestButtonFocus() { + boolean muted = volumeInfo.isMute(); + if (!muted && volumeUpButton != null) { + volumeUpButton.requestFocus(); + } else if (muted && volumeOffButton != null) { + volumeOffButton.requestFocus(); + } + } + + void dispatchOnScrubStart() { + // Fetch the 'hideAction' Runnable from super class. We need this to synchronize the show/hide + // behaviour when user does something. + if (!hideActionFetched) { + try { + hideActionField = PlayerControlView.class.getDeclaredField("hideAction"); + hideActionField.setAccessible(true); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } + hideActionFetched = true; + } + + if (hideActionField != null) { + try { + removeCallbacks((Runnable) hideActionField.get(this)); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + } + + // Scrub Move will always modify actual Volume, there is no 'mute-with-non-zero-volume' state. + void dispatchOnScrubMove(long position) { + if (position > 100) position = 100; + if (position < 0) position = 0; + + float actualVolume = position / (float) 100; + this.volumeInfo.setTo(actualVolume == 0, actualVolume); + if (getPlayer() instanceof SimpleExoPlayer) { + ToroExo.setVolumeInfo((SimpleExoPlayer) getPlayer(), this.volumeInfo); + } + + updateVolumeButtons(); + } + + void dispatchOnScrubStop(long position) { + this.dispatchOnScrubMove(position); + } + + private class ComponentListener + implements OnClickListener, TimeBar.OnScrubListener, ToroPlayer.OnVolumeChangeListener { + + ComponentListener() { + } + + @Override public void onClick(View v) { + Player player = ToroControlView.super.getPlayer(); + if (!(player instanceof SimpleExoPlayer)) return; + if (v == volumeOffButton) { // click to vol Off --> unmute + volumeInfo.setTo(false, volumeInfo.getVolume()); + } else if (v == volumeUpButton) { // click to vol Up --> mute + volumeInfo.setTo(true, volumeInfo.getVolume()); + } + ToroExo.setVolumeInfo((SimpleExoPlayer) player, volumeInfo); + updateVolumeButtons(); + } + + /// TimeBar.OnScrubListener + + @Override public void onScrubStart(TimeBar timeBar, long position) { + dispatchOnScrubStart(); + } + + @Override public void onScrubMove(TimeBar timeBar, long position) { + dispatchOnScrubMove(position); + } + + @Override public void onScrubStop(TimeBar timeBar, long position, boolean canceled) { + dispatchOnScrubStop(position); + } + + /// ToroPlayer.OnVolumeChangeListener + + @Override public void onVolumeChanged(@NonNull VolumeInfo volumeInfo) { + ToroControlView.this.volumeInfo.setTo(volumeInfo.isMute(), volumeInfo.getVolume()); + updateVolumeButtons(); + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Common.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Common.java new file mode 100644 index 00000000..30e86ace --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Common.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.widget; + +import android.graphics.Point; +import android.graphics.Rect; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; + +/** + * @author eneim | 6/2/17. + * + * A hub for internal convenient methods. + */ + +@SuppressWarnings({ "unused", "WeakerAccess" }) // +@RestrictTo(RestrictTo.Scope.LIBRARY) // +final class Common { + + private static final String TAG = "ToroLib:Common"; + // Keep static values to reduce instance initialization. We don't need to access its value. + private static final Rect dummyRect = new Rect(); + private static final Point dummyPoint = new Point(); + + interface Filter<T> { + + boolean accept(T target); + } + + static int compare(int x, int y) { + //noinspection UseCompareMethod + return (x < y) ? -1 : ((x == y) ? 0 : 1); + } + + static long max(Long... numbers) { + List<Long> list = Arrays.asList(numbers); + return Collections.<Long>max(list); + } + + static Comparator<ToroPlayer> ORDER_COMPARATOR = new Comparator<ToroPlayer>() { + @Override public int compare(ToroPlayer o1, ToroPlayer o2) { + return Common.compare(o1.getPlayerOrder(), o2.getPlayerOrder()); + } + }; + + static final Comparator<Integer> ORDER_COMPARATOR_INT = new Comparator<Integer>() { + @Override public int compare(Integer o1, Integer o2) { + return o1.compareTo(o2); + } + }; + + static boolean allowsToPlay(@NonNull ToroPlayer player) { + dummyRect.setEmpty(); + dummyPoint.set(0, 0); + boolean valid = player instanceof RecyclerView.ViewHolder; // Should be true + if (valid) valid = ((RecyclerView.ViewHolder) player).itemView.getParent() != null; + if (valid) valid = player.getPlayerView().getGlobalVisibleRect(dummyRect, dummyPoint); + return valid; + } + + @Nullable static <T> T findFirst(List<T> source, Filter<T> filter) { + for (T t : source) { + if (filter.accept(t)) return t; + } + + return null; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Container.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Container.java new file mode 100644 index 00000000..bf5958cf --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/Container.java @@ -0,0 +1,1137 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.widget; + +import static android.content.Context.POWER_SERVICE; +import static ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil.checkNotNull; +import static ml.docilealligator.infinityforreddit.videoautoplay.widget.Common.max; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Message; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.PowerManager; +import android.util.AttributeSet; +import android.util.Log; +import android.util.SparseArray; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; + +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.WindowInsetsCompat; +import androidx.customview.view.AbsSavedState; +import androidx.recyclerview.widget.RecyclerView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import ml.docilealligator.infinityforreddit.videoautoplay.CacheManager; +import ml.docilealligator.infinityforreddit.videoautoplay.PlayerDispatcher; +import ml.docilealligator.infinityforreddit.videoautoplay.PlayerSelector; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.RemoveIn; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; + +/** + * A custom {@link RecyclerView} that is capable of managing and controlling the {@link ToroPlayer}s' + * playback behaviour. + * + * A client wish to have the auto playback behaviour should replace the normal use of + * {@link RecyclerView} with {@link Container}. + * + * By default, {@link Container} doesn't support playback position saving/restoring. This is + * because {@link Container} has no idea about the uniqueness of media content those are being + * played. This can be archived by supplying {@link Container} with a valid {@link CacheManager}. A + * {@link CacheManager} will help providing the uniqueness of Medias by which it can correctly + * save/restore the playback state of a specific media item. Setup this can be done using + * {@link Container#setCacheManager(CacheManager)}. + * + * {@link Container} uses {@link PlayerSelector} to control the {@link ToroPlayer}. A + * {@link PlayerSelector} will be asked to select which {@link ToroPlayer} to start playback, and + * those are not selected will be paused. By default, it uses {@link PlayerSelector#DEFAULT}. + * Custom {@link PlayerSelector} can be set via {@link Container#setPlayerSelector(PlayerSelector)}. + * + * @author eneim | 5/31/17. + */ + +@SuppressWarnings({ "unused", "ConstantConditions" }) // +public class Container extends RecyclerView { + + private static final String TAG = "ToroLib:Container"; + + static final int SOME_BLINKS = 50; // 3 frames ... + + /* package */ final PlayerManager playerManager; + /* package */ final ChildLayoutChangeListener childLayoutChangeListener; + /* package */ PlayerDispatcher playerDispatcher = PlayerDispatcher.DEFAULT; + /* package */ RecyclerListenerImpl recyclerListener; // null = not attached/detached + /* package */ PlayerSelector playerSelector = PlayerSelector.DEFAULT; // null = do nothing + /* package */ Handler animatorFinishHandler; // null = not attached/detached + /* package */ BehaviorCallback behaviorCallback; + + public Container(Context context) { + this(context, null); + } + + public Container(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public Container(Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + playerManager = new PlayerManager(); + childLayoutChangeListener = new ChildLayoutChangeListener(this); + requestDisallowInterceptTouchEvent(true); + } + + @Override public final void setRecyclerListener(RecyclerListener listener) { + if (recyclerListener == null) recyclerListener = new RecyclerListenerImpl(this); + recyclerListener.delegate = listener; + super.setRecyclerListener(recyclerListener); + } + + @CallSuper @Override protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (getAdapter() != null) dataObserver.registerAdapter(getAdapter()); + if (animatorFinishHandler == null) { + animatorFinishHandler = new Handler(new AnimatorHelper(this)); + } + + PowerManager powerManager = (PowerManager) getContext().getSystemService(POWER_SERVICE); + if (powerManager != null && powerManager.isScreenOn()) { + this.screenState = View.SCREEN_STATE_ON; + } else { + this.screenState = View.SCREEN_STATE_OFF; + } + + /* setRecyclerListener can be called before this, it is considered as user-setup */ + if (recyclerListener == null) { + recyclerListener = new RecyclerListenerImpl(this); + recyclerListener.delegate = NULL; // mark as it is set by Toro, not user. + super.setRecyclerListener(recyclerListener); // must be a super call + } + + playbackInfoCache.onAttach(); + playerManager.onAttach(); + + ViewGroup.LayoutParams params = getLayoutParams(); + if (params instanceof CoordinatorLayout.LayoutParams) { + CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) params).getBehavior(); + if (behavior instanceof Behavior) { + ((Behavior) behavior).onViewAttached(this); + } + } + } + + @CallSuper @Override protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + ViewGroup.LayoutParams params = getLayoutParams(); + if (params instanceof CoordinatorLayout.LayoutParams) { + CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) params).getBehavior(); + if (behavior instanceof Behavior) { + ((Behavior) behavior).onViewDetached(this); + } + } + + if (recyclerListener != null && recyclerListener.delegate == NULL) { // set by Toro, not user. + super.setRecyclerListener(null); // must be a super call + recyclerListener = null; + } + + if (animatorFinishHandler != null) { + animatorFinishHandler.removeCallbacksAndMessages(null); + animatorFinishHandler = null; + } + + List<ToroPlayer> players = playerManager.getPlayers(); + if (!players.isEmpty()) { + for (int size = players.size(), i = size - 1; i >= 0; i--) { + ToroPlayer player = players.get(i); + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + playerManager.release(player); + } + playerManager.clear(); + } + playerManager.onDetach(); + playbackInfoCache.onDetach(); + dataObserver.registerAdapter(null); + childLayoutChangeListener.containerRef.clear(); + } + + /** + * Filter current managed {@link ToroPlayer}s using {@link Filter}. Result is sorted by Player + * order obtained from {@link ToroPlayer#getPlayerOrder()}. + * + * @param filter the {@link Filter} to a {@link ToroPlayer}. + * @return list of players accepted by {@link Filter}. Empty list if there is no available player. + */ + @NonNull public final List<ToroPlayer> filterBy(Filter filter) { + List<ToroPlayer> result = new ArrayList<>(); + for (ToroPlayer player : playerManager.getPlayers()) { + if (filter.accept(player)) result.add(player); + } + Collections.sort(result, Common.ORDER_COMPARATOR); + return result; + } + + // This method is called when: + // [1] A ViewHolder is newly created, bound and then attached to RecyclerView. + // [2] A ViewHolder is detached before, but still in bound state, not be recycled, + // and now be re-attached to RecyclerView. + // In either cases, PlayerManager should not manage the ViewHolder before this point. + @CallSuper @Override public void onChildAttachedToWindow(@NonNull final View child) { + super.onChildAttachedToWindow(child); + child.addOnLayoutChangeListener(childLayoutChangeListener); + final ViewHolder holder = getChildViewHolder(child); + if (!(holder instanceof ToroPlayer)) return; + + final ToroPlayer player = (ToroPlayer) holder; + final View playerView = player.getPlayerView(); + if (playerView == null) { + throw new NullPointerException("Expected non-null playerView, found null for: " + player); + } + + playbackInfoCache.onPlayerAttached(player); + if (playerManager.manages(player)) { + // I don't expect this to be called. If this happens, make sure to note the scenario. + Log.w(TAG, "!!Already managed: player = [" + player + "]"); + // Only if container is in idle state and player is not playing. + if (getScrollState() == SCROLL_STATE_IDLE && !player.isPlaying()) { + playerManager.play(player, playerDispatcher); + } + } else { + // LeakCanary report a leak of OnGlobalLayoutListener but I cannot figure out why ... + child.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override public void onGlobalLayout() { + child.getViewTreeObserver().removeOnGlobalLayoutListener(this); + if (Common.allowsToPlay(player)) { + if (playerManager.attachPlayer(player)) { + dispatchUpdateOnAnimationFinished(false); + } + } + } + }); + } + } + + @CallSuper @Override public void onChildDetachedFromWindow(@NonNull View child) { + super.onChildDetachedFromWindow(child); + child.removeOnLayoutChangeListener(childLayoutChangeListener); + ViewHolder holder = getChildViewHolder(child); + if (!(holder instanceof ToroPlayer)) return; + final ToroPlayer player = (ToroPlayer) holder; + + boolean playerManaged = playerManager.manages(player); + if (player.isPlaying()) { + if (!playerManaged) { + player.pause(); // Unstable state, so forcefully pause this by itself. + /* throw new IllegalStateException( + "Player is playing while it is not in managed state: " + player); */ + } + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + if (playerManaged) { + playerManager.detachPlayer(player); + } + playbackInfoCache.onPlayerDetached(player); + // RecyclerView#onChildDetachedFromWindow(View) is called after other removal finishes, so + // sometime it happens after all Animation, but we also need to update playback here. + // If there is no anymore child view, this call will end early. + dispatchUpdateOnAnimationFinished(true); + // finally release the player + // if player manager could not manager player, release by itself. + if (!playerManager.release(player)) player.release(); + } + + @CallSuper @Override public void onScrollStateChanged(int state) { + super.onScrollStateChanged(state); + // Need to handle the dead playback even when the Container is still scrolling/flinging. + List<ToroPlayer> players = playerManager.getPlayers(); + // 1. Find players those are managed but not qualified to play anymore. + for (int i = 0, size = players.size(); i < size; i++) { + ToroPlayer player = players.get(i); + if (Common.allowsToPlay(player)) continue; + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + if (!playerManager.release(player)) player.release(); + playerManager.detachPlayer(player); + } + + // 2. Refresh the good players list. + LayoutManager layout = super.getLayoutManager(); + // current number of visible 'Virtual Children', or zero if there is no LayoutManager available. + int childCount = layout != null ? layout.getChildCount() : 0; + if (childCount <= 0 || state != SCROLL_STATE_IDLE) { + playerManager.deferPlaybacks(); + return; + } + + for (int i = 0; i < childCount; i++) { + View child = layout.getChildAt(i); + ViewHolder holder = super.getChildViewHolder(child); + if (holder instanceof ToroPlayer) { + ToroPlayer player = (ToroPlayer) holder; + // Check candidate's condition + if (Common.allowsToPlay(player)) { + if (!playerManager.manages(player)) { + playerManager.attachPlayer(player); + } + // Don't check the attach result, because the player may be managed already. + if (!player.isPlaying()) { // not playing or not ready to play. + playerManager.initialize(player, Container.this); + } + } + } + } + + final List<ToroPlayer> source = playerManager.getPlayers(); + int count = source.size(); + if (count < 1) return; // No available player, return. + + List<ToroPlayer> candidates = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ToroPlayer player = source.get(i); + if (player.wantsToPlay()) candidates.add(player); + } + Collections.sort(candidates, Common.ORDER_COMPARATOR); + + Collection<ToroPlayer> toPlay = playerSelector != null ? playerSelector.select(this, candidates) + : Collections.<ToroPlayer>emptyList(); + for (ToroPlayer player : toPlay) { + if (!player.isPlaying()) playerManager.play(player, playerDispatcher); + } + + source.removeAll(toPlay); + // Now 'source' contains only ones need to be paused. + for (ToroPlayer player : source) { + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + } + } + + /** + * Setup a {@link PlayerSelector}. Set a {@code null} {@link PlayerSelector} will stop all + * playback. + * + * @param playerSelector new {@link PlayerSelector} for this {@link Container}. + */ + public final void setPlayerSelector(@Nullable PlayerSelector playerSelector) { + if (this.playerSelector == playerSelector) return; + this.playerSelector = playerSelector; + // dispatchUpdateOnAnimationFinished(true); // doesn't work well :( + // Immediately update. + this.onScrollStateChanged(SCROLL_STATE_IDLE); + } + + /** + * Get current {@link PlayerSelector}. Can be {@code null}. + * + * @return current {@link #playerSelector} + */ + @Nullable public final PlayerSelector getPlayerSelector() { + return playerSelector; + } + + public final void setPlayerDispatcher(@NonNull PlayerDispatcher playerDispatcher) { + this.playerDispatcher = checkNotNull(playerDispatcher); + } + + /** Define the callback that to be used later by {@link Behavior} if setup. */ + public final void setBehaviorCallback(@Nullable BehaviorCallback behaviorCallback) { + this.behaviorCallback = behaviorCallback; + } + + ////// Handle update after data change animation + + long getMaxAnimationDuration() { + ItemAnimator animator = getItemAnimator(); + if (animator == null) return SOME_BLINKS; + return max(animator.getAddDuration(), animator.getMoveDuration(), animator.getRemoveDuration(), + animator.getChangeDuration()); + } + + void dispatchUpdateOnAnimationFinished(boolean immediate) { + if (getScrollState() != SCROLL_STATE_IDLE) return; + if (animatorFinishHandler == null) return; + final long duration = immediate ? SOME_BLINKS : getMaxAnimationDuration(); + if (getItemAnimator() != null) { + getItemAnimator().isRunning(new ItemAnimator.ItemAnimatorFinishedListener() { + @Override public void onAnimationsFinished() { + animatorFinishHandler.removeCallbacksAndMessages(null); + animatorFinishHandler.sendEmptyMessageDelayed(-1, duration); + } + }); + } else { + animatorFinishHandler.removeCallbacksAndMessages(null); + animatorFinishHandler.sendEmptyMessageDelayed(-1, duration); + } + } + + ////// Adapter Data Observer setup + + /** + * See {@link ToroDataObserver} + */ + private final ToroDataObserver dataObserver = new ToroDataObserver(); + + /** + * See {@link Adapter#registerAdapterDataObserver(AdapterDataObserver)} + * See {@link Adapter#unregisterAdapterDataObserver(AdapterDataObserver)} + */ + @CallSuper @Override public void setAdapter(Adapter adapter) { + super.setAdapter(adapter); + dataObserver.registerAdapter(adapter); + } + + /** + * See {@link Container#setAdapter(Adapter)} + */ + @CallSuper @Override public void swapAdapter(Adapter adapter, + boolean removeAndRecycleExistingViews) { + super.swapAdapter(adapter, removeAndRecycleExistingViews); + dataObserver.registerAdapter(adapter); + } + + //// PlaybackInfo Cache implementation + /* pkg */ final PlaybackInfoCache playbackInfoCache = new PlaybackInfoCache(this); + /* pkg */ Initializer playerInitializer = Initializer.DEFAULT; + private CacheManager cacheManager = null; // null by default + + public final void setPlayerInitializer(@NonNull Initializer playerInitializer) { + this.playerInitializer = playerInitializer; + } + + /** + * Save {@link PlaybackInfo} for the current {@link ToroPlayer} of a specific order. + * If called with {@link PlaybackInfo#SCRAP}, it is a hint that the Player is completed and need + * to be re-initialized. + * + * @param order order of the {@link ToroPlayer}. + * @param playbackInfo current {@link PlaybackInfo} of the {@link ToroPlayer}. Null info will be ignored. + */ + public final void savePlaybackInfo(int order, @Nullable PlaybackInfo playbackInfo) { + if (playbackInfo != null) playbackInfoCache.savePlaybackInfo(order, playbackInfo); + } + + /** + * Get the cached {@link PlaybackInfo} at a specific order. + * + * @param order order of the {@link ToroPlayer} to get the cached {@link PlaybackInfo}. + * @return cached {@link PlaybackInfo} if available, a new one if there is no cached one. + */ + @NonNull public final PlaybackInfo getPlaybackInfo(int order) { + return playbackInfoCache.getPlaybackInfo(order); + } + + /** + * Get current list of {@link ToroPlayer}s' orders whose {@link PlaybackInfo} are cached. + * Returning an empty list will disable the save/restore of player's position. + * + * @return list of {@link ToroPlayer}s' orders. + * @deprecated Use {@link #getLatestPlaybackInfos()} for the same purpose. + */ + @RemoveIn(version = "3.6.0") @Deprecated // + @NonNull public List<Integer> getSavedPlayerOrders() { + return new ArrayList<>(playbackInfoCache.coldKeyToOrderMap.keySet()); + } + + /** + * Get a {@link SparseArray} contains cached {@link PlaybackInfo} of {@link ToroPlayer}s managed + * by this {@link Container}. If there is non-null {@link CacheManager}, this method should + * return the list of all {@link PlaybackInfo} cached by {@link PlaybackInfoCache}, otherwise, + * this method returns current {@link PlaybackInfo} of attached {@link ToroPlayer}s only. + */ + @NonNull public SparseArray<PlaybackInfo> getLatestPlaybackInfos() { + SparseArray<PlaybackInfo> cache = new SparseArray<>(); + List<ToroPlayer> activePlayers = this.filterBy(Container.Filter.PLAYING); + // This will update hotCache and coldCache if they are available. + for (ToroPlayer player : activePlayers) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + } + + if (cacheManager == null) { + if (playbackInfoCache.hotCache != null) { + for (Map.Entry<Integer, PlaybackInfo> entry : playbackInfoCache.hotCache.entrySet()) { + cache.put(entry.getKey(), entry.getValue()); + } + } + } else { + for (Map.Entry<Integer, Object> entry : playbackInfoCache.coldKeyToOrderMap.entrySet()) { + cache.put(entry.getKey(), playbackInfoCache.coldCache.get(entry.getValue())); + } + } + + return cache; + } + + /** + * Set a {@link CacheManager} to this {@link Container}. A {@link CacheManager} will + * allow this {@link Container} to save/restore {@link PlaybackInfo} on various states or life + * cycle events. Setting a {@code null} {@link CacheManager} will remove that ability. + * {@link Container} doesn't have a non-null {@link CacheManager} by default. + * + * Setting this while there is a {@code non-null} {@link CacheManager} available will clear + * current {@link PlaybackInfo} cache. + * + * @param cacheManager The {@link CacheManager} to set to the {@link Container}. + */ + public final void setCacheManager(@Nullable CacheManager cacheManager) { + if (this.cacheManager == cacheManager) return; + this.playbackInfoCache.clearCache(); + this.cacheManager = cacheManager; + } + + /** + * Get current {@link CacheManager} of the {@link Container}. + * + * @return current {@link CacheManager} of the {@link Container}. Can be {@code null}. + */ + @Nullable public final CacheManager getCacheManager() { + return cacheManager; + } + + /** + * Temporary save current playback infos when the App is stopped but not re-created. (For example: + * User press App Stack). If not {@code empty} then user is back from a living-but-stopped state. + */ + final SparseArray<PlaybackInfo> tmpStates = new SparseArray<>(); + + /** + * In case user press "App Stack" button, this View's window will have visibility change from + * {@link #VISIBLE} to {@link #INVISIBLE} to {@link #GONE}. When user is back from that state, + * the visibility changes from {@link #GONE} to {@link #INVISIBLE} to {@link #VISIBLE}. A proper + * playback needs to handle this case too. + */ + @CallSuper @Override protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + if (visibility == View.GONE) { + List<ToroPlayer> players = playerManager.getPlayers(); + // if onSaveInstanceState is called before, source will contain no item, just fine. + for (ToroPlayer player : players) { + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + } + } else if (visibility == View.VISIBLE) { + if (tmpStates.size() > 0) { + for (int i = 0; i < tmpStates.size(); i++) { + int order = tmpStates.keyAt(i); + PlaybackInfo playbackInfo = tmpStates.get(order); + this.savePlaybackInfo(order, playbackInfo); + } + } + tmpStates.clear(); + dispatchUpdateOnAnimationFinished(true); + } + + dispatchWindowVisibilityMayChange(); + } + + private int screenState; + + @Override public void onScreenStateChanged(int screenState) { + super.onScreenStateChanged(screenState); + this.screenState = screenState; + dispatchWindowVisibilityMayChange(); + } + + @Override public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + dispatchWindowVisibilityMayChange(); + } + + /** + * This method supports the case that by some reasons, Container should changes it behaviour not + * caused by any Activity recreation (so {@link #onSaveInstanceState()} and + * {@link #onRestoreInstanceState(Parcelable)} could not help). + * + * This method is called when: + * - Screen state changed. + * or - Window focus changed. + * or - Window visibility changed. + * + * For each of that event, Screen may be turned off or Window's focus state may change, we need + * to decide if Container should keep current playback state or change it. + * + * <strong>Discussion</strong>: In fact, we expect that: Container will be playing if the + * following conditions are all satisfied: + * - Current window is visible. (but not necessarily focused). + * - Container is visible in Window (partly is fine, we care about the Media player). + * - Container is focused in Window. (so we don't screw up other components' focuses). + * + * In lower API (eg: 16), {@link #getWindowVisibility()} always returns {@link #VISIBLE}, which + * cannot tell much. We need to investigate this flag in various APIs in various Scenarios. + */ + private void dispatchWindowVisibilityMayChange() { + if (screenState == SCREEN_STATE_OFF) { + List<ToroPlayer> players = playerManager.getPlayers(); + for (ToroPlayer player : players) { + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + } + } else if (screenState == SCREEN_STATE_ON + // Container is focused in current Window + && hasFocus() + // In fact, Android 24+ supports multi-window mode in which visible Window may not have focus. + // In that case, other triggers are supposed to be called and we are safe here. + // Need further investigation if need. + && hasWindowFocus()) { + // tmpStates may be consumed already, if there is a good reason for that, so not a big deal. + if (tmpStates.size() > 0) { + for (int i = 0, size = tmpStates.size(); i < size; i++) { + int order = tmpStates.keyAt(i); + this.savePlaybackInfo(order, tmpStates.get(order)); + } + } + tmpStates.clear(); + dispatchUpdateOnAnimationFinished(true); + } + } + + @Override protected Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + List<ToroPlayer> source = playerManager.getPlayers(); + for (ToroPlayer player : source) { + if (player.isPlaying()) { + this.savePlaybackInfo(player.getPlayerOrder(), player.getCurrentPlaybackInfo()); + playerManager.pause(player); + } + } + + final SparseArray<PlaybackInfo> states = playbackInfoCache.saveStates(); + + boolean recreating = + getContext() instanceof Activity && ((Activity) getContext()).isChangingConfigurations(); + + // Release current players on recreation event only. + // Note that there are cases where this method is called without the activity destroying/recreating. + // For example: in API 26 (my test mostly run on 8.0), when user click to "Current App" button, + // current Activity will enter the "Stop" state but not be destroyed/recreated and View hierarchy + // state will be saved (this method is called). + // + // We only need to release current resources when the recreation happens. + if (recreating) { + for (ToroPlayer player : source) { + if (!playerManager.release(player)) player.release(); + playerManager.detachPlayer(player); + } + } + + // Client must consider this behavior using CacheManager implement. + PlayerViewState playerViewState = new PlayerViewState(superState); + playerViewState.statesCache = states; + + // To mark that this method was called. An activity recreation will clear this. + if (states != null && states.size() > 0) { + for (int i = 0; i < states.size(); i++) { + PlaybackInfo value = states.valueAt(i); + if (value != null) tmpStates.put(states.keyAt(i), value); + } + } + + return playerViewState; + } + + @Override protected void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof PlayerViewState)) { + super.onRestoreInstanceState(state); + return; + } + + PlayerViewState viewState = (PlayerViewState) state; + super.onRestoreInstanceState(viewState.getSuperState()); + SparseArray<?> saveStates = viewState.statesCache; + if (saveStates != null) playbackInfoCache.restoreStates(saveStates); + } + + /** + * Store the array of {@link PlaybackInfo} of recently cached playback. This state will be used + * only when {@link #cacheManager} is not {@code null}. Extension of {@link Container} must + * also have its own version of {@link SavedState} extends this {@link PlayerViewState}. + */ + public static class PlayerViewState extends AbsSavedState { + + SparseArray<?> statesCache; + + /** + * Called by onSaveInstanceState + */ + PlayerViewState(Parcelable superState) { + super(superState); + } + + /** + * Called by CREATOR + */ + PlayerViewState(Parcel in, ClassLoader loader) { + super(in, loader); + statesCache = in.readSparseArray(loader); + } + + PlayerViewState(Parcel in) { + super(in); + } + + @Override public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + //noinspection unchecked + dest.writeSparseArray((SparseArray<Object>) statesCache); + } + + public static final Creator<PlayerViewState> CREATOR = + new ClassLoaderCreator<PlayerViewState>() { // Added from API 13 + @Override public PlayerViewState createFromParcel(Parcel in, ClassLoader loader) { + return new PlayerViewState(in, loader); + } + + @Override public PlayerViewState createFromParcel(Parcel source) { + return new PlayerViewState(source); + } + + @Override public PlayerViewState[] newArray(int size) { + return new PlayerViewState[size]; + } + }; + + @NonNull @Override public String toString() { + return "Cache{" + "states=" + statesCache + '}'; + } + } + + private final class ToroDataObserver extends AdapterDataObserver { + + private Adapter adapter; + + ToroDataObserver() { + } + + final void registerAdapter(Adapter adapter) { + if (this.adapter == adapter) return; + if (this.adapter != null) { + this.adapter.unregisterAdapterDataObserver(this); + this.adapter.unregisterAdapterDataObserver(playbackInfoCache); + } + + this.adapter = adapter; + if (this.adapter != null) { + this.adapter.registerAdapterDataObserver(this); + this.adapter.registerAdapterDataObserver(playbackInfoCache); + } + } + + @Override public void onChanged() { + dispatchUpdateOnAnimationFinished(true); + } + + @Override public void onItemRangeChanged(int positionStart, int itemCount) { + dispatchUpdateOnAnimationFinished(false); + } + + @Override public void onItemRangeInserted(int positionStart, int itemCount) { + dispatchUpdateOnAnimationFinished(false); + } + + @Override public void onItemRangeRemoved(int positionStart, int itemCount) { + dispatchUpdateOnAnimationFinished(false); + } + + @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + dispatchUpdateOnAnimationFinished(false); + } + } + + /** + * A {@link Handler.Callback} that will fake a scroll with {@link #SCROLL_STATE_IDLE} to refresh + * all the playback. This is relatively expensive. + */ + private static class AnimatorHelper implements Handler.Callback { + + @NonNull private final Container container; + + AnimatorHelper(@NonNull Container container) { + this.container = container; + } + + @Override public boolean handleMessage(Message msg) { + this.container.onScrollStateChanged(SCROLL_STATE_IDLE); + return true; + } + } + + private static class RecyclerListenerImpl implements RecyclerView.RecyclerListener { + + final Container container; + RecyclerListener delegate; + + RecyclerListenerImpl(@NonNull Container container) { + this.container = container; + } + + @Override public void onViewRecycled(@NonNull ViewHolder holder) { + if (this.delegate != null) this.delegate.onViewRecycled(holder); + if (holder instanceof ToroPlayer) { + ToroPlayer player = (ToroPlayer) holder; + this.container.playbackInfoCache.onPlayerRecycled(player); + this.container.playerManager.recycle(player); + } + } + } + + // This instance is to mark a RecyclerListenerImpl to be set by Toro, not by user. + private static final RecyclerListener NULL = new RecyclerListener() { + @Override public void onViewRecycled(@NonNull ViewHolder holder) { + // No-ops + } + }; + + /** + * An utility interface, used by {@link Container} to filter for {@link ToroPlayer}. + */ + public interface Filter { + + /** + * Check a {@link ToroPlayer} for a condition. + * + * @param player the {@link ToroPlayer} to check. + * @return {@code true} if this accepts the {@link ToroPlayer}, {@code false} otherwise. + */ + boolean accept(@NonNull ToroPlayer player); + + /** + * A built-in {@link Filter} that accepts only {@link ToroPlayer} that is playing. + */ + Filter PLAYING = new Filter() { + @Override public boolean accept(@NonNull ToroPlayer player) { + return player.isPlaying(); + } + }; + + /** + * A built-in {@link Filter} that accepts only {@link ToroPlayer} that is managed by Container. + * Actually any {@link ToroPlayer} to be filtered is already managed. + */ + Filter MANAGING = new Filter() { + @Override public boolean accept(@NonNull ToroPlayer player) { + return true; + } + }; + } + + /** + * This behaviour is to catch the touch/fling/scroll caused by other children of + * {@link CoordinatorLayout}. We try to acknowledge user actions by intercepting the call but not + * consume the events. + * + * This class helps solve the issue when Client has a {@link Container} inside a + * {@link CoordinatorLayout} together with an {@link AppBarLayout} whose direct child is a + * {@link CollapsingToolbarLayout} (which is 'scrollable'). This 'scroll behavior' is not the + * same as Container's natural scroll. When user 'scrolls' to collapse or expand the {@link + * AppBarLayout}, {@link CoordinatorLayout} will offset the {@link Container} to make room for + * {@link AppBarLayout}, in which {@link Container} will not receive any scrolling event update, + * but just be shifted along the scrolling axis. This behavior results in a bad case that after + * the AppBarLayout collapse its direct {@link CollapsingToolbarLayout}, the Video may be fully + * visible, but because the Container has no way to know about that event, there is no playback + * update. + * + * @since 3.4.2 + */ + public static class Behavior extends CoordinatorLayout.Behavior<Container> + implements Handler.Callback { + + @NonNull final CoordinatorLayout.Behavior<? super Container> delegate; + @Nullable BehaviorCallback callback; + + static final int EVENT_IDLE = 1; + static final int EVENT_SCROLL = 2; + static final int EVENT_TOUCH = 3; + static final int EVENT_DELAY = 150; + + final AtomicBoolean scrollConsumed = new AtomicBoolean(false); + + Handler handler; + + void onViewAttached(Container container) { + if (handler == null) handler = new Handler(this); + this.callback = container.behaviorCallback; + } + + void onViewDetached(Container container) { + if (handler != null) { + handler.removeCallbacksAndMessages(null); + handler = null; + } + this.callback = null; + } + + @Override public boolean handleMessage(Message msg) { + if (callback == null) return true; + switch (msg.what) { + case EVENT_SCROLL: + case EVENT_TOUCH: + scrollConsumed.set(false); + handler.removeMessages(EVENT_IDLE); + handler.sendEmptyMessageDelayed(EVENT_IDLE, EVENT_DELAY); + break; + case EVENT_IDLE: + // idle --> consume it. + if (!scrollConsumed.getAndSet(true)) callback.onFinishInteraction(); + break; + } + return true; + } + + /* No default constructors. Using this class from xml will result in error. */ + + // public Behavior() { + // } + // + // public Behavior(Context context, AttributeSet attrs) { + // super(context, attrs); + // } + + public Behavior(@NonNull CoordinatorLayout.Behavior<Container> delegate) { + this.delegate = checkNotNull(delegate, "Behavior is null."); + } + + /// We only need to intercept the following 3 methods: + + @Override public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, + @NonNull Container child, @NonNull MotionEvent ev) { + if (this.handler != null) { + this.handler.removeCallbacksAndMessages(null); + this.handler.sendEmptyMessage(EVENT_TOUCH); + } + return delegate.onInterceptTouchEvent(parent, child, ev); + } + + @Override + public boolean onTouchEvent(@NonNull CoordinatorLayout parent, @NonNull Container child, + @NonNull MotionEvent ev) { + if (this.handler != null) { + this.handler.removeCallbacksAndMessages(null); + this.handler.sendEmptyMessage(EVENT_TOUCH); + } + return delegate.onTouchEvent(parent, child, ev); + } + + @Override + public boolean onStartNestedScroll(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View directTargetChild, @NonNull View target, int axes, int type) { + if (this.handler != null) { + this.handler.removeCallbacksAndMessages(null); + this.handler.sendEmptyMessage(EVENT_SCROLL); + } + return delegate.onStartNestedScroll(layout, child, directTargetChild, target, axes, type); + } + + /// Other methods + + @Override public void onAttachedToLayoutParams(@NonNull CoordinatorLayout.LayoutParams params) { + if (handler == null) handler = new Handler(this); + delegate.onAttachedToLayoutParams(params); + } + + @Override public void onDetachedFromLayoutParams() { + if (handler != null) { + handler.removeCallbacksAndMessages(null); + handler = null; + } + delegate.onDetachedFromLayoutParams(); + } + + @Override @ColorInt + public int getScrimColor(@NonNull CoordinatorLayout parent, @NonNull Container child) { + return delegate.getScrimColor(parent, child); + } + + @Override + public float getScrimOpacity(@NonNull CoordinatorLayout parent, @NonNull Container child) { + return delegate.getScrimOpacity(parent, child); + } + + @Override public boolean blocksInteractionBelow(@NonNull CoordinatorLayout parent, + @NonNull Container child) { + return delegate.blocksInteractionBelow(parent, child); + } + + @Override + public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull Container child, + @NonNull View dependency) { + return delegate.layoutDependsOn(parent, child, dependency); + } + + @Override public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, + @NonNull Container child, @NonNull View dependency) { + return delegate.onDependentViewChanged(parent, child, dependency); + } + + @Override + public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, @NonNull Container child, + @NonNull View dependency) { + delegate.onDependentViewRemoved(parent, child, dependency); + } + + @Override + public boolean onMeasureChild(@NonNull CoordinatorLayout parent, @NonNull Container child, + int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { + return delegate.onMeasureChild(parent, child, parentWidthMeasureSpec, widthUsed, + parentHeightMeasureSpec, heightUsed); + } + + @Override + public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull Container child, + int layoutDirection) { + return delegate.onLayoutChild(parent, child, layoutDirection); + } + + @Override + public void onNestedScrollAccepted(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View directTargetChild, @NonNull View target, int axes, int type) { + delegate.onNestedScrollAccepted(layout, child, directTargetChild, target, axes, type); + } + + @Override + public void onStopNestedScroll(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View target, int type) { + delegate.onStopNestedScroll(layout, child, target, type); + } + + @Override + public void onNestedScroll(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, + int type) { + delegate.onNestedScroll(layout, child, target, dxConsumed, dyConsumed, dxUnconsumed, + dyUnconsumed, type); + } + + @Override + public void onNestedPreScroll(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { + delegate.onNestedPreScroll(layout, child, target, dx, dy, consumed, type); + } + + @Override + public boolean onNestedFling(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View target, float velocityX, float velocityY, boolean consumed) { + return delegate.onNestedFling(layout, child, target, velocityX, velocityY, consumed); + } + + @Override + public boolean onNestedPreFling(@NonNull CoordinatorLayout layout, @NonNull Container child, + @NonNull View target, float velocityX, float velocityY) { + return delegate.onNestedPreFling(layout, child, target, velocityX, velocityY); + } + + @Override @NonNull + public WindowInsetsCompat onApplyWindowInsets(@NonNull CoordinatorLayout layout, + @NonNull Container child, @NonNull WindowInsetsCompat insets) { + return delegate.onApplyWindowInsets(layout, child, insets); + } + + @Override public boolean onRequestChildRectangleOnScreen(@NonNull CoordinatorLayout layout, + @NonNull Container child, @NonNull Rect rectangle, boolean immediate) { + return delegate.onRequestChildRectangleOnScreen(layout, child, rectangle, immediate); + } + + @Override + public void onRestoreInstanceState(@NonNull CoordinatorLayout parent, @NonNull Container child, + @NonNull Parcelable state) { + delegate.onRestoreInstanceState(parent, child, state); + } + + @Override public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, + @NonNull Container child) { + return delegate.onSaveInstanceState(parent, child); + } + + @Override + public boolean getInsetDodgeRect(@NonNull CoordinatorLayout parent, @NonNull Container child, + @NonNull Rect rect) { + return delegate.getInsetDodgeRect(parent, child, rect); + } + } + + /** + * Callback for {@link Behavior} to tell the Client that User has finished the interaction for + * enough amount of time, so it (the Client) should do something. Normally, we ask Container to + * dispatch an 'idle scroll' to refresh the player list. + */ + public interface BehaviorCallback { + + void onFinishInteraction(); + } + + public interface Initializer { + + @NonNull PlaybackInfo initPlaybackInfo(int order); + + Initializer DEFAULT = new Initializer() { + @NonNull @Override public PlaybackInfo initPlaybackInfo(int order) { + return new PlaybackInfo(); + } + }; + } + + static class ChildLayoutChangeListener implements OnLayoutChangeListener { + + final WeakReference<Container> containerRef; + + ChildLayoutChangeListener(Container container) { + this.containerRef = new WeakReference<>(container); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + Container container = containerRef.get(); + if (container == null) return; + if (layoutDidChange(left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom)) { + container.dispatchUpdateOnAnimationFinished(false); + } + } + } + + static boolean layoutDidChange(int left, int top, int right, int bottom, int oldLeft, int oldTop, + int oldRight, int oldBottom) { + return left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlaybackInfoCache.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlaybackInfoCache.java new file mode 100644 index 00000000..9934779b --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlaybackInfoCache.java @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.widget; + +import static ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo.SCRAP; +import static ml.docilealligator.infinityforreddit.videoautoplay.widget.Common.ORDER_COMPARATOR_INT; + +import android.annotation.SuppressLint; +import android.util.SparseArray; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil; +import ml.docilealligator.infinityforreddit.videoautoplay.media.PlaybackInfo; + +/** + * @author eneim (2018/04/24). + * + * Design Target: + * + * [1] Manage the {@link PlaybackInfo} of current {@link ToroPlayer}s. Should match 1-1 with the + * {@link ToroPlayer}s that {@link PlayerManager} is managing. + * + * [2] If a non-null {@link CacheManager} provided to the {@link Container}, this class must + * properly manage the {@link PlaybackInfo} of detached {@link ToroPlayer} and restore it to + * previous state after being re-attached. + */ +@SuppressWarnings({ "unused" }) +@SuppressLint("UseSparseArrays") // +final class PlaybackInfoCache extends RecyclerView.AdapterDataObserver { + + @NonNull private final Container container; + // Cold cache represents the map between key obtained from CacheManager and PlaybackInfo. If the + // CacheManager is null, this cache will hold nothing. + /* pkg */ HashMap<Object, PlaybackInfo> coldCache = new HashMap<>(); + + // Hot cache represents the map between Player's order and its PlaybackInfo. A key-value map only + // lives within a Player's attached state. + // Being a TreeMap because we need to traversal through it in order sometime. + /* pkg */ TreeMap<Integer, PlaybackInfo> hotCache; // only cache attached Views. + + // Holds the map between Player's order and its key obtain from CacheManager. + /* pkg */ TreeMap<Integer, Object> coldKeyToOrderMap = new TreeMap<>(ORDER_COMPARATOR_INT); + + PlaybackInfoCache(@NonNull Container container) { + this.container = container; + } + + final void onAttach() { + hotCache = new TreeMap<>(ORDER_COMPARATOR_INT); + } + + final void onDetach() { + if (hotCache != null) { + hotCache.clear(); + hotCache = null; + } + coldKeyToOrderMap.clear(); + } + + final void onPlayerAttached(ToroPlayer player) { + int playerOrder = player.getPlayerOrder(); + // [1] Check if there is cold cache for this player + Object key = getKey(playerOrder); + if (key != null) coldKeyToOrderMap.put(playerOrder, key); + + PlaybackInfo cache = key == null ? null : coldCache.get(key); + if (cache == null || cache == SCRAP) { + // We init this even if there is no CacheManager available, because this is what User expects. + cache = container.playerInitializer.initPlaybackInfo(playerOrder); + // Only save to cold cache when there is a valid CacheManager (key is not null). + if (key != null) coldCache.put(key, cache); + } + + if (hotCache != null) hotCache.put(playerOrder, cache); + } + + // Will be called from Container#onChildViewDetachedFromWindow(View) + // Therefore, it may not be called on all views. For example: when user close the App, by default + // when RecyclerView is detached from Window, it will not call onChildViewDetachedFromWindow for + // its children. + // This method will: + // [1] Take current hot cache entry of the player, and put back to cold cache. + // [2] Remove the hot cache entry of the player. + final void onPlayerDetached(ToroPlayer player) { + int playerOrder = player.getPlayerOrder(); + if (hotCache != null && hotCache.containsKey(playerOrder)) { + PlaybackInfo cache = hotCache.remove(playerOrder); + Object key = getKey(playerOrder); + if (key != null) coldCache.put(key, cache); + } + } + + @SuppressWarnings("unused") final void onPlayerRecycled(ToroPlayer player) { + // TODO do anything here? + } + + /// Adapter change events handling + + @Override public void onChanged() { + if (container.getCacheManager() != null) { + for (Integer key : coldKeyToOrderMap.keySet()) { + Object cacheKey = getKey(key); + coldCache.put(cacheKey, SCRAP); + coldKeyToOrderMap.put(key, cacheKey); + } + } + + if (hotCache != null) { + for (Integer key : hotCache.keySet()) { + hotCache.put(key, SCRAP); + } + } + } + + @Override public void onItemRangeChanged(final int positionStart, final int itemCount) { + if (itemCount == 0) return; + if (container.getCacheManager() != null) { + Set<Integer> changedColdKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : coldKeyToOrderMap.keySet()) { + if (key >= positionStart && key < positionStart + itemCount) { + changedColdKeys.add(key); + } + } + + for (Integer key : changedColdKeys) { + Object cacheKey = getKey(key); + coldCache.put(cacheKey, SCRAP); + coldKeyToOrderMap.put(key, cacheKey); + } + } + + if (hotCache != null) { + Set<Integer> changedHotKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : hotCache.keySet()) { + if (key >= positionStart && key < positionStart + itemCount) { + changedHotKeys.add(key); + } + } + + for (Integer key : changedHotKeys) { + hotCache.put(key, SCRAP); + } + } + } + + @Override public void onItemRangeInserted(final int positionStart, final int itemCount) { + if (itemCount == 0) return; + PlaybackInfo value; + // Cold cache update + if (container.getCacheManager() != null) { + // [1] Take keys of old one. + // 1.1 Extract subset of keys only: + Set<Integer> changedColdKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : coldKeyToOrderMap.keySet()) { + if (key >= positionStart) { + changedColdKeys.add(key); + } + } + + // 1.2 Extract entries from cold cache to a temp cache. + final Map<Object, PlaybackInfo> changeColdEntriesCache = new HashMap<>(); + for (Integer key : changedColdKeys) { + if ((value = coldCache.remove(coldKeyToOrderMap.get(key))) != null) { + changeColdEntriesCache.put(key, value); + } + } + + // 1.2 Update cold Cache with new keys + for (Integer key : changedColdKeys) { + coldCache.put(getKey(key + itemCount), changeColdEntriesCache.get(key)); + } + + // 1.3 Update coldKeyToOrderMap; + for (Integer key : changedColdKeys) { + coldKeyToOrderMap.put(key, getKey(key)); + } + } + + // [1] Remove cache if there is any appearance + if (hotCache != null) { + // [2] Shift cache by specific number + Map<Integer, PlaybackInfo> changedHotEntriesCache = new HashMap<>(); + Set<Integer> changedHotKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : hotCache.keySet()) { + if (key >= positionStart) { + changedHotKeys.add(key); + } + } + + for (Integer key : changedHotKeys) { + if ((value = hotCache.remove(key)) != null) { + changedHotEntriesCache.put(key, value); + } + } + + for (Integer key : changedHotKeys) { + hotCache.put(key + itemCount, changedHotEntriesCache.get(key)); + } + } + } + + @Override public void onItemRangeRemoved(final int positionStart, final int itemCount) { + if (itemCount == 0) return; + PlaybackInfo value; + // Cold cache update + if (container.getCacheManager() != null) { + // [1] Take keys of old one. + // 1.1 Extract subset of keys only: + Set<Integer> changedColdKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : coldKeyToOrderMap.keySet()) { + if (key >= positionStart + itemCount) changedColdKeys.add(key); + } + // 1.2 Extract entries from cold cache to a temp cache. + final Map<Object, PlaybackInfo> changeColdEntriesCache = new HashMap<>(); + for (Integer key : changedColdKeys) { + if ((value = coldCache.remove(coldKeyToOrderMap.get(key))) != null) { + changeColdEntriesCache.put(key, value); + } + } + + // 1.2 Update cold Cache with new keys + for (Integer key : changedColdKeys) { + coldCache.put(getKey(key - itemCount), changeColdEntriesCache.get(key)); + } + + // 1.3 Update coldKeyToOrderMap; + for (Integer key : changedColdKeys) { + coldKeyToOrderMap.put(key, getKey(key)); + } + } + + // [1] Remove cache if there is any appearance + if (hotCache != null) { + for (int i = 0; i < itemCount; i++) { + hotCache.remove(positionStart + i); + } + + // [2] Shift cache by specific number + Map<Integer, PlaybackInfo> changedHotEntriesCache = new HashMap<>(); + Set<Integer> changedHotKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : hotCache.keySet()) { + if (key >= positionStart + itemCount) changedHotKeys.add(key); + } + + for (Integer key : changedHotKeys) { + if ((value = hotCache.remove(key)) != null) { + changedHotEntriesCache.put(key, value); + } + } + + for (Integer key : changedHotKeys) { + hotCache.put(key - itemCount, changedHotEntriesCache.get(key)); + } + } + } + + // Dude I wanna test this thing >.< + @Override public void onItemRangeMoved(final int fromPos, final int toPos, int itemCount) { + if (fromPos == toPos) return; + + final int low = fromPos < toPos ? fromPos : toPos; + final int high = fromPos + toPos - low; + final int shift = fromPos < toPos ? -1 : 1; // how item will be shifted due to the move + PlaybackInfo value; + // [1] Migrate cold cache. + if (container.getCacheManager() != null) { + // 1.1 Extract subset of keys only: + Set<Integer> changedColdKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : coldKeyToOrderMap.keySet()) { + if (key >= low && key <= high) changedColdKeys.add(key); + } + // 1.2 Extract entries from cold cache to a temp cache. + final Map<Object, PlaybackInfo> changeColdEntries = new HashMap<>(); + for (Integer key : changedColdKeys) { + if ((value = coldCache.remove(coldKeyToOrderMap.get(key))) != null) { + changeColdEntries.put(key, value); + } + } + + // 1.2 Update cold Cache with new keys + for (Integer key : changedColdKeys) { + if (key == low) { + coldCache.put(getKey(high), changeColdEntries.get(key)); + } else { + coldCache.put(getKey(key + shift), changeColdEntries.get(key)); + } + } + + // 1.3 Update coldKeyToOrderMap; + for (Integer key : changedColdKeys) { + coldKeyToOrderMap.put(key, getKey(key)); + } + } + + // [2] Migrate hot cache. + if (hotCache != null) { + Set<Integer> changedHotKeys = new TreeSet<>(ORDER_COMPARATOR_INT); + for (Integer key : hotCache.keySet()) { + if (key >= low && key <= high) changedHotKeys.add(key); + } + + Map<Integer, PlaybackInfo> changedHotEntriesCache = new HashMap<>(); + for (Integer key : changedHotKeys) { + if ((value = hotCache.remove(key)) != null) changedHotEntriesCache.put(key, value); + } + + for (Integer key : changedHotKeys) { + if (key == low) { + hotCache.put(high, changedHotEntriesCache.get(key)); + } else { + hotCache.put(key + shift, changedHotEntriesCache.get(key)); + } + } + } + } + + @Nullable private Object getKey(int position) { + return position == RecyclerView.NO_POSITION ? null : container.getCacheManager() == null ? null + : container.getCacheManager().getKeyForOrder(position); + } + + //@Nullable private Integer getOrder(Object key) { + // return container.getCacheManager() == null ? null + // : container.getCacheManager().getOrderForKey(key); + //} + + @NonNull final PlaybackInfo getPlaybackInfo(int position) { + PlaybackInfo info = hotCache != null ? hotCache.get(position) : null; + if (info == SCRAP) { // has hot cache, but was SCRAP. + info = container.playerInitializer.initPlaybackInfo(position); + } + + Object key = getKey(position); + info = info != null ? info : (key != null ? coldCache.get(key) : null); + if (info == null) info = container.playerInitializer.initPlaybackInfo(position); + return info; + } + + // Call by Container#savePlaybackInfo and that method is called right before any pausing. + final void savePlaybackInfo(int position, @NonNull PlaybackInfo playbackInfo) { + ToroUtil.checkNotNull(playbackInfo); + if (hotCache != null) hotCache.put(position, playbackInfo); + Object key = getKey(position); + if (key != null) coldCache.put(key, playbackInfo); + } + + @NonNull SparseArray<PlaybackInfo> saveStates() { + SparseArray<PlaybackInfo> states = new SparseArray<>(); + if (container.getCacheManager() != null) { + for (Map.Entry<Integer, Object> entry : coldKeyToOrderMap.entrySet()) { + states.put(entry.getKey(), coldCache.get(entry.getValue())); + } + } else if (hotCache != null) { + for (Map.Entry<Integer, PlaybackInfo> entry : hotCache.entrySet()) { + states.put(entry.getKey(), entry.getValue()); + } + } + return states; + } + + void restoreStates(@Nullable SparseArray<?> savedStates) { + int cacheSize; + if (savedStates != null && (cacheSize = savedStates.size()) > 0) { + for (int i = 0; i < cacheSize; i++) { + int order = savedStates.keyAt(i); + Object key = getKey(order); + coldKeyToOrderMap.put(order, key); + PlaybackInfo playbackInfo = (PlaybackInfo) savedStates.get(order); + if (playbackInfo != null) this.savePlaybackInfo(order, playbackInfo); + } + } + } + + final void clearCache() { + coldCache.clear(); + if (hotCache != null) hotCache.clear(); + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlayerManager.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlayerManager.java new file mode 100644 index 00000000..b083a363 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PlayerManager.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2017 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.widget; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.NonNull; +import androidx.collection.ArraySet; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import ml.docilealligator.infinityforreddit.videoautoplay.PlayerDispatcher; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; + +/** + * Manage the collection of {@link ToroPlayer}s for a specific {@link Container}. + * + * Task: collect all Players in which "{@link Common#allowsToPlay(ToroPlayer)}" returns true, then + * initialize them. + * + * @author eneim | 5/31/17. + */ +@SuppressWarnings({ "unused", "UnusedReturnValue", "StatementWithEmptyBody" }) // +final class PlayerManager implements Handler.Callback { + + private static final String TAG = "ToroLib:Manager"; + private Handler handler; + + // Make sure each ToroPlayer will present only once in this Manager. + private final Set<ToroPlayer> players = new ArraySet<>(); + + boolean attachPlayer(@NonNull ToroPlayer player) { + return players.add(player); + } + + boolean detachPlayer(@NonNull ToroPlayer player) { + if (handler != null) handler.removeCallbacksAndMessages(player); + return players.remove(player); + } + + boolean manages(@NonNull ToroPlayer player) { + return players.contains(player); + } + + /** + * Return a "Copy" of the collection of players this manager is managing. + * + * @return a non null collection of Players those a managed. + */ + @NonNull List<ToroPlayer> getPlayers() { + return new ArrayList<>(this.players); + } + + void initialize(@NonNull ToroPlayer player, Container container) { + player.initialize(container, container.getPlaybackInfo(player.getPlayerOrder())); + } + + // 2018.07.02 Directly pass PlayerDispatcher so that we can easily expand the ability in the future. + void play(@NonNull ToroPlayer player, PlayerDispatcher dispatcher) { + this.play(player, dispatcher.getDelayToPlay(player)); + } + + private void play(@NonNull ToroPlayer player, int delay) { + if (delay < PlayerDispatcher.DELAY_INFINITE) throw new IllegalArgumentException("Too negative"); + if (handler == null) return; // equals to that this is not attached yet. + handler.removeMessages(MSG_PLAY, player); // remove undone msg for this player + if (delay == PlayerDispatcher.DELAY_INFINITE) { + // do nothing + } else if (delay == PlayerDispatcher.DELAY_NONE) { + player.play(); + } else { + handler.sendMessageDelayed(handler.obtainMessage(MSG_PLAY, player), delay); + } + } + + void pause(@NonNull ToroPlayer player) { + // remove all msg sent for the player + if (handler != null) handler.removeCallbacksAndMessages(player); + player.pause(); + } + + // return false if this manager could not release the player. + // normally when this manager doesn't manage the player. + boolean release(@NonNull ToroPlayer player) { + if (handler != null) handler.removeCallbacksAndMessages(null); + if (manages(player)) { + player.release(); + return true; + } else { + return false; + } + } + + void recycle(ToroPlayer player) { + if (handler != null) handler.removeCallbacksAndMessages(player); + } + + void clear() { + if (handler != null) handler.removeCallbacksAndMessages(null); + this.players.clear(); + } + + void deferPlaybacks() { + if (handler != null) handler.removeMessages(MSG_PLAY); + } + + void onAttach() { + // do nothing + if (handler == null) handler = new Handler(Looper.getMainLooper(), this); + } + + void onDetach() { + if (handler != null) { + handler.removeCallbacksAndMessages(null); + handler = null; + } + } + + @SuppressWarnings("WeakerAccess") static final int MSG_PLAY = 100; + + @Override public boolean handleMessage(Message msg) { + if (msg.what == MSG_PLAY && msg.obj instanceof ToroPlayer) { + ToroPlayer player = (ToroPlayer) msg.obj; + player.play(); + } + return true; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PressablePlayerSelector.java b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PressablePlayerSelector.java new file mode 100644 index 00000000..b72d9cd3 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/videoautoplay/widget/PressablePlayerSelector.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2018 Nam Nguyen, nam@ene.im + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ml.docilealligator.infinityforreddit.videoautoplay.widget; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; +import static java.util.Collections.singletonList; + +import static ml.docilealligator.infinityforreddit.videoautoplay.widget.Common.allowsToPlay; +import static ml.docilealligator.infinityforreddit.videoautoplay.widget.Common.findFirst; + +import android.view.View; +import android.view.View.OnLongClickListener; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import ml.docilealligator.infinityforreddit.videoautoplay.PlayerSelector; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroPlayer; +import ml.docilealligator.infinityforreddit.videoautoplay.ToroUtil; +import ml.docilealligator.infinityforreddit.videoautoplay.annotations.Beta; + +/** + * @author eneim (2018/08/17). + * + * A 'Press to Play' {@link PlayerSelector}. + * + * This is a {@link OnLongClickListener} that co-operates with {@link Container} to selectively + * trigger the playback. The common usecase is to allow user to long click on a {@link ToroPlayer} + * to trigger its playback. In that case, we should set that {@link ToroPlayer} to highest priority + * among the candidates, and also to clear its priority when user scroll it out of the playable region. + * + * This class also have a {@link #toPause} field to handle the case where User want to specifically + * pause a {@link ToroPlayer}. This selection will also be cleared with the same rules of toPlay. + * @since 3.6.0.2802 + */ +@SuppressWarnings("WeakerAccess") @Beta // +public class PressablePlayerSelector implements PlayerSelector, OnLongClickListener { + + protected final WeakReference<Container> weakContainer; + protected final PlayerSelector delegate; + protected final AtomicInteger toPlay = new AtomicInteger(NO_POSITION); + protected final AtomicInteger toPause = new AtomicInteger(NO_POSITION); + + final Common.Filter<ToroPlayer> filterToPlay = new Common.Filter<ToroPlayer>() { + @Override public boolean accept(ToroPlayer target) { + return target.getPlayerOrder() == toPlay.get(); + } + }; + + final Common.Filter<ToroPlayer> filterToPause = new Common.Filter<ToroPlayer>() { + @Override public boolean accept(ToroPlayer target) { + return target.getPlayerOrder() == toPause.get(); + } + }; + + public PressablePlayerSelector(Container container) { + this(container, DEFAULT); + } + + public PressablePlayerSelector(Container container, PlayerSelector delegate) { + this(new WeakReference<>(ToroUtil.checkNotNull(container)), ToroUtil.checkNotNull(delegate)); + } + + PressablePlayerSelector(WeakReference<Container> container, PlayerSelector delegate) { + this.weakContainer = container; + this.delegate = ToroUtil.checkNotNull(delegate); + } + + @Override public boolean onLongClick(View v) { + Container container = weakContainer.get(); + if (container == null) return false; // fail fast + + toPause.set(NO_POSITION); // long click will always mean to 'press to play'. + + RecyclerView.ViewHolder viewHolder = container.findContainingViewHolder(v); + boolean handled = viewHolder instanceof ToroPlayer; + if (handled) handled = allowsToPlay((ToroPlayer) viewHolder); + + int position = handled ? viewHolder.getAdapterPosition() : NO_POSITION; + if (handled) handled = position != toPlay.getAndSet(position); + + if (handled) container.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + return handled; + } + + @Override @NonNull public Collection<ToroPlayer> select(@NonNull Container container, + @NonNull List<ToroPlayer> items) { + // Make sure client doesn't use this instance to wrong Container. + if (container != this.weakContainer.get()) return new ArrayList<>(); + + // If there is a request to pause, we need to prioritize that first. + ToroPlayer toPauseCandidate = null; + if (toPause.get() >= 0) { + toPauseCandidate = findFirst(items, filterToPause); + if (toPauseCandidate == null) { + // the order to pause doesn't present in candidate, we clear the selection. + toPause.set(NO_POSITION); // remove the paused one. + } + } + + if (toPlay.get() >= 0) { + ToroPlayer toPlayCandidate = findFirst(items, filterToPlay); + if (toPlayCandidate != null) { + if (allowsToPlay(toPlayCandidate)) { + return singletonList(toPlayCandidate); + } + } + } + // In the list of candidates, selected item no longer presents or is not allowed to play, + // we should reset the selection. + toPlay.set(NO_POSITION); + // Wrap by an ArrayList to make it modifiable. + Collection<ToroPlayer> selected = new ArrayList<>(delegate.select(container, items)); + if (toPauseCandidate != null) selected.remove(toPauseCandidate); + return selected; + } + + @Override @NonNull public PlayerSelector reverse() { + return new PressablePlayerSelector(this.weakContainer, delegate.reverse()); + } + + public boolean toPlay(int position) { + if (toPause.get() == position) toPause.set(NO_POSITION); + Container container = weakContainer.get(); + if (container == null) return false; + if (position != toPlay.getAndSet(position)) { + container.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + return true; + } + + return false; + } + + public void toPause(int position) { + toPlay.set(NO_POSITION); + toPause.set(position); + } +} diff --git a/app/src/main/res/drawable/ic_volume_off_black_24dp.xml b/app/src/main/res/drawable/ic_volume_off_black_24dp.xml new file mode 100644 index 00000000..628a8aef --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_black_24dp.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (c) 2018 Nam Nguyen, nam@ene.im + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:height="32dp" + android:viewportHeight="24.0" + android:viewportWidth="24.0" + android:width="32dp"> + <path + android:fillColor="#FFFFFF" + android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9L3,9v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18L12,4z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml new file mode 100644 index 00000000..7c11fa9e --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (c) 2018 Nam Nguyen, nam@ene.im + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:fillColor="#FFFFFF" + android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/> +</vector> diff --git a/app/src/main/res/layout/toro_exo_control_view.xml b/app/src/main/res/layout/toro_exo_control_view.xml new file mode 100644 index 00000000..481230bf --- /dev/null +++ b/app/src/main/res/layout/toro_exo_control_view.xml @@ -0,0 +1,181 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2018 Nam Nguyen, nam@ene.im + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<androidx.appcompat.widget.LinearLayoutCompat + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:layout_marginTop="4dp" + android:background="#CC000000" + android:gravity="center_vertical" + android:layoutDirection="ltr" + android:orientation="horizontal" + android:theme="@style/Theme.AppCompat" + tools:ignore="UnusedAttribute" + > + + <!--<androidx.appcompat.widget.AppCompatImageButton + android:id="@id/exo_play" + tools:ignore="ContentDescription" + style="@style/ToroMediaButton.Play" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@id/exo_pause" + tools:ignore="ContentDescription" + tools:visibility="gone" + style="@style/ToroMediaButton.Pause" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:id="@id/exo_position" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:text="1:25" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:text="/" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:ignore="HardcodedText" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:id="@id/exo_duration" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:text="5:25" + /> + + <com.google.android.exoplayer2.ui.DefaultTimeBar + android:id="@id/exo_progress" + android:layout_width="0dp" + android:layout_height="24dp" + android:layout_weight="1" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@+id/exo_volume_up" + tools:ignore="ContentDescription" + style="@style/ToroMediaButton.VolumeUp" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@+id/exo_volume_off" + tools:ignore="ContentDescription" + tools:visibility="gone" + style="@style/ToroMediaButton.VolumeOff" + />--> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@id/exo_play" + tools:ignore="ContentDescription" + style="@style/ToroMediaButton.Play" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@id/exo_pause" + tools:ignore="ContentDescription" + tools:visibility="gone" + style="@style/ToroMediaButton.Pause" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:id="@id/exo_position" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:text="1:25" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:text="/" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:ignore="HardcodedText" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:id="@id/exo_duration" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:textColor="#FFBEBEBE" + android:textSize="14sp" + android:textStyle="bold" + tools:text="5:25" + /> + + <com.google.android.exoplayer2.ui.DefaultTimeBar + android:id="@id/exo_progress" + android:layout_width="0dp" + android:layout_height="24dp" + android:layout_weight="1" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@+id/exo_volume_up" + tools:ignore="ContentDescription" + style="@style/ToroMediaButton.VolumeUp" + /> + + <androidx.appcompat.widget.AppCompatImageButton + android:id="@+id/exo_volume_off" + tools:ignore="ContentDescription" + tools:visibility="gone" + style="@style/ToroMediaButton.VolumeOff" + /> + + <com.google.android.exoplayer2.ui.DefaultTimeBar + android:id="@+id/volume_bar" + android:layout_width="80dp" + android:layout_height="24dp" + android:layout_marginEnd="8dp" + android:layout_marginRight="8dp" + /> + +</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file diff --git a/app/src/main/res/layout/toro_exo_player_view.xml b/app/src/main/res/layout/toro_exo_player_view.xml new file mode 100644 index 00000000..b318b954 --- /dev/null +++ b/app/src/main/res/layout/toro_exo_player_view.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2018 Nam Nguyen, nam@ene.im + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + > + + <com.google.android.exoplayer2.ui.AspectRatioFrameLayout + android:id="@id/exo_content_frame" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + > + + <!-- Video surface will be inserted as the first child of the content frame. --> + + <View + android:id="@id/exo_shutter" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/black" + /> + + <androidx.appcompat.widget.AppCompatImageView + android:id="@id/exo_artwork" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleType="fitXY" + /> + + <com.google.android.exoplayer2.ui.SubtitleView + android:id="@id/exo_subtitles" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <androidx.appcompat.widget.AppCompatTextView + android:id="@id/exo_error_message" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:background="@color/exo_error_message_background_color" + android:gravity="center" + android:padding="16dp" + /> + + </com.google.android.exoplayer2.ui.AspectRatioFrameLayout> + + <FrameLayout + android:id="@id/exo_ad_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <FrameLayout + android:id="@id/exo_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <ProgressBar + android:id="@id/exo_buffering" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + /> + + <ml.docilealligator.infinityforreddit.videoautoplay.ui.ToroControlView + android:id="@id/exo_controller" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + app:controller_layout_id="@layout/toro_exo_control_view" /> + +</merge>
\ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee96d717..29108bee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ -<resources> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="application_name" translatable="false">Infinity</string> <string name="login_activity_label">Login</string> <string name="search_activity_label" translatable="false"> </string> @@ -1295,4 +1295,21 @@ <string name="handle_link">Handle Link</string> <string name="invalid_link">Invalid link</string> + <string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string> + <string name="enable_random_adaptation">Enable random adaptation</string> + <string name="error_drm_not_supported">Protected content not supported on API levels below 18</string> + <string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string> + <string name="error_drm_unknown">An unknown DRM error occurred</string> + <string name="error_no_decoder">This device does not provide a decoder for <xliff:g id="mime_type">%1$s</xliff:g></string> + <string name="error_no_secure_decoder">This device does not provide a secure decoder for <xliff:g id="mime_type">%1$s</xliff:g></string> + <string name="error_querying_decoders">Unable to query device decoders</string> + <string name="error_instantiating_decoder">Unable to instantiate decoder <xliff:g id="decoder_name">%1$s</xliff:g></string> + <string name="error_unsupported_video">Media includes video tracks, but none are playable by this device</string> + <string name="error_unsupported_audio">Media includes audio tracks, but none are playable by this device</string> + <string name="storage_permission_denied">Permission to access storage was denied</string> + <string name="sample_list_load_error">One or more sample lists failed to load</string> + + <string name="exo_controls_volume_up_description">Volume Up</string> + <string name="exo_controls_volume_off_description">Volume Off</string> + </resources> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 40a2c351..35c75d46 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -517,4 +517,30 @@ <item name="switchStyle">@style/Widget.App.Switch.Dark</item> </style> + <style name="ToroMediaButton"> + <item name="android:background">@null</item> + <item name="android:layout_width">65dp</item> + <item name="android:layout_height">48dp</item> + </style> + + <style name="ToroMediaButton.Play"> + <item name="android:src">@drawable/exo_controls_play</item> + <item name="android:contentDescription">@string/exo_controls_play_description</item> + </style> + + <style name="ToroMediaButton.Pause"> + <item name="android:src">@drawable/exo_controls_pause</item> + <item name="android:contentDescription">@string/exo_controls_pause_description</item> + </style> + + <style name="ToroMediaButton.VolumeUp"> + <item name="android:src">@drawable/ic_volume_up_black_24dp</item> + <item name="android:contentDescription">@string/exo_controls_volume_up_description</item> + </style> + + <style name="ToroMediaButton.VolumeOff"> + <item name="android:src">@drawable/ic_volume_off_black_24dp</item> + <item name="android:contentDescription">@string/exo_controls_volume_off_description</item> + </style> + </resources> |